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 +3 -0
- fagun/__main__.py +29 -0
- fagun/browser.py +143 -0
- fagun/install.py +81 -0
- fagun/qa.py +285 -0
- fagun/report.py +55 -0
- fagun/server.py +238 -0
- fagun-0.2.0.dist-info/METADATA +162 -0
- fagun-0.2.0.dist-info/RECORD +12 -0
- fagun-0.2.0.dist-info/WHEEL +4 -0
- fagun-0.2.0.dist-info/entry_points.txt +2 -0
- fagun-0.2.0.dist-info/licenses/LICENSE +21 -0
fagun/__init__.py
ADDED
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,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.
|