qaforge-mcp 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """QAForge MCP — The World's First All-in-One QA Engineering MCP Server."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Sahil"
@@ -0,0 +1,10 @@
1
+ """Entry point for QAForge MCP server."""
2
+
3
+
4
+ def main():
5
+ from .server import mcp
6
+ mcp.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
qaforge_mcp/config.py ADDED
@@ -0,0 +1,35 @@
1
+ import os
2
+ from pathlib import Path
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+
8
+ class Config:
9
+ def __init__(self):
10
+ self.anthropic_api_key: str = os.getenv("ANTHROPIC_API_KEY", "")
11
+ self.claude_model: str = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
12
+
13
+ self.jira_url: str = os.getenv("JIRA_URL", "").rstrip("/")
14
+ self.jira_email: str = os.getenv("JIRA_EMAIL", "")
15
+ self.jira_token: str = os.getenv("JIRA_TOKEN", "")
16
+ self.jira_project_key: str = os.getenv("JIRA_PROJECT_KEY", "QA")
17
+
18
+ self.slack_webhook_url: str = os.getenv("SLACK_WEBHOOK", "")
19
+
20
+ self.baseline_file: Path = Path(os.getenv("BASELINE_FILE", "qaforge_baselines.json"))
21
+
22
+ @property
23
+ def jira_configured(self) -> bool:
24
+ return bool(self.jira_url and self.jira_token)
25
+
26
+ @property
27
+ def slack_configured(self) -> bool:
28
+ return bool(self.slack_webhook_url)
29
+
30
+ @property
31
+ def ai_configured(self) -> bool:
32
+ return bool(self.anthropic_api_key)
33
+
34
+
35
+ config = Config()
File without changes
@@ -0,0 +1,66 @@
1
+ import base64
2
+ from typing import Optional
3
+ import httpx
4
+ from ..config import config
5
+
6
+
7
+ def _auth_header() -> str:
8
+ credentials = f"{config.jira_email}:{config.jira_token}"
9
+ return "Basic " + base64.b64encode(credentials.encode()).decode()
10
+
11
+
12
+ async def create_issue(
13
+ summary: str,
14
+ description: str,
15
+ issue_type: str = "Bug",
16
+ priority: str = "High",
17
+ labels: Optional[list[str]] = None,
18
+ assignee_account_id: Optional[str] = None,
19
+ ) -> dict:
20
+ if not config.jira_configured:
21
+ return {"error": "Jira not configured. Set JIRA_URL, JIRA_EMAIL, JIRA_TOKEN env vars."}
22
+
23
+ headers = {
24
+ "Authorization": _auth_header(),
25
+ "Content-Type": "application/json",
26
+ "Accept": "application/json",
27
+ }
28
+
29
+ fields: dict = {
30
+ "project": {"key": config.jira_project_key},
31
+ "summary": summary,
32
+ "description": {
33
+ "type": "doc",
34
+ "version": 1,
35
+ "content": [
36
+ {
37
+ "type": "paragraph",
38
+ "content": [{"type": "text", "text": description}],
39
+ }
40
+ ],
41
+ },
42
+ "issuetype": {"name": issue_type},
43
+ "priority": {"name": priority},
44
+ }
45
+
46
+ if labels:
47
+ fields["labels"] = labels
48
+ if assignee_account_id:
49
+ fields["assignee"] = {"accountId": assignee_account_id}
50
+
51
+ payload = {"fields": fields}
52
+
53
+ async with httpx.AsyncClient(timeout=30) as client:
54
+ resp = await client.post(
55
+ f"{config.jira_url}/rest/api/3/issue",
56
+ headers=headers,
57
+ json=payload,
58
+ )
59
+ if resp.status_code == 201:
60
+ data = resp.json()
61
+ return {
62
+ "key": data["key"],
63
+ "id": data["id"],
64
+ "url": f"{config.jira_url}/browse/{data['key']}",
65
+ }
66
+ return {"error": f"Jira API error {resp.status_code}: {resp.text[:500]}"}
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ _K6_CANDIDATES = [
10
+ "k6",
11
+ r"C:\Program Files\k6\k6.exe",
12
+ r"C:\Program Files (x86)\k6\k6.exe",
13
+ str(Path.home() / "AppData" / "Local" / "Microsoft" / "WinGet" / "Packages" / "GrafanaLabs.k6_Microsoft.Winget.Source_8wekyb3d8bbwe" / "k6.exe"),
14
+ ]
15
+
16
+
17
+ def _find_k6() -> str:
18
+ if found := shutil.which("k6"):
19
+ return found
20
+ for candidate in _K6_CANDIDATES:
21
+ if Path(candidate).exists():
22
+ return candidate
23
+ return "k6"
24
+
25
+
26
+ def generate_k6_script(
27
+ endpoints: list[dict],
28
+ vus: int = 10,
29
+ duration: str = "30s",
30
+ baseline_thresholds: Optional[dict] = None,
31
+ ) -> str:
32
+ endpoint_calls = []
33
+ for ep in endpoints:
34
+ method = ep.get("method", "GET").upper()
35
+ url = ep.get("url", "")
36
+ headers = ep.get("headers", {})
37
+ body = ep.get("body", None)
38
+
39
+ url_js = json.dumps(url)
40
+ headers_js = json.dumps(headers)
41
+ if method == "GET":
42
+ endpoint_calls.append(f' http.get({url_js}, {{headers: {headers_js}}});')
43
+ else:
44
+ body_js = json.dumps(body) if body else "null"
45
+ endpoint_calls.append(
46
+ f' http.{method.lower()}({url_js}, JSON.stringify({body_js}), {{headers: {headers_js}}});'
47
+ )
48
+
49
+ calls_block = "\n".join(endpoint_calls) if endpoint_calls else ' http.get(__ENV.TARGET_URL || "http://localhost:3000");'
50
+
51
+ thresholds = baseline_thresholds or {
52
+ "http_req_duration": ["p(90)<2000", "p(95)<3000"],
53
+ "http_req_failed": ["rate<0.05"],
54
+ }
55
+ thresholds_js = json.dumps(thresholds, indent=4)
56
+
57
+ return f"""import http from 'k6/http';
58
+ import {{ check, sleep }} from 'k6';
59
+
60
+ export const options = {{
61
+ vus: {vus},
62
+ duration: '{duration}',
63
+ thresholds: {thresholds_js},
64
+ }};
65
+
66
+ export default function () {{
67
+ {calls_block}
68
+ sleep(1);
69
+ }}
70
+ """
71
+
72
+
73
+ async def run_k6(script_content: str, output_json: bool = True) -> dict:
74
+ with tempfile.TemporaryDirectory() as tmpdir:
75
+ script_path = Path(tmpdir) / "test.js"
76
+ output_path = Path(tmpdir) / "results.json"
77
+ script_path.write_text(script_content)
78
+
79
+ cmd = [_find_k6(), "run"]
80
+ if output_json:
81
+ cmd += ["--out", f"json={output_path}"]
82
+ cmd.append(str(script_path))
83
+
84
+ try:
85
+ proc = await asyncio.create_subprocess_exec(
86
+ *cmd,
87
+ stdout=asyncio.subprocess.PIPE,
88
+ stderr=asyncio.subprocess.PIPE,
89
+ cwd=tmpdir,
90
+ )
91
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
92
+ except FileNotFoundError:
93
+ return {"error": "k6 not installed. Install from https://k6.io/docs/getting-started/installation/"}
94
+ except asyncio.TimeoutError:
95
+ proc.kill()
96
+ return {"error": "k6 run timed out after 5 minutes"}
97
+
98
+ stdout_text = stdout.decode(errors="replace")
99
+ stderr_text = stderr.decode(errors="replace")
100
+
101
+ metrics = _parse_k6_stdout(stdout_text)
102
+ metrics["exit_code"] = proc.returncode
103
+ metrics["stdout"] = stdout_text[-3000:]
104
+ if proc.returncode != 0:
105
+ metrics["stderr"] = stderr_text[-1000:]
106
+
107
+ return metrics
108
+
109
+
110
+ def _parse_k6_stdout(output: str) -> dict:
111
+ metrics: dict = {}
112
+ for line in output.splitlines():
113
+ if "http_req_duration" in line and "avg=" in line:
114
+ metrics["raw_duration_line"] = line.strip()
115
+ for part in line.split():
116
+ for key in ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"]:
117
+ if part.startswith(key + "="):
118
+ val_str = part.split("=")[1].replace("ms", "").replace("s", "")
119
+ try:
120
+ metrics[key.replace("(", "").replace(")", "")] = float(val_str)
121
+ except ValueError:
122
+ pass
123
+ elif "http_req_failed" in line and "rate=" in line:
124
+ for part in line.split():
125
+ if part.startswith("rate="):
126
+ try:
127
+ metrics["failure_rate"] = float(part.split("=")[1].rstrip("%")) / 100
128
+ except ValueError:
129
+ pass
130
+ elif "http_reqs" in line and "rate=" in line:
131
+ for part in line.split():
132
+ if part.startswith("rate="):
133
+ try:
134
+ metrics["rps"] = float(part.split("=")[1].split("/")[0])
135
+ except ValueError:
136
+ pass
137
+ return metrics
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import base64
3
+ from typing import Optional
4
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
5
+
6
+
7
+ async def get_page_snapshot(url: str, browser_type: str = "chromium") -> dict:
8
+ async with async_playwright() as p:
9
+ launcher = getattr(p, browser_type)
10
+ browser: Browser = await launcher.launch(headless=True)
11
+ context: BrowserContext = await browser.new_context()
12
+ page: Page = await context.new_page()
13
+ errors: list[str] = []
14
+ page.on("pageerror", lambda e: errors.append(str(e)))
15
+
16
+ try:
17
+ await page.goto(url, wait_until="networkidle", timeout=30000)
18
+ title = await page.title()
19
+ screenshot_bytes = await page.screenshot(full_page=True)
20
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
21
+ dom_snapshot = await page.evaluate("document.documentElement.outerHTML")
22
+ perf = await page.evaluate("""() => {
23
+ const nav = performance.getEntriesByType('navigation')[0];
24
+ return nav ? {
25
+ dom_content_loaded: Math.round(nav.domContentLoadedEventEnd),
26
+ load: Math.round(nav.loadEventEnd)
27
+ } : {};
28
+ }""")
29
+ return {
30
+ "browser": browser_type,
31
+ "url": url,
32
+ "title": title,
33
+ "screenshot_b64": screenshot_b64,
34
+ "dom_length": len(dom_snapshot),
35
+ "dom_snippet": dom_snapshot[:2000],
36
+ "js_errors": errors,
37
+ "performance_ms": perf,
38
+ }
39
+ except Exception as e:
40
+ return {"browser": browser_type, "url": url, "error": str(e)}
41
+ finally:
42
+ await browser.close()
43
+
44
+
45
+ async def run_axe_audit(url: str) -> dict:
46
+ async with async_playwright() as p:
47
+ browser: Browser = await p.chromium.launch(headless=True)
48
+ page: Page = await browser.new_page()
49
+ try:
50
+ await page.goto(url, wait_until="networkidle", timeout=30000)
51
+ await page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js")
52
+ await page.wait_for_function("typeof axe !== 'undefined'", timeout=10000)
53
+ results = await page.evaluate("""async () => {
54
+ const result = await axe.run();
55
+ return {
56
+ violations: result.violations,
57
+ passes: result.passes.length,
58
+ incomplete: result.incomplete.length,
59
+ inapplicable: result.inapplicable.length
60
+ };
61
+ }""")
62
+ return results
63
+ except Exception as e:
64
+ return {"error": str(e), "violations": []}
65
+ finally:
66
+ await browser.close()
67
+
68
+
69
+ async def execute_steps(url: str, steps: list[str]) -> dict:
70
+ """Execute a list of natural-language-like Playwright steps on a URL."""
71
+ async with async_playwright() as p:
72
+ browser: Browser = await p.chromium.launch(headless=True)
73
+ page: Page = await browser.new_page()
74
+ executed: list[dict] = []
75
+ try:
76
+ await page.goto(url, wait_until="networkidle", timeout=30000)
77
+ for step in steps:
78
+ step_lower = step.lower().strip()
79
+ try:
80
+ if step_lower.startswith("click "):
81
+ selector = step[6:].strip().strip("'\"")
82
+ await page.click(selector, timeout=10000)
83
+ executed.append({"step": step, "status": "pass"})
84
+ elif step_lower.startswith("fill "):
85
+ parts = step[5:].split(" with ", 1)
86
+ if len(parts) == 2:
87
+ selector = parts[0].strip().strip("'\"")
88
+ value = parts[1].strip().strip("'\"")
89
+ await page.fill(selector, value, timeout=10000)
90
+ executed.append({"step": step, "status": "pass"})
91
+ elif step_lower.startswith("assert visible "):
92
+ selector = step[15:].strip().strip("'\"")
93
+ await page.wait_for_selector(selector, state="visible", timeout=10000)
94
+ executed.append({"step": step, "status": "pass"})
95
+ elif step_lower.startswith("assert text "):
96
+ text = step[12:].strip().strip("'\"")
97
+ await page.wait_for_function(
98
+ f"document.body.innerText.includes({repr(text)})", timeout=10000
99
+ )
100
+ executed.append({"step": step, "status": "pass"})
101
+ elif step_lower.startswith("navigate "):
102
+ nav_url = step[9:].strip()
103
+ await page.goto(nav_url, wait_until="networkidle", timeout=30000)
104
+ executed.append({"step": step, "status": "pass"})
105
+ else:
106
+ executed.append({"step": step, "status": "skipped", "reason": "unrecognized step"})
107
+ except Exception as e:
108
+ executed.append({"step": step, "status": "fail", "error": str(e)})
109
+ final_url = page.url
110
+ return {"steps": executed, "final_url": final_url}
111
+ except Exception as e:
112
+ return {"steps": executed, "error": str(e)}
113
+ finally:
114
+ await browser.close()
@@ -0,0 +1,43 @@
1
+ from typing import Optional
2
+ import httpx
3
+ from ..config import config
4
+
5
+
6
+ async def send_message(text: str, blocks: Optional[list] = None) -> dict:
7
+ if not config.slack_configured:
8
+ return {"error": "Slack not configured. Set SLACK_WEBHOOK env var."}
9
+
10
+ payload: dict = {"text": text}
11
+ if blocks:
12
+ payload["blocks"] = blocks
13
+
14
+ async with httpx.AsyncClient(timeout=15) as client:
15
+ resp = await client.post(config.slack_webhook_url, json=payload)
16
+ if resp.status_code == 200:
17
+ return {"status": "sent"}
18
+ return {"error": f"Slack error {resp.status_code}: {resp.text}"}
19
+
20
+
21
+ async def send_qa_report(summary: str, pass_count: int, fail_count: int, channel_note: str = "") -> dict:
22
+ blocks = [
23
+ {
24
+ "type": "header",
25
+ "text": {"type": "plain_text", "text": "QAForge Test Report"},
26
+ },
27
+ {
28
+ "type": "section",
29
+ "fields": [
30
+ {"type": "mrkdwn", "text": f"*Passed:* {pass_count}"},
31
+ {"type": "mrkdwn", "text": f"*Failed:* {fail_count}"},
32
+ ],
33
+ },
34
+ {
35
+ "type": "section",
36
+ "text": {"type": "mrkdwn", "text": summary},
37
+ },
38
+ ]
39
+ if channel_note:
40
+ blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": channel_note}]})
41
+
42
+ return await send_message(text=f"QA Report: {pass_count} passed, {fail_count} failed", blocks=blocks)
43
+