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.
- devtools_inspector/__init__.py +3 -0
- devtools_inspector/cdp.py +607 -0
- devtools_inspector/fixing.py +310 -0
- devtools_inspector/models.py +65 -0
- devtools_inspector/redaction.py +43 -0
- devtools_inspector/server.py +123 -0
- devtools_inspector/setup.py +159 -0
- devtools_inspector_mcp-0.1.0.dist-info/METADATA +242 -0
- devtools_inspector_mcp-0.1.0.dist-info/RECORD +11 -0
- devtools_inspector_mcp-0.1.0.dist-info/WHEEL +4 -0
- devtools_inspector_mcp-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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()
|