diff --git a/README.md b/README.md index 13215e5..7e2241d 100644 --- a/README.md +++ b/README.md @@ -687,11 +687,26 @@ WebRunner ships a [Model Context Protocol](https://modelcontextprotocol.io/) ser python -m je_web_runner.mcp_server ``` -The default tool list exposes: +The default tool list (19 tools) exposes: -- `webrunner_lint_action`, `webrunner_locator_strength` -- `webrunner_render_template`, `webrunner_compute_trend` -- `webrunner_validate_response`, `webrunner_summary_markdown` +Action JSON authoring & linting: +- `webrunner_lint_action`, `webrunner_score_action_locators`, `webrunner_locator_strength` +- `webrunner_format_actions`, `webrunner_parse_markdown`, `webrunner_render_template` +- `webrunner_translate_actions_to_playwright`, `webrunner_translate_python_to_playwright` + +Code generation: +- `webrunner_pom_from_html` + +Quality / triage: +- `webrunner_a11y_diff`, `webrunner_cluster_failures`, `webrunner_compute_trend` + +Security & privacy: +- `webrunner_scan_pii`, `webrunner_redact_pii` + +Reporting & contract: +- `webrunner_summary_markdown`, `webrunner_validate_response` + +Sharding & infra: - `webrunner_diff_shard`, `webrunner_render_k8s`, `webrunner_partition_shard` ```python diff --git a/docs/source/Eng/doc/extended_features/extended_features_doc.rst b/docs/source/Eng/doc/extended_features/extended_features_doc.rst index a33991f..370331b 100644 --- a/docs/source/Eng/doc/extended_features/extended_features_doc.rst +++ b/docs/source/Eng/doc/extended_features/extended_features_doc.rst @@ -380,11 +380,22 @@ drive it over JSON-RPC stdio: python -m je_web_runner.mcp_server -Default tools registered: ``webrunner_lint_action``, -``webrunner_locator_strength``, ``webrunner_render_template``, -``webrunner_compute_trend``, ``webrunner_validate_response``, -``webrunner_summary_markdown``, ``webrunner_diff_shard``, -``webrunner_render_k8s``, ``webrunner_partition_shard``. +Default tools registered (19 in total): + +* Action authoring & lint: ``webrunner_lint_action``, + ``webrunner_score_action_locators``, ``webrunner_locator_strength``, + ``webrunner_format_actions``, ``webrunner_parse_markdown``, + ``webrunner_render_template``, + ``webrunner_translate_actions_to_playwright``, + ``webrunner_translate_python_to_playwright`` +* Code generation: ``webrunner_pom_from_html`` +* Quality & triage: ``webrunner_a11y_diff``, + ``webrunner_cluster_failures``, ``webrunner_compute_trend`` +* Security: ``webrunner_scan_pii``, ``webrunner_redact_pii`` +* Reporting & contract: ``webrunner_summary_markdown``, + ``webrunner_validate_response`` +* Sharding / infra: ``webrunner_diff_shard``, + ``webrunner_render_k8s``, ``webrunner_partition_shard`` Custom tools register via ``McpServer.register(Tool(...))``; the server implements MCP ``2024-11-05`` (``initialize`` / ``tools/list`` / diff --git a/docs/source/Zh/doc/extended_features/extended_features_doc.rst b/docs/source/Zh/doc/extended_features/extended_features_doc.rst index 215e052..a2781b7 100644 --- a/docs/source/Zh/doc/extended_features/extended_features_doc.rst +++ b/docs/source/Zh/doc/extended_features/extended_features_doc.rst @@ -264,12 +264,25 @@ MCP server python -m je_web_runner.mcp_server -預設工具:``webrunner_lint_action`` / ``webrunner_locator_strength`` / -``webrunner_render_template`` / ``webrunner_compute_trend`` / -``webrunner_validate_response`` / ``webrunner_summary_markdown`` / -``webrunner_diff_shard`` / ``webrunner_render_k8s`` / -``webrunner_partition_shard``。可透過 ``McpServer.register(Tool(...))`` -擴充自訂工具,協定版本 ``2024-11-05``。 +預設工具共 19 個,依用途分組: + +* Action 撰寫 / lint:``webrunner_lint_action`` / + ``webrunner_score_action_locators`` / ``webrunner_locator_strength`` / + ``webrunner_format_actions`` / ``webrunner_parse_markdown`` / + ``webrunner_render_template`` / + ``webrunner_translate_actions_to_playwright`` / + ``webrunner_translate_python_to_playwright`` +* 程式碼生成:``webrunner_pom_from_html`` +* 品質 / triage:``webrunner_a11y_diff`` / ``webrunner_cluster_failures`` + / ``webrunner_compute_trend`` +* 安全 / 隱私:``webrunner_scan_pii`` / ``webrunner_redact_pii`` +* 報告 / contract:``webrunner_summary_markdown`` / + ``webrunner_validate_response`` +* Sharding / infra:``webrunner_diff_shard`` / ``webrunner_render_k8s`` + / ``webrunner_partition_shard`` + +可透過 ``McpServer.register(Tool(...))`` 自行擴充工具,協定版本 +``2024-11-05``。 Action JSON LSP =============== diff --git a/je_web_runner/mcp_server/server.py b/je_web_runner/mcp_server/server.py index 50967f0..dfc05f3 100644 --- a/je_web_runner/mcp_server/server.py +++ b/je_web_runner/mcp_server/server.py @@ -145,8 +145,10 @@ def _tool_lint_action(arguments: Dict[str, Any]) -> Any: actions = arguments.get("actions") if not isinstance(actions, list): raise McpServerError("'actions' must be a list") - return [{"index": f.index, "level": f.level, "message": f.message, - "rule": f.rule} for f in lint_action(actions)] + # ``lint_action`` returns ``List[Dict[str, Any]]`` with ``rule`` / + # ``severity`` / ``message`` / ``location`` keys; pass through verbatim + # so MCP clients see the same shape the Python API exposes. + return list(lint_action(actions)) def _tool_locator_strength(arguments: Dict[str, Any]) -> Any: @@ -232,6 +234,127 @@ def _tool_partition(arguments: Dict[str, Any]) -> Any: ) +def _tool_format_actions(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.action_formatter.formatter import format_actions + actions = arguments.get("actions") + if not isinstance(actions, list): + raise McpServerError("'actions' must be a list") + return format_actions(actions, indent=int(arguments.get("indent", 2))) + + +def _tool_parse_markdown(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.md_authoring.markdown_to_actions import parse_markdown + text = arguments.get("text") + if not isinstance(text, str): + raise McpServerError("'text' must be a string") + return parse_markdown(text) + + +def _tool_translate_actions_to_playwright(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.sel_to_pw.translator import translate_action_list + actions = arguments.get("actions") + if not isinstance(actions, list): + raise McpServerError("'actions' must be a list") + return translate_action_list(actions) + + +def _tool_translate_python_to_playwright(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.sel_to_pw.translator import translate_python_source + source = arguments.get("source") + if not isinstance(source, str): + raise McpServerError("'source' must be a string") + translations = translate_python_source(source) + return [ + {"line": t.line, "original": t.original, + "translated": t.translated, "note": t.note} + for t in translations + ] + + +def _tool_pom_from_html(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.pom_codegen.codegen import ( + discover_elements_from_html, + render_pom_module, + ) + html = arguments.get("html") + if not isinstance(html, str): + raise McpServerError("'html' must be a string") + elements = discover_elements_from_html(html) + class_name = str(arguments.get("class_name", "WebRunnerPage")) + return { + "module": render_pom_module(elements, class_name=class_name), + "elements": [ + {"name": e.name, "strategy": e.strategy, + "value": e.value, "tag": e.tag, "source": e.source} + for e in elements + ], + } + + +def _tool_scan_pii(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.pii_scanner.scanner import scan_text + text = arguments.get("text") + if not isinstance(text, str): + raise McpServerError("'text' must be a string") + categories = arguments.get("categories") + findings = scan_text(text, categories=categories) + return [ + {"category": f.category, "start": f.start, + "end": f.end, "redacted": f.redacted} + for f in findings + ] + + +def _tool_redact_pii(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.pii_scanner.scanner import redact_text + text = arguments.get("text") + if not isinstance(text, str): + raise McpServerError("'text' must be a string") + return redact_text( + text, + replacement=str(arguments.get("replacement", "[REDACTED]")), + categories=arguments.get("categories"), + ) + + +def _tool_cluster_failures(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.failure_cluster.clustering import ( + cluster_failures, + cluster_summary, + ) + failures = arguments.get("failures") + if not isinstance(failures, list): + raise McpServerError("'failures' must be a list") + top_n = arguments.get("top_n") + if top_n is not None: + top_n = int(top_n) + clusters = cluster_failures(failures, top_n=top_n) + return cluster_summary(clusters) + + +def _tool_a11y_diff(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.accessibility.a11y_diff import diff_violations + baseline = arguments.get("baseline") + current = arguments.get("current") + if not isinstance(baseline, list) or not isinstance(current, list): + raise McpServerError("'baseline' and 'current' must be lists") + diff = diff_violations(baseline, current) + return { + "added": diff.added, + "resolved": diff.resolved, + "persisting": diff.persisting, + "regressed": diff.regressed, + } + + +def _tool_score_action_locators(arguments: Dict[str, Any]) -> Any: + from je_web_runner.utils.linter.locator_strength import score_action_locators + actions = arguments.get("actions") + if not isinstance(actions, list): + raise McpServerError("'actions' must be a list") + return list(score_action_locators(actions)) + + def build_default_tools() -> List[Tool]: """Construct the default tool list shipped with the server.""" return [ @@ -351,6 +474,146 @@ def build_default_tools() -> List[Tool]: }, handler=_tool_partition, ), + Tool( + name="webrunner_format_actions", + description="Format an action JSON list with canonical kwarg order.", + input_schema={ + "type": "object", + "properties": { + "actions": {"type": "array"}, + "indent": {"type": "integer"}, + }, + "required": ["actions"], + }, + handler=_tool_format_actions, + ), + Tool( + name="webrunner_parse_markdown", + description="Transpile a Markdown bullet list into a WR_* action list.", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + handler=_tool_parse_markdown, + ), + Tool( + name="webrunner_translate_actions_to_playwright", + description=( + "Rewrite a WR_* action list to its WR_pw_* Playwright" + " equivalent (drops WR_implicitly_wait)." + ), + input_schema={ + "type": "object", + "properties": {"actions": {"type": "array"}}, + "required": ["actions"], + }, + handler=_tool_translate_actions_to_playwright, + ), + Tool( + name="webrunner_translate_python_to_playwright", + description=( + "Static translator: rewrites Selenium-style Python source" + " into Playwright equivalents; returns per-line diffs." + ), + input_schema={ + "type": "object", + "properties": {"source": {"type": "string"}}, + "required": ["source"], + }, + handler=_tool_translate_python_to_playwright, + ), + Tool( + name="webrunner_pom_from_html", + description=( + "Discover [data-testid] / id / form fields in HTML and" + " render a Python Page Object module." + ), + input_schema={ + "type": "object", + "properties": { + "html": {"type": "string"}, + "class_name": {"type": "string"}, + }, + "required": ["html"], + }, + handler=_tool_pom_from_html, + ), + Tool( + name="webrunner_scan_pii", + description=( + "Scan text for PII (email / phone / Luhn-card / SSN /" + " ROC ID / IPv4); returns category + redacted preview." + ), + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string"}, + "categories": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text"], + }, + handler=_tool_scan_pii, + ), + Tool( + name="webrunner_redact_pii", + description="Replace each detected PII match with a sentinel string.", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string"}, + "replacement": {"type": "string"}, + "categories": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text"], + }, + handler=_tool_redact_pii, + ), + Tool( + name="webrunner_cluster_failures", + description=( + "Group failures by normalised error signature; returns" + " top buckets sorted by count." + ), + input_schema={ + "type": "object", + "properties": { + "failures": {"type": "array"}, + "top_n": {"type": "integer"}, + }, + "required": ["failures"], + }, + handler=_tool_cluster_failures, + ), + Tool( + name="webrunner_a11y_diff", + description=( + "Diff two axe-core ``violations`` arrays and bucket the" + " findings into added / resolved / persisting." + ), + input_schema={ + "type": "object", + "properties": { + "baseline": {"type": "array"}, + "current": {"type": "array"}, + }, + "required": ["baseline", "current"], + }, + handler=_tool_a11y_diff, + ), + Tool( + name="webrunner_score_action_locators", + description=( + "Score every locator referenced by an action JSON list on" + " a 0–100 scale; lower = more fragile." + ), + input_schema={ + "type": "object", + "properties": {"actions": {"type": "array"}}, + "required": ["actions"], + }, + handler=_tool_score_action_locators, + ), ] diff --git a/test/unit_test/test_mcp_server.py b/test/unit_test/test_mcp_server.py index ce49e75..15313a1 100644 --- a/test/unit_test/test_mcp_server.py +++ b/test/unit_test/test_mcp_server.py @@ -118,6 +118,121 @@ def test_default_tools_inputschemas(self): for tool in build_default_tools(): self.assertEqual(tool.input_schema["type"], "object") + def test_full_default_tool_surface(self): + # The MCP server is the public LLM-facing surface; freeze the tool + # list here so accidental removals fail loudly in review. + names = {tool.name for tool in build_default_tools()} + self.assertEqual(names, { + "webrunner_lint_action", + "webrunner_locator_strength", + "webrunner_render_template", + "webrunner_compute_trend", + "webrunner_validate_response", + "webrunner_summary_markdown", + "webrunner_diff_shard", + "webrunner_render_k8s", + "webrunner_partition_shard", + "webrunner_format_actions", + "webrunner_parse_markdown", + "webrunner_translate_actions_to_playwright", + "webrunner_translate_python_to_playwright", + "webrunner_pom_from_html", + "webrunner_scan_pii", + "webrunner_redact_pii", + "webrunner_cluster_failures", + "webrunner_a11y_diff", + "webrunner_score_action_locators", + }) + + +class TestNewTools(unittest.TestCase): + + def setUp(self): + self.server = make_default_server() + + def _call(self, name, arguments): + return self.server.handle({"id": 1, "method": "tools/call", "params": { + "name": name, "arguments": arguments, + }}) + + def test_format_actions(self): + result = self._call("webrunner_format_actions", + {"actions": [["WR_quit_all"]]}) + self.assertFalse(result["result"]["isError"]) + self.assertIn('["WR_quit_all"]', result["result"]["content"][0]["text"]) + + def test_parse_markdown(self): + result = self._call("webrunner_parse_markdown", + {"text": "- open https://example.com\n- quit"}) + body = result["result"]["content"][0]["text"] + self.assertIn("WR_to_url", body) + self.assertIn("WR_quit_all", body) + + def test_translate_actions_to_playwright(self): + result = self._call("webrunner_translate_actions_to_playwright", { + "actions": [["WR_to_url", {"url": "https://x"}]], + }) + self.assertIn("WR_pw_to_url", result["result"]["content"][0]["text"]) + + def test_translate_python_to_playwright(self): + result = self._call("webrunner_translate_python_to_playwright", { + "source": "driver.get('https://x.com')", + }) + self.assertIn("page.goto", result["result"]["content"][0]["text"]) + + def test_pom_from_html(self): + html = '' + result = self._call("webrunner_pom_from_html", + {"html": html, "class_name": "Login"}) + body = result["result"]["content"][0]["text"] + self.assertIn("class Login", body) + self.assertIn("primary_cta", body) + + def test_scan_pii(self): + result = self._call("webrunner_scan_pii", + {"text": "email alice@example.com here"}) + body = result["result"]["content"][0]["text"] + self.assertIn("email", body) + + def test_redact_pii(self): + result = self._call("webrunner_redact_pii", { + "text": "email alice@example.com here", + "replacement": "[X]", + }) + self.assertIn("[X]", result["result"]["content"][0]["text"]) + + def test_cluster_failures(self): + result = self._call("webrunner_cluster_failures", { + "failures": [ + {"function_name": "a", "exception": "TimeoutError at 0xab"}, + {"function_name": "b", "exception": "TimeoutError at 0xcd"}, + ] + }) + body = result["result"]["content"][0]["text"] + self.assertIn('"count": 2', body) + + def test_a11y_diff(self): + result = self._call("webrunner_a11y_diff", { + "baseline": [], + "current": [{ + "id": "label", + "impact": "serious", + "nodes": [{"target": ["input.email"]}], + }], + }) + body = result["result"]["content"][0]["text"] + self.assertIn('"regressed": true', body) + + def test_score_action_locators(self): + result = self._call("webrunner_score_action_locators", { + "actions": [ + ["WR_save_test_object", + {"test_object_name": "submit", "object_type": "ID"}], + ], + }) + body = result["result"]["content"][0]["text"] + self.assertIn("score", body) + if __name__ == "__main__": unittest.main()