frontend-perception-engine 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.
Files changed (50) hide show
  1. frontend_perception_engine-0.1.0.dist-info/METADATA +235 -0
  2. frontend_perception_engine-0.1.0.dist-info/RECORD +50 -0
  3. frontend_perception_engine-0.1.0.dist-info/WHEEL +5 -0
  4. frontend_perception_engine-0.1.0.dist-info/entry_points.txt +2 -0
  5. frontend_perception_engine-0.1.0.dist-info/top_level.txt +1 -0
  6. navigation/__init__.py +1 -0
  7. navigation/browser_use/__init__.py +17 -0
  8. navigation/browser_use/agent_runner.py +165 -0
  9. navigation/browser_use/hints.py +155 -0
  10. navigation/browser_use/integration.py +46 -0
  11. navigation/browser_use/llm.py +70 -0
  12. navigation/codeGraph/__init__.py +4 -0
  13. navigation/codeGraph/crg_impl.py +170 -0
  14. navigation/codeGraph/factory.py +24 -0
  15. navigation/codeGraph/interface.py +78 -0
  16. navigation/codeGraph/null_impl.py +47 -0
  17. navigation/mcp/__init__.py +4 -0
  18. navigation/mcp/__main__.py +4 -0
  19. navigation/mcp/diff.py +28 -0
  20. navigation/mcp/envelope.py +50 -0
  21. navigation/mcp/handlers.py +791 -0
  22. navigation/mcp/instructions.py +36 -0
  23. navigation/mcp/resources.py +82 -0
  24. navigation/mcp/scan_registry.py +47 -0
  25. navigation/mcp/server.py +229 -0
  26. navigation/mcp/session_store.py +87 -0
  27. navigation/mcp/tools.py +332 -0
  28. navigation/perception/__init__.py +76 -0
  29. navigation/perception/artifacts.py +19 -0
  30. navigation/perception/auth_gate.py +57 -0
  31. navigation/perception/budget.py +55 -0
  32. navigation/perception/cdp_hub.py +122 -0
  33. navigation/perception/dev_insights.py +625 -0
  34. navigation/perception/exploration.py +99 -0
  35. navigation/perception/feature_flags.py +83 -0
  36. navigation/perception/file_upload.py +84 -0
  37. navigation/perception/flow_graph.py +121 -0
  38. navigation/perception/form_probe.py +148 -0
  39. navigation/perception/iframe_context.py +76 -0
  40. navigation/perception/observation.py +97 -0
  41. navigation/perception/preflight.py +91 -0
  42. navigation/perception/rich_editors.py +90 -0
  43. navigation/perception/route_guards.py +136 -0
  44. navigation/perception/runner.py +138 -0
  45. navigation/perception/scan.py +74 -0
  46. navigation/perception/scripted_actions.py +67 -0
  47. navigation/perception/state_manager.py +114 -0
  48. navigation/perception/verification.py +117 -0
  49. navigation/perception/virtual_scroll.py +84 -0
  50. navigation/perception/websocket_observer.py +79 -0
@@ -0,0 +1,36 @@
1
+ """MCP server preamble — programs host agent behavior. See AGENT_GUIDE.md for full playbooks."""
2
+
3
+ MCP_INSTRUCTIONS = """\
4
+ Frontend Perception MCP — deterministic browser runtime (no LLM inside this server).
5
+
6
+ YOU are the brain. This server returns facts only — never "next step" suggestions.
7
+ Read AGENT_GUIDE.md at session start (resource: perception://agent-guide).
8
+
9
+ Universal loop (AGENT_GUIDE §0):
10
+ OBSERVE → REASON → ACT → VERIFY → repeat or STOP
11
+
12
+ Bootstrap (§1):
13
+ perception_health → perception_session_start → save session_id
14
+
15
+ Playbooks by task (AGENT_GUIDE.md):
16
+ §2 Page inspection / new UI → navigate_and_observe, verify, diff
17
+ §3 Debugging broken UI → observe blocking first, fix code, verify + diff
18
+ §4 Forms & validation → probe_form, invalid submit, verify, valid fill, verify
19
+ §5 Navigation & route guards → probe_guards, auth_gate, state_save/restore
20
+ §6 Multi-step flows → flow_describe, per-checkpoint verify
21
+ §7 Regression verify-only → observe + verify (no act unless fail)
22
+ §8 Responsive / viewport → session_start with viewport, observe + screenshot
23
+ §9 Feature flags & edge UI → edge-lab routes, feature-specific verify
24
+ §10 Code ↔ live UI → code_context + observe + diff
25
+
26
+ Hard rules:
27
+ - Never claim UI work is done without perception_verify.
28
+ - Never skip verify after execute_script or execute_actions.
29
+ - Read agent_summary.blocking before advisory warnings.
30
+ - On verify failure: re-observe with screenshot, then perception_diff.
31
+ - perception_auth_gate requires_human → STOP, ask user (no login/MFA loops).
32
+ - probe_form before filling unknown forms; invalid submit before valid.
33
+ - flow_describe gives checkpoints — YOU execute each; MCP does not run flows.
34
+
35
+ Eval scenario (smoke test your wiring): perception://eval/validation-form
36
+ """
@@ -0,0 +1,82 @@
1
+ """MCP resources — guides, evals, and scan artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .scan_registry import ScanRegistry
9
+
10
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
11
+
12
+
13
+ def list_resources(scans: ScanRegistry | None = None) -> list[dict[str, str]]:
14
+ resources = [
15
+ {
16
+ "uri": "perception://agent-guide",
17
+ "name": "AGENT_GUIDE",
18
+ "description": "Primary behavior contract — playbooks for host agent (read at session start)",
19
+ "mimeType": "text/markdown",
20
+ },
21
+ {
22
+ "uri": "perception://eval/validation-form",
23
+ "name": "Validation Form Eval",
24
+ "description": "M3 eval scenario — complete using AGENT_GUIDE §4 form playbook",
25
+ "mimeType": "text/markdown",
26
+ },
27
+ ]
28
+ if scans is not None:
29
+ for rec in scans.all():
30
+ resources.append(
31
+ {
32
+ "uri": f"perception://scan/{rec.scan_id}/report.json",
33
+ "name": f"scan_report_{rec.scan_id}",
34
+ "description": f"Observation report for {rec.scan_id}",
35
+ "mimeType": "application/json",
36
+ }
37
+ )
38
+ if rec.screenshot_path:
39
+ resources.append(
40
+ {
41
+ "uri": f"perception://scan/{rec.scan_id}/screenshot.png",
42
+ "name": f"scan_screenshot_{rec.scan_id}",
43
+ "description": f"Screenshot image for {rec.scan_id}",
44
+ "mimeType": "image/png",
45
+ }
46
+ )
47
+ return resources
48
+
49
+
50
+ def read_resource(uri: str, scans: ScanRegistry | None = None) -> tuple[str, str, bool]:
51
+ """Return (mime_type, payload, is_blob). Raises KeyError if unknown."""
52
+ if uri == "perception://agent-guide":
53
+ path = PROJECT_ROOT / "AGENT_GUIDE.md"
54
+ if not path.is_file():
55
+ raise FileNotFoundError(f"AGENT_GUIDE.md not found at {path}")
56
+ return "text/markdown", path.read_text(encoding="utf-8"), False
57
+
58
+ if uri == "perception://eval/validation-form":
59
+ path = PROJECT_ROOT / "evals" / "VALIDATION_FORM_EVAL.md"
60
+ if not path.is_file():
61
+ raise FileNotFoundError(f"eval doc not found at {path}")
62
+ return "text/markdown", path.read_text(encoding="utf-8"), False
63
+
64
+ if uri.startswith("perception://scan/") and scans is not None:
65
+ # pattern: perception://scan/{scan_id}/(report.json|screenshot.png)
66
+ parts = uri.split("/")
67
+ if len(parts) >= 5:
68
+ scan_id = parts[3]
69
+ artifact = parts[4]
70
+ rec = scans.get(scan_id)
71
+ if rec is None:
72
+ raise KeyError(uri)
73
+ if artifact == "report.json":
74
+ return "application/json", json.dumps(rec.observation, indent=2, default=str), False
75
+ if artifact == "screenshot.png":
76
+ screenshot_path = Path(str(rec.screenshot_path or ""))
77
+ if not screenshot_path.is_file():
78
+ return "text/plain", "", False
79
+ raw = screenshot_path.read_bytes()
80
+ return "image/png", base64.b64encode(raw).decode("ascii"), True
81
+
82
+ raise KeyError(uri)
@@ -0,0 +1,47 @@
1
+ """Observation snapshots keyed by scan_id (for diff and audit)."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ScanRecord:
11
+ scan_id: str
12
+ session_id: str
13
+ run_id: str
14
+ url: str
15
+ observation: dict[str, Any]
16
+ screenshot_path: str | None = None
17
+
18
+
19
+ class ScanRegistry:
20
+ def __init__(self) -> None:
21
+ self._scans: dict[str, ScanRecord] = {}
22
+
23
+ def register(
24
+ self,
25
+ *,
26
+ session_id: str,
27
+ run_id: str,
28
+ url: str,
29
+ observation: dict[str, Any],
30
+ ) -> ScanRecord:
31
+ scan_id = f"scan_{uuid.uuid4().hex[:12]}"
32
+ rec = ScanRecord(
33
+ scan_id=scan_id,
34
+ session_id=session_id,
35
+ run_id=run_id,
36
+ url=url,
37
+ observation=observation,
38
+ screenshot_path=observation.get("screenshot_path"),
39
+ )
40
+ self._scans[scan_id] = rec
41
+ return rec
42
+
43
+ def get(self, scan_id: str) -> ScanRecord | None:
44
+ return self._scans.get(scan_id)
45
+
46
+ def all(self) -> list[ScanRecord]:
47
+ return list(self._scans.values())
@@ -0,0 +1,229 @@
1
+ """Frontend Perception MCP server — host agent is the brain; this is the runtime."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any
11
+
12
+ os.environ.setdefault("BROWSER_USE_LOGGING_LEVEL", "warning")
13
+ os.environ.setdefault("BROWSER_USE_SETUP_LOGGING", "false")
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ try:
18
+ import mcp.server.stdio
19
+ import mcp.types as types
20
+ from mcp.server import NotificationOptions, Server
21
+ from mcp.server.models import InitializationOptions
22
+
23
+ MCP_AVAILABLE = True
24
+ except ImportError:
25
+ MCP_AVAILABLE = False
26
+ types = None # type: ignore
27
+
28
+ from .handlers import (
29
+ handle_auth_gate,
30
+ handle_code_context,
31
+ handle_diff,
32
+ handle_execute_actions,
33
+ handle_execute_script,
34
+ handle_flow_describe,
35
+ handle_health,
36
+ handle_navigate,
37
+ handle_navigate_and_observe,
38
+ handle_observe,
39
+ handle_probe_form,
40
+ handle_probe_guards,
41
+ handle_session_end,
42
+ handle_session_start,
43
+ handle_state_list,
44
+ handle_state_restore,
45
+ handle_state_save,
46
+ handle_verify,
47
+ )
48
+ from .instructions import MCP_INSTRUCTIONS
49
+ from .resources import list_resources, read_resource
50
+ from .scan_registry import ScanRegistry
51
+ from .session_store import SessionStore
52
+ from .tools import perception_tools
53
+
54
+
55
+ HandlerFn = Callable[..., Awaitable[dict[str, Any]]]
56
+
57
+
58
+ class PerceptionMCPServer:
59
+ def __init__(self) -> None:
60
+ if not MCP_AVAILABLE:
61
+ raise RuntimeError("mcp package not installed. pip install mcp")
62
+ self._store = SessionStore()
63
+ self._scans = ScanRegistry()
64
+ self._server = Server("frontend-perception")
65
+ self._dispatch: dict[str, HandlerFn] = self._build_dispatch()
66
+
67
+ @self._server.list_tools()
68
+ async def list_tools() -> list[types.Tool]:
69
+ return perception_tools(types)
70
+
71
+ @self._server.list_resources()
72
+ async def list_resources_handler() -> list[types.Resource]:
73
+ return [
74
+ types.Resource(
75
+ uri=item["uri"],
76
+ name=item["name"],
77
+ description=item["description"],
78
+ mimeType=item["mimeType"],
79
+ )
80
+ for item in list_resources(self._scans)
81
+ ]
82
+
83
+ @self._server.read_resource()
84
+ async def read_resource_handler(uri: str) -> types.TextResourceContents | types.BlobResourceContents:
85
+ uri_str = str(uri)
86
+ mime, payload, is_blob = read_resource(uri_str, self._scans)
87
+ if is_blob:
88
+ return types.BlobResourceContents(uri=uri_str, mimeType=mime, blob=payload)
89
+ return types.TextResourceContents(uri=uri_str, mimeType=mime, text=payload)
90
+
91
+ @self._server.call_tool()
92
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
93
+ args = arguments or {}
94
+ try:
95
+ handler = self._dispatch.get(name)
96
+ if handler is None:
97
+ result = {
98
+ "contract_version": "1.0",
99
+ "tool": name,
100
+ "ok": False,
101
+ "error": f"unknown tool: {name}",
102
+ "data": {},
103
+ }
104
+ else:
105
+ result = await handler(args)
106
+ except Exception as exc:
107
+ logger.exception("tool %s failed", name)
108
+ result = {
109
+ "contract_version": "1.0",
110
+ "tool": name,
111
+ "ok": False,
112
+ "error": str(exc),
113
+ "data": {},
114
+ }
115
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
116
+
117
+ def _build_dispatch(self) -> dict[str, HandlerFn]:
118
+ store = self._store
119
+ scans = self._scans
120
+
121
+ async def health(args: dict[str, Any]) -> dict[str, Any]:
122
+ return await handle_health(args)
123
+
124
+ async def session_start(args: dict[str, Any]) -> dict[str, Any]:
125
+ return await handle_session_start(store, args)
126
+
127
+ async def session_end(args: dict[str, Any]) -> dict[str, Any]:
128
+ return await handle_session_end(store, args)
129
+
130
+ async def navigate(args: dict[str, Any]) -> dict[str, Any]:
131
+ return await handle_navigate(store, args)
132
+
133
+ async def navigate_and_observe(args: dict[str, Any]) -> dict[str, Any]:
134
+ return await handle_navigate_and_observe(store, scans, args)
135
+
136
+ async def observe(args: dict[str, Any]) -> dict[str, Any]:
137
+ return await handle_observe(store, scans, args)
138
+
139
+ async def execute_script(args: dict[str, Any]) -> dict[str, Any]:
140
+ return await handle_execute_script(store, scans, args)
141
+
142
+ async def execute_actions(args: dict[str, Any]) -> dict[str, Any]:
143
+ return await handle_execute_actions(store, scans, args)
144
+
145
+ async def verify(args: dict[str, Any]) -> dict[str, Any]:
146
+ return await handle_verify(store, args)
147
+
148
+ async def diff(args: dict[str, Any]) -> dict[str, Any]:
149
+ return await handle_diff(scans, args)
150
+
151
+ async def auth_gate(args: dict[str, Any]) -> dict[str, Any]:
152
+ return await handle_auth_gate(store, args)
153
+
154
+ async def probe_form(args: dict[str, Any]) -> dict[str, Any]:
155
+ return await handle_probe_form(store, args)
156
+
157
+ async def probe_guards(args: dict[str, Any]) -> dict[str, Any]:
158
+ return await handle_probe_guards(store, args)
159
+
160
+ async def state_save(args: dict[str, Any]) -> dict[str, Any]:
161
+ return await handle_state_save(store, args)
162
+
163
+ async def state_restore(args: dict[str, Any]) -> dict[str, Any]:
164
+ return await handle_state_restore(store, args)
165
+
166
+ async def state_list(args: dict[str, Any]) -> dict[str, Any]:
167
+ return await handle_state_list(store, args)
168
+
169
+ async def flow_describe(args: dict[str, Any]) -> dict[str, Any]:
170
+ return await handle_flow_describe(args)
171
+
172
+ async def code_context(args: dict[str, Any]) -> dict[str, Any]:
173
+ return await handle_code_context(args)
174
+
175
+ return {
176
+ "perception_health": health,
177
+ "perception_session_start": session_start,
178
+ "perception_session_end": session_end,
179
+ "perception_navigate": navigate,
180
+ "perception_navigate_and_observe": navigate_and_observe,
181
+ "perception_observe": observe,
182
+ "perception_execute_script": execute_script,
183
+ "perception_execute_actions": execute_actions,
184
+ "perception_verify": verify,
185
+ "perception_diff": diff,
186
+ "perception_auth_gate": auth_gate,
187
+ "perception_probe_form": probe_form,
188
+ "perception_probe_guards": probe_guards,
189
+ "perception_state_save": state_save,
190
+ "perception_state_restore": state_restore,
191
+ "perception_state_list": state_list,
192
+ "perception_flow_describe": flow_describe,
193
+ "perception_code_context": code_context,
194
+ }
195
+
196
+ async def run(self) -> None:
197
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
198
+ await self._server.run(
199
+ read_stream,
200
+ write_stream,
201
+ InitializationOptions(
202
+ server_name="frontend-perception",
203
+ server_version="0.4.0",
204
+ capabilities=self._server.get_capabilities(
205
+ notification_options=NotificationOptions(),
206
+ experimental_capabilities={},
207
+ ),
208
+ instructions=MCP_INSTRUCTIONS,
209
+ ),
210
+ )
211
+
212
+
213
+ async def async_main() -> None:
214
+ if not MCP_AVAILABLE:
215
+ print("Install MCP: pip install mcp", file=sys.stderr)
216
+ raise SystemExit(1)
217
+ server = PerceptionMCPServer()
218
+ try:
219
+ await server.run()
220
+ finally:
221
+ await server._store.end_all()
222
+
223
+
224
+ def main() -> None:
225
+ asyncio.run(async_main())
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
@@ -0,0 +1,87 @@
1
+ """Browser session lifecycle for MCP tools."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from navigation.perception.state_manager import StateManager
10
+
11
+
12
+ @dataclass
13
+ class SessionRecord:
14
+ session_id: str
15
+ base_url: str
16
+ browser: Any
17
+ artifacts_dir: Path
18
+ state_manager: StateManager = field(default_factory=StateManager)
19
+ run_counter: int = 0
20
+ current_run_id: str = ""
21
+
22
+ def next_run_id(self) -> str:
23
+ self.run_counter += 1
24
+ self.current_run_id = f"run_{self.run_counter:04d}"
25
+ return self.current_run_id
26
+
27
+
28
+ class SessionStore:
29
+ def __init__(self, artifacts_root: Path | None = None) -> None:
30
+ self._sessions: dict[str, SessionRecord] = {}
31
+ self._artifacts_root = artifacts_root or Path.cwd() / "artifacts" / "mcp"
32
+
33
+ def get(self, session_id: str) -> SessionRecord | None:
34
+ return self._sessions.get(session_id)
35
+
36
+ def require(self, session_id: str) -> SessionRecord:
37
+ rec = self.get(session_id)
38
+ if rec is None:
39
+ raise KeyError(f"unknown session_id: {session_id}")
40
+ return rec
41
+
42
+ async def start(
43
+ self,
44
+ *,
45
+ base_url: str,
46
+ headless: bool = True,
47
+ viewport_width: int = 1920,
48
+ viewport_height: int = 1080,
49
+ ) -> SessionRecord:
50
+ from browser_use import BrowserProfile, BrowserSession
51
+
52
+ session_id = f"sess_{uuid.uuid4().hex[:12]}"
53
+ artifacts_dir = self._artifacts_root / session_id
54
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
55
+
56
+ browser = BrowserSession(
57
+ browser_profile=BrowserProfile(
58
+ headless=headless,
59
+ viewport={"width": viewport_width, "height": viewport_height},
60
+ )
61
+ )
62
+ await browser.start()
63
+ await browser.navigate_to(base_url.rstrip("/"))
64
+
65
+ rec = SessionRecord(
66
+ session_id=session_id,
67
+ base_url=base_url.rstrip("/"),
68
+ browser=browser,
69
+ artifacts_dir=artifacts_dir,
70
+ )
71
+ rec.next_run_id()
72
+ self._sessions[session_id] = rec
73
+ return rec
74
+
75
+ async def end(self, session_id: str) -> bool:
76
+ rec = self._sessions.pop(session_id, None)
77
+ if rec is None:
78
+ return False
79
+ try:
80
+ await rec.browser.kill()
81
+ except Exception:
82
+ pass
83
+ return True
84
+
85
+ async def end_all(self) -> None:
86
+ for sid in list(self._sessions):
87
+ await self.end(sid)