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.
- fraser_gate/__init__.py +12 -0
- fraser_gate/cli.py +181 -0
- fraser_gate/mcp_server.py +185 -0
- fraser_gate/tunnel.py +132 -0
- fraser_gate-0.1.0.dist-info/METADATA +97 -0
- fraser_gate-0.1.0.dist-info/RECORD +9 -0
- fraser_gate-0.1.0.dist-info/WHEEL +4 -0
- fraser_gate-0.1.0.dist-info/entry_points.txt +3 -0
- fraser_gate-0.1.0.dist-info/licenses/LICENSE +21 -0
fraser_gate/__init__.py
ADDED
|
@@ -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,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.
|