fagun 0.2.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.
fagun/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fagun — cross-tool browser + QA MCP server."""
2
+
3
+ __version__ = "0.2.0"
fagun/__main__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Entry point. `fagun` (or `uvx fagun`) starts the MCP server over stdio.
2
+
3
+ Env vars:
4
+ FAGUN_HEADLESS "0" to show the browser window (default "1" = headless)
5
+ FAGUN_CDP_URL connect to an already-running Chrome via CDP instead of
6
+ launching a fresh one, e.g. http://127.0.0.1:9222
7
+ FAGUN_BROWSER chromium | firefox | webkit (default chromium)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+
15
+ def main() -> None:
16
+ # Support `fagun install` helper without importing the heavy MCP stack.
17
+ if len(sys.argv) > 1 and sys.argv[1] in {"install", "--install", "help", "--help", "-h"}:
18
+ from .install import run_cli
19
+
20
+ run_cli(sys.argv[1:])
21
+ return
22
+
23
+ from .server import serve
24
+
25
+ serve()
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
fagun/browser.py ADDED
@@ -0,0 +1,143 @@
1
+ """Playwright wrapper. One shared browser/context/page per server process.
2
+
3
+ Captures console messages and network requests as they happen so the QA tools
4
+ and the AI can inspect them at any time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Optional
12
+
13
+ from playwright.async_api import (
14
+ Browser,
15
+ BrowserContext,
16
+ Page,
17
+ Playwright,
18
+ async_playwright,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class NetworkEntry:
24
+ method: str
25
+ url: str
26
+ status: Optional[int] = None
27
+ resource_type: str = ""
28
+ failure: Optional[str] = None
29
+
30
+
31
+ @dataclass
32
+ class ConsoleEntry:
33
+ type: str
34
+ text: str
35
+ location: str = ""
36
+
37
+
38
+ @dataclass
39
+ class BrowserManager:
40
+ _pw: Optional[Playwright] = None
41
+ _browser: Optional[Browser] = None
42
+ _context: Optional[BrowserContext] = None
43
+ _page: Optional[Page] = None
44
+ console: list[ConsoleEntry] = field(default_factory=list)
45
+ network: list[NetworkEntry] = field(default_factory=list)
46
+
47
+ @property
48
+ def is_open(self) -> bool:
49
+ return self._page is not None and not self._page.is_closed()
50
+
51
+ async def start(self, headless: Optional[bool] = None) -> str:
52
+ if self.is_open:
53
+ return f"Browser already open at {self._page.url or 'about:blank'}"
54
+
55
+ self._pw = await async_playwright().start()
56
+ engine = os.environ.get("FAGUN_BROWSER", "chromium").lower()
57
+ launcher = {
58
+ "chromium": self._pw.chromium,
59
+ "firefox": self._pw.firefox,
60
+ "webkit": self._pw.webkit,
61
+ }.get(engine, self._pw.chromium)
62
+
63
+ cdp = os.environ.get("FAGUN_CDP_URL")
64
+ if cdp:
65
+ # Attach to an already-running Chrome (the "autoConnect" style flow).
66
+ self._browser = await launcher.connect_over_cdp(cdp)
67
+ self._context = (
68
+ self._browser.contexts[0]
69
+ if self._browser.contexts
70
+ else await self._browser.new_context()
71
+ )
72
+ else:
73
+ if headless is None:
74
+ headless = os.environ.get("FAGUN_HEADLESS", "1") != "0"
75
+ self._browser = await launcher.launch(headless=headless)
76
+ self._context = await self._browser.new_context()
77
+
78
+ self._page = (
79
+ self._context.pages[0]
80
+ if self._context.pages
81
+ else await self._context.new_page()
82
+ )
83
+ self._wire_listeners(self._page)
84
+ return f"Browser started ({engine}, {'CDP' if cdp else 'launched'})."
85
+
86
+ def _wire_listeners(self, page: Page) -> None:
87
+ def on_console(msg: Any) -> None:
88
+ loc = msg.location or {}
89
+ where = f"{loc.get('url', '')}:{loc.get('lineNumber', '')}"
90
+ self.console.append(ConsoleEntry(msg.type, msg.text, where))
91
+
92
+ def on_response(resp: Any) -> None:
93
+ self.network.append(
94
+ NetworkEntry(
95
+ method=resp.request.method,
96
+ url=resp.url,
97
+ status=resp.status,
98
+ resource_type=resp.request.resource_type,
99
+ )
100
+ )
101
+
102
+ def on_request_failed(req: Any) -> None:
103
+ self.network.append(
104
+ NetworkEntry(
105
+ method=req.method,
106
+ url=req.url,
107
+ failure=(req.failure or "failed"),
108
+ resource_type=req.resource_type,
109
+ )
110
+ )
111
+
112
+ page.on("console", on_console)
113
+ page.on("response", on_response)
114
+ page.on("requestfailed", on_request_failed)
115
+
116
+ async def page(self) -> Page:
117
+ if not self.is_open:
118
+ await self.start()
119
+ assert self._page is not None
120
+ return self._page
121
+
122
+ def clear_logs(self) -> None:
123
+ self.console.clear()
124
+ self.network.clear()
125
+
126
+ async def stop(self) -> str:
127
+ for closer in (self._context, self._browser):
128
+ try:
129
+ if closer is not None:
130
+ await closer.close()
131
+ except Exception:
132
+ pass
133
+ if self._pw is not None:
134
+ try:
135
+ await self._pw.stop()
136
+ except Exception:
137
+ pass
138
+ self._pw = self._browser = self._context = self._page = None
139
+ self.clear_logs()
140
+ return "Browser closed."
141
+
142
+
143
+ manager = BrowserManager()
fagun/install.py ADDED
@@ -0,0 +1,81 @@
1
+ """`fagun install` — print copy-paste MCP config for each AI tool.
2
+
3
+ We only PRINT config (and offer to write Claude/Cursor JSON) so the user never
4
+ has to hand-edit blind. Every tool that speaks MCP can run Fagun with:
5
+
6
+ command: uvx args: ["fagun"]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+
15
+ SERVER_BLOCK = {"command": "uvx", "args": ["fagun"]}
16
+
17
+ CLAUDE = json.dumps({"mcpServers": {"fagun": SERVER_BLOCK}}, indent=2)
18
+ CURSOR = json.dumps({"mcpServers": {"fagun": SERVER_BLOCK}}, indent=2)
19
+ VSCODE = json.dumps({"servers": {"fagun": {"type": "stdio", **SERVER_BLOCK}}}, indent=2)
20
+ CODEX = '[mcp_servers.fagun]\ncommand = "uvx"\nargs = ["fagun"]'
21
+
22
+ HELP = f"""🦊 Fagun install — add this MCP server to your AI tool, then say "fagun".
23
+
24
+ Prereqs (once):
25
+ pip install uv # gives you uvx
26
+ uvx --from fagun python -m playwright install chromium # browser engine
27
+
28
+ ────────────────────────────────────────────────────────────────────
29
+ Claude Code → run: claude mcp add fagun -- uvx fagun
30
+ Claude Desktop → ~/Library/Application Support/Claude/claude_desktop_config.json
31
+ Cursor → ~/.cursor/mcp.json (or .cursor/mcp.json in project)
32
+ Windsurf / Cline / Antigravity → their MCP settings, same JSON as Cursor
33
+ {CURSOR}
34
+
35
+ VS Code (Copilot MCP) → .vscode/mcp.json
36
+ {VSCODE}
37
+
38
+ Codex CLI → ~/.codex/config.toml
39
+ {CODEX}
40
+ ────────────────────────────────────────────────────────────────────
41
+ After adding: restart the tool, then type fagun to start.
42
+ """
43
+
44
+
45
+ def _write_json_server(path: Path, key: str) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ data = {}
48
+ if path.exists():
49
+ try:
50
+ data = json.loads(path.read_text() or "{}")
51
+ except json.JSONDecodeError:
52
+ print(f"⚠️ {path} exists but is not valid JSON — skipped, add manually.")
53
+ return
54
+ data.setdefault(key, {})
55
+ if key == "servers":
56
+ data[key]["fagun"] = {"type": "stdio", **SERVER_BLOCK}
57
+ else:
58
+ data[key]["fagun"] = SERVER_BLOCK
59
+ path.write_text(json.dumps(data, indent=2))
60
+ print(f"✅ wrote fagun to {path}")
61
+
62
+
63
+ def run_cli(argv: list[str]) -> None:
64
+ if not argv or argv[0] in {"help", "--help", "-h"}:
65
+ print(HELP)
66
+ return
67
+
68
+ # `fagun install cursor` / `install claude` writes the file for you.
69
+ target = argv[1] if len(argv) > 1 else ""
70
+ home = Path.home()
71
+ if target == "cursor":
72
+ _write_json_server(home / ".cursor" / "mcp.json", "mcpServers")
73
+ elif target == "claude":
74
+ _write_json_server(
75
+ home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
76
+ "mcpServers",
77
+ )
78
+ elif target == "vscode":
79
+ _write_json_server(Path.cwd() / ".vscode" / "mcp.json", "servers")
80
+ else:
81
+ print(HELP)
fagun/qa.py ADDED
@@ -0,0 +1,285 @@
1
+ """QA engine — crawl a site and run an automated bug/quality sweep.
2
+
3
+ Findings are plain dicts so they serialize cleanly back to the AI tool and into
4
+ the report writer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+ from urllib.parse import urldefrag, urljoin, urlparse
12
+
13
+ from .browser import manager
14
+
15
+
16
+ def _same_site(a: str, b: str) -> bool:
17
+ return urlparse(a).netloc == urlparse(b).netloc
18
+
19
+
20
+ async def crawl(start_url: str, max_pages: int = 20) -> dict[str, Any]:
21
+ """Breadth-first crawl within the same host. Returns discovered pages."""
22
+ page = await manager.page()
23
+ seen: set[str] = set()
24
+ queue: list[str] = [urldefrag(start_url)[0]]
25
+ pages: list[dict[str, Any]] = []
26
+
27
+ while queue and len(pages) < max_pages:
28
+ url = queue.pop(0)
29
+ if url in seen:
30
+ continue
31
+ seen.add(url)
32
+ try:
33
+ resp = await page.goto(url, wait_until="domcontentloaded", timeout=20000)
34
+ status = resp.status if resp else None
35
+ title = await page.title()
36
+ except Exception as e:
37
+ pages.append({"url": url, "status": None, "error": str(e)})
38
+ continue
39
+
40
+ hrefs = await page.eval_on_selector_all(
41
+ "a[href]", "els => els.map(e => e.getAttribute('href'))"
42
+ )
43
+ links = []
44
+ for h in hrefs:
45
+ if not h or h.startswith(("mailto:", "tel:", "javascript:", "#")):
46
+ continue
47
+ absolute = urldefrag(urljoin(url, h))[0]
48
+ links.append(absolute)
49
+ if _same_site(start_url, absolute) and absolute not in seen:
50
+ queue.append(absolute)
51
+
52
+ pages.append(
53
+ {"url": url, "status": status, "title": title, "links_found": len(links)}
54
+ )
55
+
56
+ return {"start": start_url, "crawled": len(pages), "pages": pages}
57
+
58
+
59
+ async def run_qa(url: str) -> dict[str, Any]:
60
+ """Load one page and collect findings across several quality dimensions."""
61
+ page = await manager.page()
62
+ manager.clear_logs()
63
+ findings: list[dict[str, Any]] = []
64
+
65
+ t0 = time.perf_counter()
66
+ resp = None
67
+ last_err: Exception | None = None
68
+ for attempt in range(2):
69
+ try:
70
+ resp = await page.goto(url, wait_until="load", timeout=30000)
71
+ last_err = None
72
+ break
73
+ except Exception as e:
74
+ last_err = e # e.g. "interrupted by another navigation" — settle and retry
75
+ try:
76
+ await page.wait_for_load_state("load", timeout=5000)
77
+ except Exception:
78
+ pass
79
+ if last_err is not None:
80
+ return {
81
+ "url": url,
82
+ "ok": False,
83
+ "findings": [{"severity": "high", "type": "load-failure", "detail": str(last_err)}],
84
+ }
85
+ load_ms = round((time.perf_counter() - t0) * 1000)
86
+
87
+ status = resp.status if resp else None
88
+ if status and status >= 400:
89
+ findings.append(
90
+ {"severity": "high", "type": "http-error", "detail": f"Page returned {status}"}
91
+ )
92
+
93
+ # Console errors / warnings.
94
+ for c in manager.console:
95
+ if c.type in ("error", "warning"):
96
+ findings.append(
97
+ {
98
+ "severity": "high" if c.type == "error" else "low",
99
+ "type": f"console-{c.type}",
100
+ "detail": c.text[:300],
101
+ "at": c.location,
102
+ }
103
+ )
104
+
105
+ # Failed / erroring network requests.
106
+ for n in manager.network:
107
+ if n.failure:
108
+ findings.append(
109
+ {"severity": "high", "type": "request-failed", "detail": f"{n.method} {n.url} — {n.failure}"}
110
+ )
111
+ elif n.status and n.status >= 400:
112
+ findings.append(
113
+ {"severity": "medium", "type": "bad-response", "detail": f"{n.status} {n.method} {n.url}"}
114
+ )
115
+
116
+ # Basic accessibility heuristics.
117
+ imgs_no_alt = await page.eval_on_selector_all(
118
+ "img:not([alt])", "els => els.length"
119
+ )
120
+ if imgs_no_alt:
121
+ findings.append(
122
+ {"severity": "low", "type": "a11y-img-alt", "detail": f"{imgs_no_alt} <img> without alt"}
123
+ )
124
+ inputs_no_label = await page.evaluate(
125
+ """() => {
126
+ const inputs = [...document.querySelectorAll('input,select,textarea')];
127
+ return inputs.filter(el => {
128
+ if (el.type === 'hidden' || el.type === 'submit' || el.type === 'button') return false;
129
+ if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')) return false;
130
+ if (el.id && document.querySelector(`label[for="${el.id}"]`)) return false;
131
+ return !el.closest('label');
132
+ }).length;
133
+ }"""
134
+ )
135
+ if inputs_no_label:
136
+ findings.append(
137
+ {"severity": "low", "type": "a11y-input-label", "detail": f"{inputs_no_label} form fields without a label"}
138
+ )
139
+ if not await page.query_selector("title, h1"):
140
+ findings.append({"severity": "low", "type": "seo", "detail": "No <title> or <h1>"})
141
+
142
+ # Performance signal.
143
+ if load_ms > 4000:
144
+ findings.append(
145
+ {"severity": "medium", "type": "perf", "detail": f"Load took {load_ms} ms (>4s)"}
146
+ )
147
+
148
+ # Dedup identical findings (a retried load can double-fire console/network).
149
+ seen: set[tuple[str, str]] = set()
150
+ unique: list[dict[str, Any]] = []
151
+ for f in findings:
152
+ key = (f.get("type", ""), f.get("detail", ""))
153
+ if key not in seen:
154
+ seen.add(key)
155
+ unique.append(f)
156
+
157
+ return {
158
+ "url": url,
159
+ "ok": True,
160
+ "status": status,
161
+ "load_ms": load_ms,
162
+ "console_count": len(manager.console),
163
+ "network_count": len(manager.network),
164
+ "findings": unique,
165
+ }
166
+
167
+
168
+ # --- security headers ------------------------------------------------------
169
+ _SEC_HEADERS = {
170
+ "content-security-policy": ("high", "Missing CSP — XSS/injection risk"),
171
+ "strict-transport-security": ("medium", "Missing HSTS — downgrade/MITM risk"),
172
+ "x-frame-options": ("medium", "Missing X-Frame-Options — clickjacking risk"),
173
+ "x-content-type-options": ("low", "Missing X-Content-Type-Options: nosniff"),
174
+ "referrer-policy": ("low", "Missing Referrer-Policy"),
175
+ "permissions-policy": ("low", "Missing Permissions-Policy"),
176
+ }
177
+
178
+
179
+ async def security_headers(url: str) -> dict[str, Any]:
180
+ """Fetch a URL and check for missing/weak security response headers."""
181
+ page = await manager.page()
182
+ findings: list[dict[str, Any]] = []
183
+ if not url.lower().startswith("http"):
184
+ return {"url": url, "skipped": "security headers only apply to http(s)", "findings": []}
185
+ resp = await page.request.get(url, timeout=20000)
186
+ headers = {k.lower(): v for k, v in resp.headers.items()}
187
+
188
+ for h, (sev, msg) in _SEC_HEADERS.items():
189
+ if h not in headers:
190
+ findings.append({"severity": sev, "type": "sec-header", "detail": msg})
191
+
192
+ server = headers.get("server", "")
193
+ if server and any(c.isdigit() for c in server):
194
+ findings.append(
195
+ {"severity": "low", "type": "info-leak", "detail": f"Server header leaks version: {server}"}
196
+ )
197
+ if "x-powered-by" in headers:
198
+ findings.append(
199
+ {"severity": "low", "type": "info-leak", "detail": f"X-Powered-By leaks stack: {headers['x-powered-by']}"}
200
+ )
201
+ return {"url": url, "status": resp.status, "headers_present": sorted(headers), "findings": findings}
202
+
203
+
204
+ # --- broken link checker ---------------------------------------------------
205
+ async def check_links(url: str, max_links: int = 100) -> dict[str, Any]:
206
+ """Collect links on a page and probe each; report broken ones (4xx/5xx/fail)."""
207
+ page = await manager.page()
208
+ await page.goto(url, wait_until="domcontentloaded", timeout=20000)
209
+ hrefs = await page.eval_on_selector_all(
210
+ "a[href]", "els => [...new Set(els.map(e => e.href))]"
211
+ )
212
+ hrefs = [h for h in hrefs if h.startswith("http")][:max_links]
213
+ findings: list[dict[str, Any]] = []
214
+ checked = 0
215
+ for h in hrefs:
216
+ try:
217
+ r = await page.request.head(h, timeout=15000)
218
+ st = r.status
219
+ if st == 405: # HEAD not allowed -> retry GET
220
+ r = await page.request.get(h, timeout=15000)
221
+ st = r.status
222
+ checked += 1
223
+ if st >= 400:
224
+ findings.append(
225
+ {"severity": "medium", "type": "broken-link", "detail": f"{st} → {h}"}
226
+ )
227
+ except Exception as e:
228
+ findings.append({"severity": "medium", "type": "broken-link", "detail": f"unreachable → {h} ({e})"})
229
+ return {"url": url, "links_checked": checked, "findings": findings}
230
+
231
+
232
+ # --- form auditor ----------------------------------------------------------
233
+ async def test_forms(url: str) -> dict[str, Any]:
234
+ """Static + DOM audit of every form on a page. Non-destructive (no submit)."""
235
+ page = await manager.page()
236
+ await page.goto(url, wait_until="load", timeout=20000)
237
+ forms = await page.evaluate(
238
+ """() => [...document.forms].map(f => ({
239
+ action: f.getAttribute('action') || location.href,
240
+ method: (f.getAttribute('method') || 'get').toLowerCase(),
241
+ fields: [...f.elements].filter(e => e.name || e.id).map(e => ({
242
+ name: e.name || e.id, type: e.type,
243
+ required: e.required, hasLabel: !!(e.labels && e.labels.length) ||
244
+ !!e.getAttribute('aria-label'),
245
+ autocomplete: e.getAttribute('autocomplete') || ''
246
+ }))
247
+ }))"""
248
+ )
249
+ findings: list[dict[str, Any]] = []
250
+ for i, f in enumerate(forms):
251
+ tag = f"form#{i} ({f['method'].upper()} {f['action']})"
252
+ if f["method"] == "get" and any(
253
+ fld["type"] == "password" for fld in f["fields"]
254
+ ):
255
+ findings.append({"severity": "high", "type": "form-security", "detail": f"{tag} submits a password over GET"})
256
+ if str(f["action"]).startswith("http://"):
257
+ findings.append({"severity": "high", "type": "form-security", "detail": f"{tag} posts to insecure http://"})
258
+ for fld in f["fields"]:
259
+ if not fld["hasLabel"] and fld["type"] not in ("hidden", "submit", "button"):
260
+ findings.append({"severity": "low", "type": "form-a11y", "detail": f"{tag} field {fld['name']!r} has no label"})
261
+ if fld["type"] == "password" and fld["autocomplete"] not in ("new-password", "current-password"):
262
+ findings.append({"severity": "low", "type": "form-hint", "detail": f"{tag} password field missing autocomplete hint"})
263
+ if not fld["required"] and fld["type"] in ("email", "password", "tel"):
264
+ findings.append({"severity": "low", "type": "form-validation", "detail": f"{tag} {fld['type']} field {fld['name']!r} not marked required"})
265
+ return {"url": url, "forms": len(forms), "findings": findings}
266
+
267
+
268
+ async def deep_test(url: str, max_pages: int = 8) -> dict[str, Any]:
269
+ """Everything: crawl → per-page QA + forms + security headers, aggregated."""
270
+ crawl_result = await crawl(url, max_pages)
271
+ results: list[dict[str, Any]] = []
272
+ for p in crawl_result["pages"]:
273
+ if p.get("error") or (p.get("status") and p["status"] >= 400):
274
+ continue
275
+ page_url = p["url"]
276
+ merged = await run_qa(page_url)
277
+ for check in (test_forms, security_headers):
278
+ try:
279
+ merged["findings"].extend((await check(page_url)).get("findings", []))
280
+ except Exception as e:
281
+ merged["findings"].append(
282
+ {"severity": "low", "type": "check-error", "detail": f"{check.__name__} failed: {e}"}
283
+ )
284
+ results.append(merged)
285
+ return {"start": url, "pages_tested": len(results), "results": results}
fagun/report.py ADDED
@@ -0,0 +1,55 @@
1
+ """Turn QA results into a Markdown report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _SEV_ORDER = {"high": 0, "medium": 1, "low": 2}
8
+ _SEV_ICON = {"high": "🔴", "medium": "🟠", "low": "🟡"}
9
+
10
+
11
+ def build_markdown(results: list[dict[str, Any]], title: str = "Fagun QA Report") -> str:
12
+ lines = [f"# {title}", ""]
13
+ all_findings: list[tuple[str, dict[str, Any]]] = []
14
+ for r in results:
15
+ for f in r.get("findings", []):
16
+ all_findings.append((r.get("url", "?"), f))
17
+
18
+ counts = {"high": 0, "medium": 0, "low": 0}
19
+ for _, f in all_findings:
20
+ counts[f.get("severity", "low")] = counts.get(f.get("severity", "low"), 0) + 1
21
+
22
+ lines += [
23
+ "## Summary",
24
+ "",
25
+ f"- Pages checked: **{len(results)}**",
26
+ f"- Findings: **{len(all_findings)}** "
27
+ f"({_SEV_ICON['high']} {counts['high']} high · "
28
+ f"{_SEV_ICON['medium']} {counts['medium']} medium · "
29
+ f"{_SEV_ICON['low']} {counts['low']} low)",
30
+ "",
31
+ ]
32
+
33
+ for r in results:
34
+ lines.append(f"## {r.get('url', '?')}")
35
+ meta = []
36
+ if r.get("status") is not None:
37
+ meta.append(f"status {r['status']}")
38
+ if r.get("load_ms") is not None:
39
+ meta.append(f"{r['load_ms']} ms")
40
+ if meta:
41
+ lines.append(f"_{' · '.join(meta)}_")
42
+ lines.append("")
43
+ fs = sorted(
44
+ r.get("findings", []), key=lambda f: _SEV_ORDER.get(f.get("severity", "low"), 3)
45
+ )
46
+ if not fs:
47
+ lines.append("✅ No findings.")
48
+ else:
49
+ for f in fs:
50
+ icon = _SEV_ICON.get(f.get("severity", "low"), "•")
51
+ extra = f" _(at {f['at']})_" if f.get("at") else ""
52
+ lines.append(f"- {icon} **{f.get('type')}**: {f.get('detail')}{extra}")
53
+ lines.append("")
54
+
55
+ return "\n".join(lines)
fagun/server.py ADDED
@@ -0,0 +1,238 @@
1
+ """Fagun MCP server.
2
+
3
+ Exposes browser-driving + QA tools over MCP so ANY MCP-capable AI tool
4
+ (Claude Code/Desktop, Cursor, Codex, Antigravity, Windsurf, Cline, VS Code)
5
+ can use them. The `fagun` prompt is the entry point users invoke.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from typing import Optional
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from . import __version__
18
+ from .browser import manager
19
+ from .qa import check_links as _check_links
20
+ from .qa import crawl as _crawl
21
+ from .qa import deep_test as _deep_test
22
+ from .qa import run_qa as _run_qa
23
+ from .qa import security_headers as _security_headers
24
+ from .qa import test_forms as _test_forms
25
+ from .report import build_markdown
26
+
27
+ mcp = FastMCP("fagun")
28
+
29
+ MENU = f"""🦊 **Fagun v{__version__}** — browser + QA agent, ready.
30
+
31
+ I can drive a real browser and run a full quality sweep. Ask me to:
32
+
33
+ **Browse & debug**
34
+ - `open the browser` / `go to <url>`
35
+ - `click <text>` · `type <text> into <field>` · `press Enter`
36
+ - `screenshot` · `show console errors` · `show network requests`
37
+ - `run this JS: <code>`
38
+
39
+ **QA & bug hunting**
40
+ - `crawl <url>` — map the site
41
+ - `run QA on <url>` — console errors, failed requests, a11y, perf, SEO
42
+ - `check links on <url>` — find broken links
43
+ - `test forms on <url>` — form security / validation / a11y
44
+ - `security headers of <url>` — CSP, HSTS, X-Frame, info leaks
45
+ - `deep test <url>` — crawl + QA + forms + headers, full report
46
+ - `write the report to <path>`
47
+
48
+ Tell me a URL to start. Example: *"deep test https://example.com and write the report to ./report.md."*
49
+ """
50
+
51
+
52
+ @mcp.prompt(title="Start Fagun")
53
+ def fagun() -> str:
54
+ """Entry point. Invoke this to start Fagun and see what it can do."""
55
+ return MENU
56
+
57
+
58
+ @mcp.tool()
59
+ def fagun_start() -> str:
60
+ """Start Fagun and list its capabilities. Call this when the user says 'fagun'."""
61
+ return MENU
62
+
63
+
64
+ # ---------------------------------------------------------------- browser tools
65
+ @mcp.tool()
66
+ async def open_browser(headless: bool = True) -> str:
67
+ """Launch (or attach to) the browser."""
68
+ return await manager.start(headless=headless)
69
+
70
+
71
+ @mcp.tool()
72
+ async def navigate(url: str) -> str:
73
+ """Go to a URL."""
74
+ page = await manager.page()
75
+ resp = await page.goto(url, wait_until="load", timeout=30000)
76
+ return f"Loaded {page.url} (status {resp.status if resp else '?'}) — title: {await page.title()!r}"
77
+
78
+
79
+ @mcp.tool()
80
+ async def click(target: str) -> str:
81
+ """Click an element by CSS selector or visible text."""
82
+ page = await manager.page()
83
+ try:
84
+ await page.click(target, timeout=8000)
85
+ except Exception:
86
+ await page.get_by_text(target, exact=False).first.click(timeout=8000)
87
+ return f"Clicked {target!r}. Now at {page.url}"
88
+
89
+
90
+ @mcp.tool()
91
+ async def fill(selector: str, value: str) -> str:
92
+ """Type text into a form field (CSS selector or label/placeholder text)."""
93
+ page = await manager.page()
94
+ try:
95
+ await page.fill(selector, value, timeout=8000)
96
+ except Exception:
97
+ await page.get_by_label(selector).fill(value, timeout=8000)
98
+ return f"Filled {selector!r}."
99
+
100
+
101
+ @mcp.tool()
102
+ async def press_key(key: str) -> str:
103
+ """Press a keyboard key, e.g. 'Enter', 'Tab', 'Escape'."""
104
+ page = await manager.page()
105
+ await page.keyboard.press(key)
106
+ return f"Pressed {key}."
107
+
108
+
109
+ @mcp.tool()
110
+ async def screenshot(full_page: bool = False) -> str:
111
+ """Take a screenshot; saves a PNG and returns its path."""
112
+ page = await manager.page()
113
+ path = os.path.join(tempfile.gettempdir(), f"fagun-{abs(hash(page.url)) % 10**6}.png")
114
+ await page.screenshot(path=path, full_page=full_page)
115
+ return f"Screenshot saved: {path}"
116
+
117
+
118
+ @mcp.tool()
119
+ async def evaluate_js(code: str) -> str:
120
+ """Run JavaScript in the page and return the result as JSON."""
121
+ page = await manager.page()
122
+ result = await page.evaluate(code)
123
+ try:
124
+ return json.dumps(result, default=str)[:5000]
125
+ except Exception:
126
+ return str(result)[:5000]
127
+
128
+
129
+ @mcp.tool()
130
+ async def get_console(only_errors: bool = False) -> str:
131
+ """Return captured console messages."""
132
+ entries = manager.console
133
+ if only_errors:
134
+ entries = [c for c in entries if c.type == "error"]
135
+ if not entries:
136
+ return "No console messages captured."
137
+ return "\n".join(f"[{c.type}] {c.text} ({c.location})" for c in entries[-100:])
138
+
139
+
140
+ @mcp.tool()
141
+ async def get_network(only_problems: bool = False) -> str:
142
+ """Return captured network requests. only_problems -> failures / 4xx / 5xx."""
143
+ entries = manager.network
144
+ if only_problems:
145
+ entries = [n for n in entries if n.failure or (n.status and n.status >= 400)]
146
+ if not entries:
147
+ return "No network activity matched."
148
+ out = []
149
+ for n in entries[-150:]:
150
+ state = n.failure or n.status
151
+ out.append(f"{n.method} {state} [{n.resource_type}] {n.url}")
152
+ return "\n".join(out)
153
+
154
+
155
+ @mcp.tool()
156
+ async def close_browser() -> str:
157
+ """Close the browser and free resources."""
158
+ return await manager.stop()
159
+
160
+
161
+ # --------------------------------------------------------------------- qa tools
162
+ @mcp.tool()
163
+ async def crawl(url: str, max_pages: int = 20) -> str:
164
+ """Crawl a site within the same host, up to max_pages. Returns JSON."""
165
+ return json.dumps(await _crawl(url, max_pages), indent=2)
166
+
167
+
168
+ @mcp.tool()
169
+ async def run_qa(url: str) -> str:
170
+ """Run the QA sweep on a single page (console, network, a11y, perf, SEO)."""
171
+ return json.dumps(await _run_qa(url), indent=2)
172
+
173
+
174
+ @mcp.tool()
175
+ async def full_qa_sweep(url: str, max_pages: int = 10, report_path: Optional[str] = None) -> str:
176
+ """Crawl a site, run QA on each page, and (optionally) write a Markdown report."""
177
+ crawl_result = await _crawl(url, max_pages)
178
+ results = []
179
+ for p in crawl_result["pages"]:
180
+ if p.get("status") and p["status"] < 400 and not p.get("error"):
181
+ results.append(await _run_qa(p["url"]))
182
+ md = build_markdown(results, title=f"Fagun QA Report — {url}")
183
+ if report_path:
184
+ with open(report_path, "w", encoding="utf-8") as fh:
185
+ fh.write(md)
186
+ return f"Swept {len(results)} pages. Report written to {report_path}\n\n{md[:1500]}"
187
+ return md
188
+
189
+
190
+ @mcp.tool()
191
+ async def security_headers(url: str) -> str:
192
+ """Check a page for missing/weak security headers (CSP, HSTS, X-Frame, etc.)."""
193
+ return json.dumps(await _security_headers(url), indent=2)
194
+
195
+
196
+ @mcp.tool()
197
+ async def check_links(url: str, max_links: int = 100) -> str:
198
+ """Find broken links (4xx/5xx/unreachable) among the links on a page."""
199
+ return json.dumps(await _check_links(url, max_links), indent=2)
200
+
201
+
202
+ @mcp.tool()
203
+ async def test_forms(url: str) -> str:
204
+ """Audit every form on a page for security, validation and a11y issues (no submit)."""
205
+ return json.dumps(await _test_forms(url), indent=2)
206
+
207
+
208
+ @mcp.tool()
209
+ async def deep_test(url: str, max_pages: int = 8, report_path: Optional[str] = None) -> str:
210
+ """Full audit: crawl + QA + forms + security headers across the site, one report."""
211
+ result = await _deep_test(url, max_pages)
212
+ md = build_markdown(result["results"], title=f"Fagun Deep Test — {url}")
213
+ if report_path:
214
+ with open(report_path, "w", encoding="utf-8") as fh:
215
+ fh.write(md)
216
+ return f"Deep-tested {result['pages_tested']} pages. Report → {report_path}\n\n{md[:1800]}"
217
+ return md
218
+
219
+
220
+ @mcp.tool()
221
+ async def write_report(results_json: str, path: str, title: str = "Fagun QA Report") -> str:
222
+ """Write a Markdown report from a JSON list of run_qa results."""
223
+ results = json.loads(results_json)
224
+ if isinstance(results, dict):
225
+ results = [results]
226
+ md = build_markdown(results, title=title)
227
+ with open(path, "w", encoding="utf-8") as fh:
228
+ fh.write(md)
229
+ return f"Report written to {path}"
230
+
231
+
232
+ def serve() -> None:
233
+ """Run the MCP server over stdio."""
234
+ mcp.run()
235
+
236
+
237
+ if __name__ == "__main__":
238
+ serve()
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: fagun
3
+ Version: 0.2.0
4
+ Summary: Fagun — one MCP server that lets any AI tool (Claude, Cursor, Codex, Antigravity, Windsurf) drive a browser, hunt bugs, and run a full QA sweep.
5
+ Project-URL: Homepage, https://github.com/fagun-dev/fagun
6
+ Author-email: Mejbaur Bahar Fagun <mejbaur@markopolo.ai>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,browser,claude,codex,cursor,mcp,playwright,qa,testing
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: mcp>=1.2.0
12
+ Requires-Dist: playwright>=1.44.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # 🦊 Fagun
16
+
17
+ **One MCP server. Every AI tool. Type `fagun` and it drives a browser + runs full QA.**
18
+
19
+ Fagun is an [MCP](https://modelcontextprotocol.io) server that lets any AI coding
20
+ tool — **Claude Code / Desktop, Cursor, Codex, Antigravity, Windsurf, Cline, VS Code** —
21
+ control a real browser, debug it (console, network, DOM, screenshots, JS eval), and
22
+ run an automated quality sweep (broken requests, console errors, accessibility,
23
+ performance, SEO) with a Markdown report.
24
+
25
+ You set it up once. Then inside any of those tools you just say **`fagun`** (or `/fagun`) and it starts.
26
+
27
+ ---
28
+
29
+ ## One-line setup (paste into your AI tool)
30
+
31
+ Tell your AI agent (Claude, Cursor, Codex, Antigravity, …) exactly this:
32
+
33
+ > Install or upgrade **fagun** to the latest stable version with `uv` using Python
34
+ > 3.12, register the skill from `fagun skill`, and connect it to my browser. Follow
35
+ > https://github.com/mejbaurbahar/fagun/blob/main/install.md if setup or connection fails.
36
+
37
+ The agent follows [`install.md`](install.md) and wires everything up. Then just type
38
+ **`/fagun`** and describe what to test.
39
+
40
+ ---
41
+
42
+ ## Setup (once, ~2 min)
43
+
44
+ **1. Install the runner + browser engine**
45
+
46
+ ```bash
47
+ pip install uv
48
+ uvx --from fagun python -m playwright install chromium
49
+ ```
50
+
51
+ `uvx` runs Fagun without a permanent install and always uses the latest version.
52
+
53
+ **2. Add Fagun to your AI tool**
54
+
55
+ Print ready-to-paste config for every tool:
56
+
57
+ ```bash
58
+ uvx fagun install
59
+ ```
60
+
61
+ Or let Fagun write the file for you:
62
+
63
+ ```bash
64
+ uvx fagun install cursor # writes ~/.cursor/mcp.json
65
+ uvx fagun install claude # writes Claude Desktop config
66
+ uvx fagun install vscode # writes .vscode/mcp.json
67
+ ```
68
+
69
+ Manual config is identical everywhere — command `uvx`, args `["fagun"]`:
70
+
71
+ | Tool | Where |
72
+ |------|-------|
73
+ | **Claude Code** | `claude mcp add fagun -- uvx fagun` |
74
+ | **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` |
75
+ | **Cursor** | `~/.cursor/mcp.json` |
76
+ | **Windsurf / Cline / Antigravity** | their MCP settings (same JSON as Cursor) |
77
+ | **VS Code (Copilot)** | `.vscode/mcp.json` |
78
+ | **Codex CLI** | `~/.codex/config.toml` |
79
+
80
+ ```jsonc
81
+ // Claude Desktop / Cursor / Windsurf / Cline / Antigravity
82
+ { "mcpServers": { "fagun": { "command": "uvx", "args": ["fagun"] } } }
83
+ ```
84
+
85
+ ```toml
86
+ # Codex ~/.codex/config.toml
87
+ [mcp_servers.fagun]
88
+ command = "uvx"
89
+ args = ["fagun"]
90
+ ```
91
+
92
+ **3. Restart the tool. Say `fagun`.**
93
+
94
+ ---
95
+
96
+ ## Use it
97
+
98
+ Inside any tool, just talk:
99
+
100
+ - *"fagun"* → shows the menu and starts up
101
+ - *"go to example.com and screenshot it"*
102
+ - *"run QA on https://example.com"*
103
+ - *"full QA sweep of https://example.com, write the report to ./qa.md"*
104
+ - *"show me the console errors"* · *"any failed network requests?"*
105
+ - *"click Sign in, type me@x.com into email, press Enter"*
106
+
107
+ - *"deep test https://example.com and write the report to ./report.md"* — the full sweep
108
+
109
+ ## The `/fagun` skill — autonomous bug hunter
110
+
111
+ `skills/fagun/SKILL.md` is a full QA methodology the AI follows: it hunts **real,
112
+ reproducible** bugs across 10 scenario classes — functional, JS/runtime errors,
113
+ network/API, form validation, auth/session/authorization, accessibility,
114
+ performance, visual/responsive, security posture, and edge cases — with an
115
+ evidence-or-it-didn't-happen rule and impact-based severity. Register it (see
116
+ `install.md` Step 5) to get the `/fagun` slash command.
117
+
118
+ ## What it exposes (MCP tools)
119
+
120
+ `fagun_start` · `open_browser` · `navigate` · `click` · `fill` · `press_key` ·
121
+ `screenshot` · `evaluate_js` · `get_console` · `get_network` · `crawl` · `run_qa` ·
122
+ `check_links` · `test_forms` · `security_headers` · `deep_test` · `full_qa_sweep` ·
123
+ `write_report` · `close_browser`
124
+
125
+ Plus a **`fagun` prompt** — appears as a slash command in tools that surface MCP prompts.
126
+
127
+ ## Options (env vars)
128
+
129
+ | Var | Default | Meaning |
130
+ |-----|---------|---------|
131
+ | `FAGUN_HEADLESS` | `1` | `0` shows the browser window |
132
+ | `FAGUN_BROWSER` | `chromium` | `chromium` \| `firefox` \| `webkit` |
133
+ | `FAGUN_CDP_URL` | — | Attach to a running Chrome, e.g. `http://127.0.0.1:9222` |
134
+
135
+ ## Local dev
136
+
137
+ ```bash
138
+ git clone <repo> && cd fagun
139
+ pip install -e .
140
+ python -m playwright install chromium
141
+ python -m fagun # starts the MCP server on stdio
142
+ ```
143
+
144
+ ## Releasing to PyPI (maintainer)
145
+
146
+ Auto-publish is wired via GitHub Actions using **PyPI Trusted Publishing** (OIDC —
147
+ no API token stored). One-time setup on [pypi.org](https://pypi.org/manage/account/publishing/):
148
+
149
+ - Project: `fagun` · Owner: `mejbaurbahar` · Repo: `fagun`
150
+ - Workflow: `publish.yml` · Environment: `pypi`
151
+
152
+ Then every release is just a tag:
153
+
154
+ ```bash
155
+ # bump version in pyproject.toml + src/fagun/__init__.py first
156
+ git tag v0.2.0 && git push origin v0.2.0
157
+ ```
158
+
159
+ The `Publish to PyPI` workflow builds, checks, and uploads automatically. Build
160
+ locally to verify anytime: `python -m build && twine check dist/*`.
161
+
162
+ MIT © Mejbaur Bahar Fagun
@@ -0,0 +1,12 @@
1
+ fagun/__init__.py,sha256=KRibwFKmcW2hhBUg53-j0JQXo06SZiMCnuKJfSATTmw,75
2
+ fagun/__main__.py,sha256=_EEe7-N8GQGS1-lmru2kAxsReB8Om5QAkhIEG84QDsU,783
3
+ fagun/browser.py,sha256=9s3kuRYQiaOIbSckdtRrN7dNaePLmHHxbq9auADusCQ,4345
4
+ fagun/install.py,sha256=uKWJOStiY0nOzx0fZ4QcFBbTno8FjSzLd1TPLo2fkKA,3134
5
+ fagun/qa.py,sha256=P06_UAtGZcP4H0LvQ-jediaDq5YJhcRGHVQeKSrg5ck,11889
6
+ fagun/report.py,sha256=EHPfcUAC-4iQC56HAGzVK0YmoQAWMG1Xal2WQlAAA7A,1882
7
+ fagun/server.py,sha256=heRyAavapuRwaABFmjosGx23NP5PpbtunHZNibux9-U,7946
8
+ fagun-0.2.0.dist-info/METADATA,sha256=cPEkvyCWvhPVPdjkcTnUF5IyioR5We-Rrc6AZ3MuWBg,5471
9
+ fagun-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ fagun-0.2.0.dist-info/entry_points.txt,sha256=lZbZrR6m6teanwWcsPZEJVkyPv4XDf-Lh5jDDvhypuo,46
11
+ fagun-0.2.0.dist-info/licenses/LICENSE,sha256=_LmB-jVUi9jVftzdcpoccXXHGXX-drSazWxjveIuH_U,1076
12
+ fagun-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fagun = fagun.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mejbaur Bahar Fagun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.