fraser-gate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """FRASER Experience Merge-Gate client — MCP server + CLI for AI coding agents.
2
+
3
+ A thin, dependency-light client for the hosted FRASER API: point a persona swarm at a build, read
4
+ grounded machine-readable experience findings, ship a fix, and re-run the identical cast to gate the
5
+ fix pass/fail. FRASER is the loop's brain and eyes; it never writes or merges your code.
6
+ """
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("fraser-gate")
11
+ except PackageNotFoundError: # running from a source checkout, not an installed dist
12
+ __version__ = "0.0.0+source"
fraser_gate/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """fraser — thin CLI for the FRASER Experience Merge-Gate.
3
+
4
+ A machine caller (CI, a coding agent, a person) runs FRASER's persona swarm against a DEPLOYED
5
+ preview URL and reads back grounded, machine-readable experience findings. Talks the FRASER HTTP API
6
+ with a bearer token — imports nothing from the backend, no third-party deps (stdlib only), so a CI
7
+ can `python gate_client/cli.py ...` with just Python.
8
+
9
+ Env:
10
+ FRASER_API_KEY required — your FRASER MCP/API token (Settings -> Connect MCP -> Generate token)
11
+ FRASER_API_URL base URL (default http://localhost:8000)
12
+
13
+ Commands:
14
+ fraser review <url> [--mode visual|battle] [--objective TEXT] [--auth-notes TEXT] [--wait]
15
+ fraser status <run_id>
16
+ fraser findings <run_id>
17
+ fraser validate <project_id> <flow_id> <baseline_run_id> <new_url> [--wait] # Phase 2
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+
29
+ # accept either naming — the dashboard "Generate MCP token" flow emits FRASER_BASE_URL/FRASER_API_TOKEN
30
+ API = (os.environ.get("FRASER_API_URL") or os.environ.get("FRASER_BASE_URL") or "http://localhost:8000").rstrip("/")
31
+ KEY = os.environ.get("FRASER_API_KEY") or os.environ.get("FRASER_API_TOKEN") or ""
32
+ TERMINAL = ("complete", "failed", "cancelled", "timed_out")
33
+
34
+
35
+ def _req(method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
36
+ data = json.dumps(body).encode() if body is not None else None
37
+ req = urllib.request.Request(
38
+ API + path, data=data, method=method,
39
+ headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
40
+ try:
41
+ with urllib.request.urlopen(req, timeout=120) as r:
42
+ return r.status, json.loads(r.read().decode() or "{}")
43
+ except urllib.error.HTTPError as e:
44
+ try:
45
+ return e.code, json.loads(e.read().decode() or "{}")
46
+ except Exception: # noqa: BLE001
47
+ return e.code, {"detail": e.reason}
48
+ except urllib.error.URLError as e:
49
+ _die(f"cannot reach {API} ({e.reason}); is the server running / FRASER_API_URL right?")
50
+
51
+
52
+ def _die(msg: str, code: int = 1):
53
+ print(f"error: {msg}", file=sys.stderr)
54
+ sys.exit(code)
55
+
56
+
57
+ def _need_key():
58
+ if not KEY:
59
+ _die("set FRASER_API_KEY (your FRASER API token)")
60
+
61
+
62
+ def _poll_until_done(run_id: str, poll_ms: int) -> dict:
63
+ """Poll status to a terminal state, printing progress to stderr (so stdout stays clean JSON)."""
64
+ delay = max(1.0, min(poll_ms / 1000.0, 10.0))
65
+ while True:
66
+ time.sleep(delay)
67
+ st, s = _req("GET", f"/api/runs/{run_id}/status")
68
+ if st != 200:
69
+ _die(f"status failed ({st}): {s.get('detail')}")
70
+ p = s.get("progress", {})
71
+ print(f" [{s.get('status')}] testers {p.get('personas_done')}/{p.get('total')} "
72
+ f"· findings {s.get('finding_count')} · goal {s.get('goal_outcome')}", file=sys.stderr)
73
+ if s.get("status") in TERMINAL:
74
+ return s
75
+
76
+
77
+ def cmd_review(a):
78
+ _need_key()
79
+ body = {"target_url": a.url, "mode": a.mode}
80
+ if a.objective:
81
+ body["objective"] = a.objective
82
+ if a.context:
83
+ body["context"] = a.context
84
+ if a.testers:
85
+ body["num_testers"] = a.testers
86
+ if a.max_steps:
87
+ body["max_steps"] = a.max_steps
88
+ if a.auth_notes:
89
+ body["auth_notes"] = a.auth_notes
90
+ body["auth_mode"] = "credentials"
91
+ st, env = _req("POST", "/api/reviews", body)
92
+ if st != 200:
93
+ _die(f"review rejected ({st}): {env.get('detail')}")
94
+ if env.get("notice"):
95
+ print(f"note: {env['notice']}", file=sys.stderr)
96
+ if not a.wait:
97
+ # not waiting → the envelope IS the result (stdout); tell the user how to follow up
98
+ print(json.dumps(env, indent=2))
99
+ print(f"\npoll: fraser status {env['run_id']}\nresult: fraser findings {env['run_id']}",
100
+ file=sys.stderr)
101
+ return
102
+ # waiting → keep stdout clean for the findings JSON; envelope + progress go to stderr
103
+ print(json.dumps(env), file=sys.stderr)
104
+ final = _poll_until_done(env["run_id"], env.get("poll_interval_ms", 5000))
105
+ if final.get("status") != "complete":
106
+ _die(f"run ended {final.get('status')}: {final.get('error', '')}", code=2)
107
+ st, f = _req("GET", f"/api/reviews/{env['run_id']}/findings")
108
+ print(json.dumps(f, indent=2))
109
+
110
+
111
+ def cmd_status(a):
112
+ _need_key()
113
+ st, s = _req("GET", f"/api/runs/{a.run_id}/status")
114
+ print(json.dumps(s, indent=2))
115
+ sys.exit(0 if st == 200 else 1)
116
+
117
+
118
+ def cmd_findings(a):
119
+ _need_key()
120
+ st, f = _req("GET", f"/api/reviews/{a.run_id}/findings")
121
+ print(json.dumps(f, indent=2))
122
+ sys.exit(0 if st == 200 else 1)
123
+
124
+
125
+ def cmd_validate(a):
126
+ _need_key()
127
+ body = {"flow_id": a.flow_id, "baseline_run_id": a.baseline_run_id, "target_url": a.new_url}
128
+ st, env = _req("POST", f"/api/projects/{a.project_id}/validate", body)
129
+ if st != 200:
130
+ _die(f"validate rejected ({st}): {env.get('detail')}")
131
+ if not a.wait:
132
+ print(json.dumps(env, indent=2))
133
+ return
134
+ print(json.dumps(env), file=sys.stderr) # keep stdout clean for the verdict JSON
135
+ _poll_until_done(env["run_id"], env.get("poll_interval_ms", 5000))
136
+ st, v = _req("GET", f"/api/runs/{env['run_id']}/validation?baseline={a.baseline_run_id}")
137
+ print(json.dumps(v, indent=2))
138
+ print(f"gate: {v.get('gate')}", file=sys.stderr)
139
+ sys.exit(0 if v.get("gate") == "pass" else 3) # non-zero exit on a FAIL gate → CI blocks the merge
140
+
141
+
142
+ def build_parser() -> argparse.ArgumentParser:
143
+ p = argparse.ArgumentParser(prog="fraser", description="FRASER Experience Merge-Gate CLI")
144
+ sub = p.add_subparsers(dest="cmd", required=True)
145
+
146
+ r = sub.add_parser("review", help="start a persona review of a deployed URL")
147
+ r.add_argument("url")
148
+ r.add_argument("--mode", default="visual", choices=["visual", "battle"])
149
+ r.add_argument("--objective", default="")
150
+ r.add_argument("--context", default="", help="product background shared with every tester")
151
+ r.add_argument("--testers", type=int, default=0, help="number of persona testers (0 = default cast)")
152
+ r.add_argument("--max-steps", dest="max_steps", type=int, default=0, help="per-tester move budget")
153
+ r.add_argument("--auth-notes", dest="auth_notes", default="", help="app login creds (SECRET)")
154
+ r.add_argument("--wait", action="store_true", help="block until done, then print findings")
155
+ r.set_defaults(fn=cmd_review)
156
+
157
+ s = sub.add_parser("status", help="poll a run's async status")
158
+ s.add_argument("run_id")
159
+ s.set_defaults(fn=cmd_status)
160
+
161
+ fi = sub.add_parser("findings", help="the scrubbed machine findings for a run")
162
+ fi.add_argument("run_id")
163
+ fi.set_defaults(fn=cmd_findings)
164
+
165
+ v = sub.add_parser("validate", help="re-run the identical cast and gate the fix (Phase 2)")
166
+ v.add_argument("project_id")
167
+ v.add_argument("flow_id")
168
+ v.add_argument("baseline_run_id")
169
+ v.add_argument("new_url")
170
+ v.add_argument("--wait", action="store_true")
171
+ v.set_defaults(fn=cmd_validate)
172
+ return p
173
+
174
+
175
+ def main(argv=None):
176
+ a = build_parser().parse_args(argv)
177
+ a.fn(a)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
@@ -0,0 +1,185 @@
1
+ """FRASER Experience Merge-Gate — MCP server (the primary distribution surface, §6.1).
2
+
3
+ Six tools a coding agent (Claude Code, Cursor, Copilot, …) uses to run FRASER's persona swarm against
4
+ a deployed preview URL — or its OWN localhost — read grounded, machine-readable experience findings,
5
+ ship a fix, and re-validate to a PASS/FAIL merge-gate. This is a THIN local process: it runs on the
6
+ agent's machine (stdio), holds the FRASER API token, and proxies the hosted HTTP API. Its one piece of
7
+ real magic is the localhost tunnel (frictionless: the agent points at localhost:3000 and this opens an
8
+ ephemeral public tunnel so the hosted engine can reach it — no setup).
9
+
10
+ Run: FRASER_API_KEY=<token> FRASER_API_URL=<https://fraser.lythe.ai> uvx --from . fraser-gate-mcp
11
+ .mcp.json: {"mcpServers":{"fraser":{"type":"stdio","command":"...","env":{"FRASER_API_KEY":"..."}}}}
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+
18
+ import httpx
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ try:
22
+ from .tunnel import Tunnel, is_local_url, open_tunnel
23
+ except ImportError: # run as a plain script (not a package)
24
+ from tunnel import Tunnel, is_local_url, open_tunnel
25
+
26
+ # accept either naming — the dashboard "Generate MCP token" flow emits FRASER_BASE_URL/FRASER_API_TOKEN
27
+ API = (os.environ.get("FRASER_API_URL") or os.environ.get("FRASER_BASE_URL") or "http://localhost:8000").rstrip("/")
28
+ KEY = os.environ.get("FRASER_API_KEY") or os.environ.get("FRASER_API_TOKEN") or ""
29
+ TERMINAL = ("complete", "failed", "cancelled", "timed_out")
30
+
31
+ mcp = FastMCP("fraser-gate")
32
+ _tunnels: dict[str, Tunnel] = {} # run_id -> live localhost tunnel, closed when the run finishes
33
+
34
+
35
+ async def _req(method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
36
+ if not KEY:
37
+ return 0, {"detail": "set FRASER_API_KEY to your FRASER API token"}
38
+ try:
39
+ async with httpx.AsyncClient(timeout=120) as c:
40
+ r = await c.request(method, API + path, json=body,
41
+ headers={"Authorization": f"Bearer {KEY}"})
42
+ except Exception as e: # noqa: BLE001
43
+ return 0, {"detail": f"cannot reach FRASER at {API}: {e}"}
44
+ try:
45
+ return r.status_code, r.json()
46
+ except Exception: # noqa: BLE001
47
+ return r.status_code, {"detail": r.text[:400]}
48
+
49
+
50
+ def _close_tunnel(run_id: str) -> None:
51
+ t = _tunnels.pop(run_id, None)
52
+ if t:
53
+ t.close()
54
+
55
+
56
+ async def _start_tunnel_if_local(target_url: str) -> tuple[str, Tunnel | None]:
57
+ if is_local_url(target_url):
58
+ tun = await asyncio.to_thread(open_tunnel, target_url)
59
+ return tun.public_url, tun
60
+ return target_url, None
61
+
62
+
63
+ @mcp.tool()
64
+ async def fraser_start_review(target_url: str, mode: str = "visual", objective: str = "",
65
+ context: str = "", num_testers: int = 0, auth_notes: str = "",
66
+ auth_mode: str = "") -> dict:
67
+ """Start a persona EXPERIENCE review of a deployed preview URL (or your localhost). Returns a job
68
+ envelope immediately — this is ASYNC and SLOW: FRASER first generates ICP personas from your app,
69
+ then drives a real browser as each one, which takes SEVERAL MINUTES. Do NOT wait inline; poll
70
+ fraser_get_status(run_id) every ~5s until status is terminal, then fraser_get_findings(run_id).
71
+
72
+ localhost is handled for you: if target_url is on your machine (localhost / 127.0.0.1 / a private
73
+ IP), this opens an ephemeral public tunnel automatically so the hosted engine can reach it — you
74
+ set nothing up. The tunnel closes when the run finishes.
75
+
76
+ Knobs (defaults are good — override only with a reason):
77
+ mode: 'visual' = experience/design-friction critique (DEFAULT, what you usually want);
78
+ 'battle' = an adversarial functional defect sweep (pure bug-hunting).
79
+ num_testers: 0 = the grounded ICP cast (recommended). 2–3 for a fast pass; capped by your plan.
80
+ context: one line on what the product IS — anchors the personas when the app is new/thin.
81
+ objective: the specific task to exercise, e.g. 'sign up and create your first project'.
82
+ auth_notes: app login credentials if it's behind your own login (SECRET — never echoed/logged);
83
+ auth_mode defaults to 'credentials' when you pass this.
84
+ Cost: ~1 simulation credit; estimated_cost_cents is in the envelope.
85
+ """
86
+ url, tunnel = await _start_tunnel_if_local(target_url)
87
+ body: dict = {"target_url": url, "mode": mode, "origin": "mcp"}
88
+ if objective:
89
+ body["objective"] = objective
90
+ if context:
91
+ body["context"] = context
92
+ if num_testers:
93
+ body["num_testers"] = num_testers
94
+ if auth_notes:
95
+ body["auth_notes"] = auth_notes
96
+ body["auth_mode"] = auth_mode or "credentials"
97
+ st, env = await _req("POST", "/api/reviews", body)
98
+ if st != 200:
99
+ if tunnel:
100
+ tunnel.close()
101
+ return {"error": env.get("detail", "review failed"), "status_code": st}
102
+ if tunnel:
103
+ _tunnels[env["run_id"]] = tunnel # keep the tunnel up until the run is terminal
104
+ env["tunneled_from"] = target_url
105
+ return env
106
+
107
+
108
+ @mcp.tool()
109
+ async def fraser_get_status(run_id: str) -> dict:
110
+ """Poll a review/validate job (cheap). Returns status (queued|running|complete|failed|cancelled|
111
+ timed_out), progress {personas_done,total,step}, and finding_count. A run takes minutes — poll
112
+ every ~5s. When it reads complete, call fraser_get_findings (review) or fraser_get_validation via
113
+ fraser_validate_fix's follow-up. 'failed' = the app broke the tester; 'timed_out' = retry-able."""
114
+ _, s = await _req("GET", f"/api/runs/{run_id}/status")
115
+ if s.get("status") in TERMINAL:
116
+ _close_tunnel(run_id) # engine is done with the localhost tunnel
117
+ return s
118
+
119
+
120
+ @mcp.tool()
121
+ async def fraser_get_findings(run_id: str) -> dict:
122
+ """The scrubbed, machine-readable findings for a COMPLETE review — each with a stable finding_id
123
+ (map fix→verify on it), defect_class, severity, the exact reproduction path, a screenshot_url, and
124
+ a non-authoritative fix_hint; plus a run-level fix_prompt (a deterministic brief). Known
125
+ false-positives are already withheld. YOU (the agent) fix from these — FRASER never edits code.
126
+ Then redeploy and call fraser_validate_fix to gate the fix."""
127
+ st, f = await _req("GET", f"/api/reviews/{run_id}/findings")
128
+ if st != 200:
129
+ return {"error": f.get("detail", "findings unavailable"), "status_code": st}
130
+ return f
131
+
132
+
133
+ @mcp.tool()
134
+ async def fraser_validate_fix(project_id: str, flow_id: str, baseline_run_id: str,
135
+ target_url: str, with_control: bool = True) -> dict:
136
+ """After you ship a fix, re-run the IDENTICAL persona cast against the fixed deploy and gate it.
137
+ ASYNC + SLOW like a review — returns a job envelope; poll fraser_get_status(run_id) to terminal,
138
+ then read the verdict with fraser_get_validation(run_id, baseline_run_id).
139
+
140
+ MUST pass the SAME project_id + flow_id + baseline_run_id from the original review (never start a
141
+ fresh review for validation — that breaks the cast + the diff). localhost is tunneled for you.
142
+ with_control=True also runs a paired no-change control for the noise floor (costs 1 more credit);
143
+ set False to save a credit at lower confidence."""
144
+ url, tunnel = await _start_tunnel_if_local(target_url)
145
+ body = {"flow_id": flow_id, "baseline_run_id": baseline_run_id, "target_url": url,
146
+ "with_control": with_control, "origin": "mcp"}
147
+ st, env = await _req("POST", f"/api/projects/{project_id}/validate", body)
148
+ if st != 200:
149
+ if tunnel:
150
+ tunnel.close()
151
+ return {"error": env.get("detail", "validate failed"), "status_code": st}
152
+ if tunnel:
153
+ _tunnels[env["run_id"]] = tunnel
154
+ env["tunneled_from"] = target_url
155
+ env["read_verdict_with"] = f"fraser_get_validation(run_id='{env['run_id']}', baseline_run_id='{baseline_run_id}')"
156
+ return env
157
+
158
+
159
+ @mcp.tool()
160
+ async def fraser_get_validation(run_id: str, baseline_run_id: str) -> dict:
161
+ """The PASS/FAIL merge-gate verdict for a COMPLETE validate run vs its baseline: gate=pass|fail,
162
+ each prior finding classified {fixed|still_present|regressed}, plus any new_findings the fix
163
+ introduced. gate='fail' means the EXPERIENCE is still broken even if your build/tests are green —
164
+ do not merge. This is the whole point: a build can pass its own self-test and still fail a user."""
165
+ st, v = await _req("GET", f"/api/runs/{run_id}/validation?baseline={baseline_run_id}")
166
+ if st != 200:
167
+ return {"error": v.get("detail", "validation unavailable"), "status_code": st}
168
+ return v
169
+
170
+
171
+ @mcp.tool()
172
+ async def fraser_cancel(run_id: str) -> dict:
173
+ """Stop a running review/validate (keeps whatever partial findings it gathered) and tear down its
174
+ localhost tunnel."""
175
+ st, r = await _req("POST", f"/api/runs/{run_id}/control", {"action": "stop"})
176
+ _close_tunnel(run_id)
177
+ return r if st == 200 else {"error": r.get("detail", "cancel failed"), "status_code": st}
178
+
179
+
180
+ def main() -> None:
181
+ mcp.run()
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
fraser_gate/tunnel.py ADDED
@@ -0,0 +1,132 @@
1
+ """Frictionless localhost exposure for the MCP server.
2
+
3
+ The hardest part of the loop: a HOSTED FRASER engine can't reach the coding agent's `localhost:3000`.
4
+ The agent shouldn't have to think about this. So when a review targets a localhost / private URL, the
5
+ LOCAL MCP process (which runs on the agent's own machine) opens an ephemeral Cloudflare Quick Tunnel
6
+ (`*.trycloudflare.com`, no account, dies with the process) and hands the hosted engine that public URL.
7
+
8
+ cloudflared is a single binary; if it isn't on PATH we download it once to a cache and reuse it. The
9
+ client does nothing but say "review my localhost app" — the tunnel is invisible. FRASER hosts no
10
+ tunnel and stays read-only-over-HTTPS (§6.4).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import ipaddress
15
+ import os
16
+ import pathlib
17
+ import platform
18
+ import re
19
+ import stat
20
+ import subprocess
21
+ import threading
22
+ import time
23
+ import urllib.parse
24
+ import urllib.request
25
+
26
+ _CACHE = pathlib.Path(os.path.expanduser("~")) / ".fraser" / "bin"
27
+ _TRYCF = re.compile(r"https://[-a-z0-9]+\.trycloudflare\.com")
28
+
29
+
30
+ def is_local_url(url: str) -> bool:
31
+ """True if the URL points at the caller's own machine / a private network the hosted engine can't
32
+ reach directly (so it needs a tunnel)."""
33
+ host = (urllib.parse.urlparse(url).hostname or "").lower()
34
+ if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0") or host.endswith(".local"):
35
+ return True
36
+ try:
37
+ return ipaddress.ip_address(host).is_private
38
+ except ValueError:
39
+ return False
40
+
41
+
42
+ def _asset() -> tuple[str, str]:
43
+ """(download URL, binary filename) for this OS/arch."""
44
+ sysname = platform.system().lower()
45
+ arch = "amd64" if platform.machine().lower() in ("x86_64", "amd64") else "arm64"
46
+ base = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
47
+ if sysname == "windows":
48
+ return base + f"cloudflared-windows-{arch}.exe", "cloudflared.exe"
49
+ if sysname == "darwin":
50
+ return base + f"cloudflared-darwin-{arch}.tgz", "cloudflared" # tgz — extracted below
51
+ return base + f"cloudflared-linux-{arch}", "cloudflared"
52
+
53
+
54
+ def ensure_cloudflared() -> str:
55
+ """Path to a usable cloudflared, downloading it once into ~/.fraser/bin if needed."""
56
+ from shutil import which
57
+ found = which("cloudflared")
58
+ if found:
59
+ return found
60
+ _CACHE.mkdir(parents=True, exist_ok=True)
61
+ url, fname = _asset()
62
+ dest = _CACHE / fname
63
+ if dest.exists():
64
+ return str(dest)
65
+ tmp = dest.with_suffix(dest.suffix + ".part")
66
+ urllib.request.urlretrieve(url, tmp) # noqa: S310 — pinned to the official cloudflare release host
67
+ if url.endswith(".tgz"):
68
+ import tarfile
69
+ with tarfile.open(tmp) as t:
70
+ member = next(m for m in t.getmembers() if m.name.endswith("cloudflared"))
71
+ with t.extractfile(member) as src, open(dest, "wb") as out:
72
+ out.write(src.read())
73
+ tmp.unlink(missing_ok=True)
74
+ else:
75
+ tmp.replace(dest)
76
+ dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IRWXU)
77
+ return str(dest)
78
+
79
+
80
+ class Tunnel:
81
+ """A live Quick Tunnel to a local URL. `public_url` is the https address to hand the engine."""
82
+
83
+ def __init__(self, local_url: str, public_url: str, proc: subprocess.Popen):
84
+ self.local_url = local_url
85
+ self.public_url = public_url
86
+ self._proc = proc
87
+
88
+ def close(self) -> None:
89
+ try:
90
+ self._proc.terminate()
91
+ self._proc.wait(timeout=10)
92
+ except Exception: # noqa: BLE001
93
+ try:
94
+ self._proc.kill()
95
+ except Exception: # noqa: BLE001
96
+ pass
97
+
98
+
99
+ def open_tunnel(local_url: str, *, timeout: float = 30.0) -> Tunnel:
100
+ """Start a cloudflared Quick Tunnel to `local_url` and return a Tunnel once the public URL is up.
101
+ Raises TimeoutError if the URL doesn't appear in time."""
102
+ exe = ensure_cloudflared()
103
+ proc = subprocess.Popen(
104
+ [exe, "tunnel", "--url", local_url, "--no-autoupdate"],
105
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
106
+ url_box: list[str] = []
107
+
108
+ def _scan():
109
+ for line in proc.stdout: # type: ignore[union-attr]
110
+ m = _TRYCF.search(line)
111
+ if m:
112
+ url_box.append(m.group(0))
113
+ break
114
+
115
+ t = threading.Thread(target=_scan, daemon=True)
116
+ t.start()
117
+ deadline = time.monotonic() + timeout
118
+ while time.monotonic() < deadline:
119
+ if url_box:
120
+ return Tunnel(local_url, url_box[0], proc)
121
+ if proc.poll() is not None:
122
+ raise RuntimeError("cloudflared exited before a tunnel URL appeared")
123
+ time.sleep(0.2)
124
+ proc.terminate()
125
+ raise TimeoutError("timed out waiting for the cloudflared tunnel URL")
126
+
127
+
128
+ if __name__ == "__main__": # tiny self-check: fraser tunnel demo (expects something serving :5050)
129
+ tun = open_tunnel("http://127.0.0.1:5050")
130
+ print("public:", tun.public_url)
131
+ time.sleep(2)
132
+ tun.close()
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: fraser-gate
3
+ Version: 0.1.0
4
+ Summary: MCP server + CLI that lets an AI coding agent run FRASER's persona swarm against a build and gate the fix pass/fail
5
+ Project-URL: Homepage, https://fraser.lythe.ai
6
+ Author-email: Lythe <team@lythe.ai>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,code-review,coding-agent,experience,fraser,mcp,merge-gate,model-context-protocol,qa,testing
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: mcp>=1.2.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # FRASER Experience Merge-Gate — for AI coding agents
26
+
27
+ Point FRASER's persona swarm at a build, get back grounded, machine-readable **experience** findings,
28
+ ship a fix, then re-run the **identical** cast to gate the fix `pass | fail`. FRASER is the loop's
29
+ **brain and eyes** — it finds where real users break and verifies the fix helped. It **never writes or
30
+ merges your code**; your agent does that.
31
+
32
+ Why this exists: a build can pass its own typecheck + unit tests and still have a user drop off. That
33
+ gap — **experience ≠ correctness** — is what the gate catches.
34
+
35
+ ## The loop
36
+
37
+ ```
38
+ your agent builds + deploys ─▶ fraser_start_review(target_url) → {run_id, project_id, flow_id}
39
+ │ (async, minutes — poll)
40
+ fraser_get_status(run_id) ─▶ complete
41
+ fraser_get_findings(run_id)
42
+ │ → findings[] {finding_id, screenshot, reproduction, fix_hint} + fix_prompt
43
+ your agent fixes + redeploys ◀────┘ (FRASER never touches code)
44
+
45
+ fraser_validate_fix(project_id, flow_id, baseline_run_id, new_url)
46
+ fraser_get_validation(run_id, baseline_run_id)
47
+ │ → {gate: pass|fail, per-finding {fixed|still_present|regressed}, new_findings}
48
+ gate=fail ⇒ don't merge · gate=pass ⇒ ship
49
+ ```
50
+
51
+ ## Setup (one paste)
52
+
53
+ Get an API token from the FRASER dashboard (**Settings → Connect MCP → Generate token**), then add to
54
+ your agent's `.mcp.json`:
55
+
56
+ ```jsonc
57
+ {
58
+ "mcpServers": {
59
+ "fraser": {
60
+ "command": "uvx",
61
+ "args": ["--from", "fraser-gate", "fraser-gate-mcp"],
62
+ "env": { "FRASER_API_KEY": "<your token>", "FRASER_API_URL": "https://fraser.lythe.ai" }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ That's it. **localhost just works** — if you point a review at `http://localhost:3000`, the MCP opens
69
+ an ephemeral public tunnel automatically so the hosted engine can reach your machine; you set nothing up.
70
+
71
+ ## Tools (6)
72
+
73
+ | Tool | Use |
74
+ |------|-----|
75
+ | `fraser_start_review(target_url, mode?, objective?, context?, num_testers?, auth_notes?)` | Start a review. Async — returns a `run_id`. |
76
+ | `fraser_get_status(run_id)` | Poll until terminal (`complete`/`failed`/`timed_out`). Runs take **minutes**. |
77
+ | `fraser_get_findings(run_id)` | Scrubbed findings + `fix_prompt`. You fix from these. |
78
+ | `fraser_validate_fix(project_id, flow_id, baseline_run_id, target_url)` | Re-run the identical cast on the fix. Async. |
79
+ | `fraser_get_validation(run_id, baseline_run_id)` | The `pass`/`fail` gate verdict. |
80
+ | `fraser_cancel(run_id)` | Stop a run. |
81
+
82
+ **It takes minutes.** FRASER generates personas from your app, then drives a real browser as each one.
83
+ Start the review, do other work, and poll `fraser_get_status` every few seconds.
84
+
85
+ **Knobs** (defaults are good): `mode` = `visual` (experience critique, default) or `battle` (defect
86
+ sweep); `num_testers` `0` = the grounded cast; `context` = one line on what the product is (anchors the
87
+ personas for a new/thin app); `auth_notes` = app login creds if it's behind your own login (secret).
88
+
89
+ ## CLI (same loop, for CI without MCP)
90
+
91
+ ```bash
92
+ export FRASER_API_KEY=<token> FRASER_API_URL=https://fraser.lythe.ai
93
+ fraser review https://myapp-pr42.vercel.app --wait # prints findings JSON
94
+ fraser validate <project_id> <flow_id> <baseline_run_id> <new_url> --wait # exits non-zero on a FAIL gate
95
+ ```
96
+
97
+ Local dev of this package: `pip install -e .` then run `python mcp_server.py` (stdio) or `python cli.py`.
@@ -0,0 +1,9 @@
1
+ fraser_gate/__init__.py,sha256=-GMbv-wp9NUuu8crB2PC9xYECie579rn59ekFZZX6mI,602
2
+ fraser_gate/cli.py,sha256=koZJ4JCcpj1x7_MRp6WuZQeE4JWnUIROG96in7IRcmg,7429
3
+ fraser_gate/mcp_server.py,sha256=TFnHhCPcydB_y_Pr6ebhycxdQdgfpZDUNRfkSCcAkpU,9367
4
+ fraser_gate/tunnel.py,sha256=XlTXaWf8EfygbnRcK8Kxv9ZXcnQQ_ARpr3gRLrmPYnk,5127
5
+ fraser_gate-0.1.0.dist-info/METADATA,sha256=kWb6lCQA-dfJgkW4mT7oHTgboKUezY81IOhYTvSQf_c,4716
6
+ fraser_gate-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ fraser_gate-0.1.0.dist-info/entry_points.txt,sha256=3ElKJ1NRo2RREyXifQ28FS1wkKSAIZ_MsVR-7L_zCU8,99
8
+ fraser_gate-0.1.0.dist-info/licenses/LICENSE,sha256=EQXLBOFxLucplu6CLqbf0wK6LS0DPXagRrsV_MwFZwU,1062
9
+ fraser_gate-0.1.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,3 @@
1
+ [console_scripts]
2
+ fraser-gate = fraser_gate.cli:main
3
+ fraser-gate-mcp = fraser_gate.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lythe
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.