devtools-inspector-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.

Potentially problematic release.


This version of devtools-inspector-mcp might be problematic. Click here for more details.

@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from tempfile import NamedTemporaryFile
12
+ from uuid import uuid4
13
+
14
+ from .redaction import redact_text
15
+
16
+
17
+ _ALLOWED_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".css"}
18
+ _BLOCKED_PARTS = {".git", ".venv", "venv", "node_modules", "site-packages", "dist", "build"}
19
+
20
+
21
+ class PatchError(ValueError):
22
+ """A patch could not be safely prepared, applied, or rolled back."""
23
+
24
+
25
+ @dataclass
26
+ class ProposedPatch:
27
+ patch_id: str
28
+ path: Path
29
+ expected_sha256: str
30
+ old_text: str
31
+ new_text: str
32
+ rationale: str
33
+
34
+
35
+ class PatchManager:
36
+ def __init__(self, project_root: Path):
37
+ self.root = project_root.resolve()
38
+ self._proposals: dict[str, ProposedPatch] = {}
39
+ self._backup_root = self.root / ".devtools_mcp" / "rollback"
40
+
41
+ def _ensure_state_dir(self) -> None:
42
+ state_dir = self.root / ".devtools_mcp"
43
+ try:
44
+ state_dir.mkdir(exist_ok=True)
45
+ if state_dir.is_symlink():
46
+ raise PatchError("The patch state directory cannot be a symbolic link.")
47
+ state_dir.resolve().relative_to(self.root)
48
+ self._backup_root.mkdir(exist_ok=True)
49
+ if self._backup_root.is_symlink():
50
+ raise PatchError("The rollback directory cannot be a symbolic link.")
51
+ self._backup_root.resolve().relative_to(self.root)
52
+ except ValueError:
53
+ raise PatchError("The patch state directory must remain inside the project root.") from None
54
+ except OSError as error:
55
+ raise PatchError(f"Could not prepare patch state: {error}") from error
56
+
57
+ def _record(self, action: str, details: dict[str, str]) -> None:
58
+ self._ensure_state_dir()
59
+ record = {
60
+ "timestamp": datetime.now(timezone.utc).isoformat(),
61
+ "action": action,
62
+ **details,
63
+ }
64
+ with (self.root / ".devtools_mcp" / "history.jsonl").open("a", encoding="utf-8") as history:
65
+ history.write(json.dumps(record, ensure_ascii=True) + "\n")
66
+
67
+ def _resolve_source(self, relative_path: str) -> Path:
68
+ candidate = Path(relative_path)
69
+ if candidate.is_absolute() or not relative_path or ".." in candidate.parts:
70
+ raise PatchError("Use a project-relative source path without '..'.")
71
+ if candidate.suffix.lower() not in _ALLOWED_SUFFIXES or any(part.lower() in _BLOCKED_PARTS for part in candidate.parts):
72
+ raise PatchError("This path is not an allowed source file; dependency, build, and environment paths are blocked.")
73
+ path = (self.root / candidate).resolve()
74
+ try:
75
+ path.relative_to(self.root)
76
+ except ValueError:
77
+ raise PatchError("The source path resolves outside the configured project root.") from None
78
+ if path.is_symlink() or not path.is_file():
79
+ raise PatchError("The selected path must be an existing regular source file.")
80
+ if path.stat().st_size > 250_000:
81
+ raise PatchError("Files larger than 250 KB are not eligible for patching.")
82
+ return path
83
+
84
+ def propose(
85
+ self,
86
+ relative_path: str,
87
+ expected_sha256: str,
88
+ old_text: str,
89
+ new_text: str,
90
+ rationale: str,
91
+ ) -> dict[str, str]:
92
+ path = self._resolve_source(relative_path)
93
+ original_bytes = path.read_bytes()
94
+ content = original_bytes.decode("utf-8")
95
+ actual_hash = hashlib.sha256(original_bytes).hexdigest()
96
+ if actual_hash != expected_sha256:
97
+ raise PatchError("The file changed since it was inspected. Read it again and propose against its current SHA-256.")
98
+ if not old_text or len(old_text) > 20_000 or len(new_text) > 20_000:
99
+ raise PatchError("Provide a non-empty old snippet and snippets no larger than 20 KB.")
100
+ if content.count(old_text) != 1:
101
+ raise PatchError("The old snippet must match exactly once; no file changes were made.")
102
+ patch_id = uuid4().hex[:16]
103
+ self._proposals[patch_id] = ProposedPatch(
104
+ patch_id=patch_id,
105
+ path=path,
106
+ expected_sha256=actual_hash,
107
+ old_text=old_text,
108
+ new_text=new_text,
109
+ rationale=redact_text(rationale, 500),
110
+ )
111
+ self._record(
112
+ "proposed",
113
+ {"patch_id": patch_id, "path": path.relative_to(self.root).as_posix(), "sha256": actual_hash},
114
+ )
115
+ return {"patch_id": patch_id, "path": path.relative_to(self.root).as_posix(), "rationale": redact_text(rationale, 500), "approval_required": True}
116
+
117
+ def apply(self, patch_id: str, approved: bool) -> dict[str, str | bool]:
118
+ if not approved:
119
+ raise PatchError("Patch not applied. Set approved=true only after reviewing the proposed change.")
120
+ proposal = self._proposals.get(patch_id)
121
+ if proposal is None:
122
+ raise PatchError("Proposal not found or expired with this server session; create it again.")
123
+ current_bytes = proposal.path.read_bytes()
124
+ current = current_bytes.decode("utf-8")
125
+ if hashlib.sha256(current_bytes).hexdigest() != proposal.expected_sha256:
126
+ raise PatchError("The file changed after the proposal. Patch refused; inspect and propose again.")
127
+ if current.count(proposal.old_text) != 1:
128
+ raise PatchError("The patch anchor is no longer unique. No file changes were made.")
129
+ updated = current.replace(proposal.old_text, proposal.new_text, 1)
130
+ updated_bytes = updated.encode("utf-8")
131
+ applied_hash = hashlib.sha256(updated_bytes).hexdigest()
132
+ self._ensure_state_dir()
133
+ backup = {
134
+ "patch_id": patch_id,
135
+ "path": proposal.path.relative_to(self.root).as_posix(),
136
+ "original": current,
137
+ "original_sha256": proposal.expected_sha256,
138
+ "applied_sha256": applied_hash,
139
+ "rationale": proposal.rationale,
140
+ }
141
+ self._atomic_write(self._backup_root / f"{patch_id}.json", json.dumps(backup, ensure_ascii=True))
142
+ self._atomic_write(proposal.path, updated_bytes)
143
+ self._proposals.pop(patch_id, None)
144
+ self._record(
145
+ "applied",
146
+ {"patch_id": patch_id, "path": proposal.path.relative_to(self.root).as_posix(), "sha256": applied_hash},
147
+ )
148
+ return {
149
+ "patch_id": patch_id,
150
+ "applied": True,
151
+ "path": proposal.path.relative_to(self.root).as_posix(),
152
+ "applied_sha256": applied_hash,
153
+ "verification": "unverified",
154
+ }
155
+
156
+ def rollback(self, patch_id: str) -> dict[str, str | bool]:
157
+ backup_path = self._backup_root / f"{patch_id}.json"
158
+ if not backup_path.is_file():
159
+ raise PatchError("No recovery point exists for this patch ID.")
160
+ backup = json.loads(backup_path.read_text(encoding="utf-8"))
161
+ path = self._resolve_source(backup["path"])
162
+ current_bytes = path.read_bytes()
163
+ current_hash = hashlib.sha256(current_bytes).hexdigest()
164
+ if current_hash != backup["applied_sha256"]:
165
+ raise PatchError("The file has changed since the patch was applied. Rollback refused to preserve newer edits.")
166
+ self._atomic_write(path, backup["original"].encode("utf-8"))
167
+ backup_path.unlink()
168
+ self._record("rolled_back", {"patch_id": patch_id, "path": backup["path"]})
169
+ return {"patch_id": patch_id, "rolled_back": True, "path": backup["path"]}
170
+
171
+ async def run_project_checks(self, patch_id: str | None = None, timeout: float = 120) -> dict[str, object]:
172
+ """Run project tests and roll back the linked patch if checks fail.
173
+
174
+ Supports common project types:
175
+ - Python: pytest
176
+ - JavaScript/TypeScript: npm test, pnpm test, yarn test
177
+ - Other ecosystems only when safely supported.
178
+
179
+ Detects project type from actual project files.
180
+ Does not install dependencies automatically.
181
+ Uses an explicit command allowlist and timeout.
182
+ Captures stdout, stderr, exit code and test summary.
183
+ Never claims tests passed if they were not executed.
184
+ """
185
+ # Determine project type from project files
186
+ project_type = self._detect_project_type()
187
+
188
+ # Build the appropriate test command based on project type
189
+ command, command_name = self._build_test_command(project_type)
190
+
191
+ try:
192
+ process = await asyncio.create_subprocess_exec(
193
+ *command,
194
+ cwd=self.root,
195
+ stdout=asyncio.subprocess.PIPE,
196
+ stderr=asyncio.subprocess.STDOUT,
197
+ )
198
+ try:
199
+ output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
200
+ exit_code = process.returncode
201
+ except TimeoutError:
202
+ process.kill()
203
+ await process.wait()
204
+ output = b"pytest/verification exceeded the verification limit."
205
+ exit_code = -1
206
+ executed = True
207
+ except OSError as error:
208
+ output = str(error).encode("utf-8", errors="replace")
209
+ exit_code = -1
210
+ executed = False
211
+
212
+ result: dict[str, object] = {
213
+ "command": command_name,
214
+ "executed": executed,
215
+ "passed": executed and exit_code == 0,
216
+ "exit_code": exit_code,
217
+ "output": redact_text(output.decode("utf-8", errors="replace")[-10_000:], 10_000),
218
+ "project_type": project_type,
219
+ }
220
+
221
+ self._record(
222
+ "checks",
223
+ {
224
+ "patch_id": patch_id or "",
225
+ "passed": str(result["passed"]),
226
+ "exit_code": str(exit_code),
227
+ "project_type": project_type,
228
+ },
229
+ )
230
+ if patch_id and not result["passed"]:
231
+ try:
232
+ result["rollback"] = self.rollback(patch_id)
233
+ except PatchError as error:
234
+ result["rollback"] = {"rolled_back": False, "error": str(error)}
235
+ return result
236
+
237
+ def _detect_project_type(self) -> str:
238
+ """Detect the project type from actual project files.
239
+
240
+ Returns one of: 'python', 'javascript', 'typescript', 'unknown'
241
+ """
242
+ # Check for Python project files
243
+ python_files = [
244
+ "pyproject.toml",
245
+ "setup.py",
246
+ "setup.cfg",
247
+ "requirements.txt",
248
+ "conftest.py",
249
+ ]
250
+ for fname in python_files:
251
+ if (self.root / fname).is_file():
252
+ return "python"
253
+
254
+ # Check for Node.js project files
255
+ node_files = ["package.json"]
256
+ for fname in node_files:
257
+ if (self.root / fname).is_file():
258
+ # Try to read just the scripts section to determine test command
259
+ try:
260
+ content = (self.root / fname).read_text(encoding="utf-8", errors="replace")
261
+ if '"test"' in content or "'test'" in content:
262
+ return "javascript"
263
+ except Exception:
264
+ return "javascript" # Assume JavaScript project if package.json exists
265
+
266
+ # Check for TypeScript project files
267
+ ts_files = ["tsconfig.json"]
268
+ for fname in ts_files:
269
+ if (self.root / fname).is_file():
270
+ return "typescript"
271
+
272
+ return "unknown"
273
+
274
+ def _build_test_command(self, project_type: str) -> tuple[list[str], str]:
275
+ """Build the appropriate test command based on project type.
276
+
277
+ Returns (command_list, command_name_string) tuple.
278
+ Uses an explicit command allowlist - no arbitrary commands from project config.
279
+ """
280
+ if project_type == "python":
281
+ return ["python", "-m", "pytest", "-q"], "python -m pytest -q"
282
+ if project_type == "javascript":
283
+ # Check for npm, pnpm, or yarn
284
+ package_json = (self.root / "package.json").read_text(encoding="utf-8", errors="replace")
285
+ if '"pnpm"' in package_json or "'pnpm'" in package_json:
286
+ return ["pnpm", "test"], "pnpm test"
287
+ if '"yarn"' in package_json or "'yarn'" in package_json:
288
+ return ["yarn", "test"], "yarn test"
289
+ return ["npm", "test"], "npm test"
290
+ if project_type == "typescript":
291
+ return ["npm", "test"], "npm test"
292
+
293
+ # unknown or unsupported - try pytest as fallback if in a Python-like structure
294
+ return ["python", "-m", "pytest", "-q"], "python -m pytest -q (fallback)"
295
+
296
+ @staticmethod
297
+ def _atomic_write(path: Path, content: str | bytes) -> None:
298
+ path.parent.mkdir(parents=True, exist_ok=True)
299
+ mode = path.stat().st_mode if path.exists() else None
300
+ if isinstance(content, bytes):
301
+ with NamedTemporaryFile("wb", dir=path.parent, delete=False) as temporary:
302
+ temporary.write(content)
303
+ temporary_path = Path(temporary.name)
304
+ else:
305
+ with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as temporary:
306
+ temporary.write(content)
307
+ temporary_path = Path(temporary.name)
308
+ if mode is not None:
309
+ os.chmod(temporary_path, mode)
310
+ os.replace(temporary_path, path)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from hashlib import sha256
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ Category = Literal["console", "runtime", "network", "performance", "security", "accessibility"]
11
+ Severity = Literal["error", "warning", "info"]
12
+ Confidence = Literal["confirmed", "correlated", "potential", "suspicion"]
13
+ Verification = Literal["unverified", "passed", "failed", "not_applicable"]
14
+
15
+
16
+ class SourceLocation(BaseModel):
17
+ url: str
18
+ line: int | None = None
19
+ column: int | None = None
20
+ function: str | None = None
21
+
22
+
23
+ class Issue(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ issue_id: str
27
+ category: Category
28
+ severity: Severity
29
+ confidence: Confidence
30
+ title: str
31
+ explanation: str
32
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
33
+ url: str | None = None
34
+ target_id: str | None = None
35
+ source_location: SourceLocation | None = None
36
+ evidence: dict[str, Any] = Field(default_factory=dict)
37
+ verification: Verification = "unverified"
38
+
39
+
40
+ def build_issue(
41
+ *,
42
+ category: Category,
43
+ severity: Severity,
44
+ confidence: Confidence,
45
+ title: str,
46
+ explanation: str,
47
+ url: str | None = None,
48
+ target_id: str | None = None,
49
+ source_location: SourceLocation | None = None,
50
+ evidence: dict[str, Any] | None = None,
51
+ ) -> Issue:
52
+ dedupe_key = "\0".join((category, title, url or "", target_id or ""))
53
+ issue_id = sha256(dedupe_key.encode("utf-8", errors="replace")).hexdigest()[:16]
54
+ return Issue(
55
+ issue_id=issue_id,
56
+ category=category,
57
+ severity=severity,
58
+ confidence=confidence,
59
+ title=title[:240],
60
+ explanation=explanation[:2000],
61
+ url=url,
62
+ target_id=target_id,
63
+ source_location=source_location,
64
+ evidence=evidence or {},
65
+ )
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
5
+
6
+
7
+ _SECRET_KEY = re.compile(r"token|secret|password|passwd|authorization|api[-_]?key|cookie", re.I)
8
+ _BEARER = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*")
9
+ _ASSIGNMENT = re.compile(
10
+ r"(?i)\b(api[-_]?key|access[-_]?token|refresh[-_]?token|token|password|secret|authorization)"
11
+ r"\s*[:=]\s*(['\"]?)[^\s,;'\"]+"
12
+ )
13
+
14
+
15
+ def redact_text(value: str, limit: int = 1000) -> str:
16
+ value = _BEARER.sub("Bearer [REDACTED]", value)
17
+ value = _ASSIGNMENT.sub(lambda match: f"{match.group(1)}=[REDACTED]", value)
18
+ return value[:limit]
19
+
20
+
21
+ def redact_url(value: str) -> str:
22
+ try:
23
+ parts = urlsplit(value)
24
+ if not parts.scheme or not parts.netloc:
25
+ return redact_text(value, 2000)
26
+ host = parts.hostname or ""
27
+ if ":" in host and not host.startswith("["):
28
+ host = f"[{host}]"
29
+ if parts.port:
30
+ host = f"{host}:{parts.port}"
31
+ query = urlencode(
32
+ [(key, "[REDACTED]" if _SECRET_KEY.search(key) else item) for key, item in parse_qsl(parts.query, keep_blank_values=True)]
33
+ )
34
+ fragment = parts.fragment
35
+ if "=" in fragment:
36
+ fragment = urlencode(
37
+ [(key, "[REDACTED]" if _SECRET_KEY.search(key) else item) for key, item in parse_qsl(fragment, keep_blank_values=True)]
38
+ )
39
+ else:
40
+ fragment = redact_text(fragment, 500)
41
+ return urlunsplit((parts.scheme, host, parts.path, query, fragment))[:2000]
42
+ except ValueError:
43
+ return redact_text(value, 2000)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from fastmcp import FastMCP
8
+
9
+ from .cdp import BrowserConnectionError, BrowserSession
10
+ from .fixing import PatchError, PatchManager
11
+
12
+
13
+ mcp = FastMCP(
14
+ "Live DevTools Inspector",
15
+ instructions="Inspect an explicitly selected local Chrome tab. Browser debugging grants powerful access; keep its endpoint local and protected.",
16
+ )
17
+ browser = BrowserSession()
18
+ project_root = Path(os.environ.get("DEVTOOLS_PROJECT_ROOT", Path.cwd())).resolve()
19
+ patches = PatchManager(project_root)
20
+
21
+
22
+ @mcp.tool
23
+ async def list_browser_targets(endpoint: str = "http://127.0.0.1:9222") -> list[dict[str, str]]:
24
+ """List page targets from a local Chrome debugging endpoint."""
25
+ return await browser.list_targets(endpoint)
26
+
27
+
28
+ @mcp.tool
29
+ async def connect_browser(endpoint: str, target_id: str) -> dict[str, object]:
30
+ """Connect to the explicitly selected page target and begin Console/Network monitoring."""
31
+ return await browser.connect(endpoint, target_id)
32
+
33
+
34
+ @mcp.tool
35
+ async def select_browser_target(endpoint: str, target_id: str) -> dict[str, object]:
36
+ """Switch the active CDP session to a selected page target."""
37
+ return await browser.connect(endpoint, target_id)
38
+
39
+
40
+ @mcp.tool
41
+ async def disconnect_browser() -> dict[str, object]:
42
+ """Close the active page connection and remove event handlers."""
43
+ return await browser.disconnect()
44
+
45
+
46
+ @mcp.tool
47
+ def get_connection_status() -> dict[str, object]:
48
+ """Report browser connection and monitoring state."""
49
+ return browser.status()
50
+
51
+
52
+ @mcp.tool
53
+ def start_live_inspection() -> dict[str, object]:
54
+ """Resume bounded event-driven Console and Network collection."""
55
+ if browser.collector is None:
56
+ raise BrowserConnectionError("Connect to a selected page target first.")
57
+ browser.collector.monitoring = True
58
+ return {"monitoring": True, "target": browser.target}
59
+
60
+
61
+ @mcp.tool
62
+ def stop_live_inspection() -> dict[str, object]:
63
+ """Pause diagnostic collection without disconnecting the browser."""
64
+ if browser.collector is None:
65
+ raise BrowserConnectionError("Connect to a selected page target first.")
66
+ browser.collector.monitoring = False
67
+ return {"monitoring": False, "target": browser.target}
68
+
69
+
70
+ @mcp.tool
71
+ def get_live_diagnostics(category: Literal["console", "runtime", "network", "performance", "security", "accessibility"] | None = None) -> list[dict[str, object]]:
72
+ """Return normalized, deduplicated diagnostics collected for the active page."""
73
+ return [issue.model_dump(mode="json") for issue in browser.diagnostics(category)]
74
+
75
+
76
+ @mcp.tool
77
+ def inspect_console() -> list[dict[str, object]]:
78
+ """Return captured console warnings, errors, and runtime exceptions."""
79
+ return [issue.model_dump(mode="json") for issue in browser.diagnostics() if issue.category in ("console", "runtime")]
80
+
81
+
82
+ @mcp.tool
83
+ def inspect_network() -> list[dict[str, object]]:
84
+ """Return captured HTTP errors and failed or aborted requests."""
85
+ return [issue.model_dump(mode="json") for issue in browser.diagnostics("network")]
86
+
87
+
88
+ @mcp.tool
89
+ async def inspect_performance() -> list[dict[str, object]]:
90
+ """Return observed long tasks, using explicitly labeled CDP tracing only when needed."""
91
+ return [issue.model_dump(mode="json") for issue in await browser.performance_diagnostics()]
92
+
93
+
94
+ @mcp.tool
95
+ def propose_fix(relative_path: str, expected_sha256: str, old_text: str, new_text: str, rationale: str) -> dict[str, str | bool]:
96
+ """Prepare a minimal exact-snippet patch without changing files."""
97
+ return patches.propose(relative_path, expected_sha256, old_text, new_text, rationale)
98
+
99
+
100
+ @mcp.tool
101
+ def apply_patch(patch_id: str, approved: bool) -> dict[str, str | bool]:
102
+ """Apply a reviewed patch only after explicit approval and save a recovery point."""
103
+ return patches.apply(patch_id, approved)
104
+
105
+
106
+ @mcp.tool
107
+ async def run_project_checks(patch_id: str | None = None) -> dict[str, object]:
108
+ """Run the fixed pytest command; roll back the linked patch if checks fail."""
109
+ return await patches.run_project_checks(patch_id)
110
+
111
+
112
+ @mcp.tool
113
+ def rollback_patch(patch_id: str) -> dict[str, str | bool]:
114
+ """Restore a patch recovery point if the file has not changed since application."""
115
+ return patches.rollback(patch_id)
116
+
117
+
118
+ def main() -> None:
119
+ mcp.run(transport="stdio")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()