From ff34525fc863faaf96ff2c5feed543a27ce9fb4b Mon Sep 17 00:00:00 2001 From: jdearmas Date: Mon, 15 Jun 2026 22:34:03 -0700 Subject: [PATCH 1/2] feat: add dynamic addon generation, flow tagging, and result visibility tools - Add run_dynamic_addon tool: uses Claude (claude-opus-4-8) to generate and hot-load a mitmproxy addon class from a natural language description - Add list_dynamic_addons and remove_dynamic_addon tools for addon lifecycle management - Add get_upstream_command tool to return a pre-filled mitmproxy upstream CLI command - Persist flow.comment to SQLite (new comment column with migration for existing DBs); dynamic addons tag every flow they touch with 'addon:' via the request hook - Add get_addon_flows tool to retrieve all flows tagged by a specific addon - Add tag filter to search_traffic for filtering by comment/tag - Add get_flow_tag_histogram tool to show per-addon flow counts - Add anthropic>=0.40.0 dependency Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 7 + src/mitmproxy_mcp/core/recorder.py | 65 +++++++-- src/mitmproxy_mcp/core/server.py | 221 ++++++++++++++++++++++++++++- uv.lock | 109 +++++++++++++- 4 files changed, 383 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index abb0853..0ef66a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "beautifulsoup4>=4.14.3", "playwright>=1.58.0", "google-re2>=1.1", + "anthropic>=0.40.0", ] [project.urls] @@ -67,3 +68,9 @@ python_files = "test_*.py" [tool.ruff] line-length = 100 target-version = "py310" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] diff --git a/src/mitmproxy_mcp/core/recorder.py b/src/mitmproxy_mcp/core/recorder.py index 983832f..8586b4c 100644 --- a/src/mitmproxy_mcp/core/recorder.py +++ b/src/mitmproxy_mcp/core/recorder.py @@ -83,12 +83,17 @@ def _init_db(self): response_headers TEXT, response_body TEXT, timestamp REAL, - size INTEGER + size INTEGER, + comment TEXT DEFAULT '' ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON flows(timestamp)") conn.execute("CREATE INDEX IF NOT EXISTS idx_url ON flows(url)") conn.execute("CREATE INDEX IF NOT EXISTS idx_method ON flows(method)") + try: + conn.execute("ALTER TABLE flows ADD COLUMN comment TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass def save_flow(self, flow: http.HTTPFlow): """Upserts a flow into the database.""" @@ -98,6 +103,8 @@ def save_flow(self, flow: http.HTTPFlow): status_code = flow.response.status_code if flow.response else None size = len(flow.response.content) if flow.response and flow.response.content else 0 + comment = getattr(flow, "comment", "") or "" + with self._get_conn() as conn: conn.execute( """ @@ -105,8 +112,8 @@ def save_flow(self, flow: http.HTTPFlow): id, url, method, status_code, request_headers, request_body, response_headers, response_body, - timestamp, size - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + timestamp, size, comment + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET url=excluded.url, method=excluded.method, @@ -115,7 +122,8 @@ def save_flow(self, flow: http.HTTPFlow): request_body=excluded.request_body, response_headers=excluded.response_headers, response_body=excluded.response_body, - size=excluded.size + size=excluded.size, + comment=excluded.comment """, ( flow.id, @@ -140,6 +148,7 @@ def save_flow(self, flow: http.HTTPFlow): resp_body, flow.request.timestamp_start, size, + comment, ), ) @@ -153,7 +162,7 @@ def get_summary( cursor = conn.execute( """ SELECT id, url, method, status_code, - response_headers, timestamp, size + response_headers, timestamp, size, comment FROM flows ORDER BY timestamp DESC LIMIT ? OFFSET ? @@ -181,6 +190,7 @@ def get_summary( "content_type": content_type, "size": row["size"], "timestamp": row["timestamp"], + "comment": row["comment"] or "", } ) return result @@ -215,6 +225,7 @@ def get_detail(self, flow_id: str) -> Optional[Dict[str, Any]]: return { "id": row["id"], + "comment": row["comment"] or "", "request": { "method": simple_request.method, "url": simple_request.url, @@ -232,9 +243,14 @@ def get_detail(self, flow_id: str) -> Optional[Dict[str, Any]]: } def search( - self, query: str = None, domain: str = None, method: str = None, limit: int = 50 + self, + query: str = None, + domain: str = None, + method: str = None, + limit: int = 50, + comment: str = None, ) -> List[Dict[str, Any]]: - sql = "SELECT id, url, method, status_code, timestamp FROM flows WHERE 1=1" + sql = "SELECT id, url, method, status_code, timestamp, comment FROM flows WHERE 1=1" params = [] if domain: @@ -250,6 +266,10 @@ def search( wildcard = f"%{query}%" params.extend([wildcard, wildcard, wildcard]) + if comment: + sql += " AND comment LIKE ?" + params.append(f"%{comment}%") + sql += " ORDER BY timestamp DESC LIMIT ?" params.append(limit) @@ -258,6 +278,19 @@ def search( cursor = conn.execute(sql, params) return [dict(row) for row in cursor.fetchall()] + def get_comment_histogram(self) -> List[Dict[str, Any]]: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT comment, COUNT(*) as count + FROM flows + WHERE comment IS NOT NULL AND comment != '' + GROUP BY comment + ORDER BY count DESC + """ + ) + return [{"comment": row[0], "count": row[1]} for row in cursor.fetchall()] + def clear(self): with self._get_conn() as conn: conn.execute("DELETE FROM flows") @@ -339,8 +372,8 @@ def get_by_ids( if columns: allowed_cols = { - "id", "url", "method", "status_code", "request_headers", - "request_body", "response_headers", "response_body", "timestamp", "size" + "id", "url", "method", "status_code", "request_headers", + "request_body", "response_headers", "response_body", "timestamp", "size", "comment" } invalid_cols = [c for c in columns if c not in allowed_cols] if invalid_cols: @@ -540,8 +573,18 @@ def get_live_flow(self, flow_id: str) -> Optional[http.HTTPFlow]: return flow return None - def search(self, query: str, domain: str, method: str, limit: int): - return self.db.search(query, domain, method, limit) + def search( + self, + query: str = None, + domain: str = None, + method: str = None, + limit: int = 50, + comment: str = None, + ): + return self.db.search(query, domain, method, limit, comment=comment) + + def get_comment_histogram(self) -> List[Dict[str, Any]]: + return self.db.get_comment_histogram() def clear(self): self.db.clear() diff --git a/src/mitmproxy_mcp/core/server.py b/src/mitmproxy_mcp/core/server.py index 0a3d2df..9c93315 100644 --- a/src/mitmproxy_mcp/core/server.py +++ b/src/mitmproxy_mcp/core/server.py @@ -1,3 +1,4 @@ +import ast import asyncio import logging import os @@ -9,6 +10,7 @@ from urllib.parse import urlparse, parse_qs, urlencode, parse_qsl import re import re2 +from anthropic import AsyncAnthropic import structlog @@ -59,6 +61,7 @@ def __init__(self, dump_file: Optional[str] = None): self.session_variables = {} self.dump_file = dump_file self.cli_upstream_proxy: Optional[str] = None + self.dynamic_addons: Dict[str, Any] = {} def _get_verify_param(self, verify_override: Optional[bool] = None) -> Any: if verify_override is not None: @@ -256,6 +259,176 @@ async def stop_proxy() -> str: return await controller.stop() +@mcp.tool() +async def get_upstream_command( + listen_port: int = 8085, + upstream_host: str = "localhost", + ssl_insecure: bool = True, +) -> str: + """ + Return a mitmproxy CLI command that chains upstream to this MCP proxy instance. + Args: + listen_port: Port for the new mitmproxy instance to listen on (default 8085) + upstream_host: Host where the MCP proxy is running (default localhost) + ssl_insecure: Whether to add --ssl-insecure flag (default True) + """ + upstream_url = f"http://{upstream_host}:{controller.port}" + cmd = f"mitmproxy --mode upstream:{upstream_url} --listen-port {listen_port}" + if ssl_insecure: + cmd += " --ssl-insecure" + return cmd + + +@mcp.tool() +async def run_dynamic_addon(description: str, addon_name: str = "dynamic_addon") -> str: + """ + Generate and load a live mitmproxy addon from a natural language description. + + Claude will write a Python addon class called DynamicAddon with any combination + of hook methods (request, response, tls_start_client, etc.), then hot-load it + into the running proxy so it takes effect immediately. + + Args: + description: Natural language description of the desired proxy behaviour + addon_name: Logical name used to track / replace this addon (default: dynamic_addon) + """ + if not controller.running or controller.master is None: + return json.dumps({"status": "error", "message": "Proxy is not running. Start it first."}) + + system_prompt = f"""You are an expert mitmproxy addon developer. Write a Python addon class +called DynamicAddon that implements the described behaviour using mitmproxy hooks. + +Rules: +- The class MUST be named exactly DynamicAddon. +- Use only stdlib + mitmproxy imports. Do NOT import anthropic, mcp, or anything unavailable. +- Available mitmproxy hooks (use only what you need): + request(self, flow: http.HTTPFlow) — fires before the request is forwarded + response(self, flow: http.HTTPFlow) — fires after the response arrives + tls_start_client(self, tls_handshake) + tls_start_server(self, tls_handshake) + connect(self, flow) + error(self, flow) +- Modify headers via flow.request.headers[key] = value or flow.response.headers[key] = value +- Modify body via flow.request.text = "..." or flow.response.text = "..." +- Kill a flow with flow.kill() +- Redirect via flow.request.url = "https://other.host/path" +- Log with: import logging; logger = logging.getLogger("mcp_mitm"); logger.info(...) +- Do NOT use print(). +- Return ONLY raw Python source code — no markdown fences, no commentary outside the class. + +IMPORTANT — flow tagging (MANDATORY): +In EVERY hook that receives a flow argument (request, response, error, etc.) the FIRST line +of the hook body must be: + flow.comment = "addon:{addon_name}" +This tags the flow in the traffic database so the user can call get_addon_flows("{addon_name}") +to see exactly which flows this addon generated or modified. + +Example skeleton: +from mitmproxy import http +import logging + +logger = logging.getLogger("mcp_mitm") + +class DynamicAddon: + def request(self, flow: http.HTTPFlow): + flow.comment = "addon:{addon_name}" + pass # implement here + + def response(self, flow: http.HTTPFlow): + flow.comment = "addon:{addon_name}" + pass # implement here +""" + + client = AsyncAnthropic() + response = await client.messages.create( + model="claude-opus-4-8", + max_tokens=4096, + thinking={"type": "adaptive"}, + system=system_prompt, + messages=[{"role": "user", "content": description}], + ) + + code = "" + for block in response.content: + if block.type == "text": + code = block.text.strip() + break + + # Strip markdown fences if the model included them anyway + if code.startswith("```"): + lines = code.splitlines() + code = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + + # Syntax-check before exec + try: + ast.parse(code) + except SyntaxError as e: + return json.dumps({"status": "error", "message": f"Generated code has syntax error: {e}", "code": code}) + + namespace: Dict[str, Any] = {} + try: + exec(compile(code, "", "exec"), namespace) + except Exception as e: + return json.dumps({"status": "error", "message": f"Error executing generated code: {e}", "code": code}) + + addon_class = namespace.get("DynamicAddon") + if addon_class is None: + return json.dumps({"status": "error", "message": "Generated code does not define a class named DynamicAddon.", "code": code}) + + try: + instance = addon_class() + except Exception as e: + return json.dumps({"status": "error", "message": f"Failed to instantiate DynamicAddon: {e}", "code": code}) + + # Remove existing addon with this name if present + existing = controller.dynamic_addons.get(addon_name) + if existing is not None: + try: + controller.master.addons.remove(existing) + except Exception: + pass + + controller.master.addons.add(instance) + controller.dynamic_addons[addon_name] = instance + + return json.dumps({ + "status": "success", + "addon_name": addon_name, + "message": f"Addon '{addon_name}' is now live in the proxy.", + "code": code, + }, indent=2) + + +@mcp.tool() +async def list_dynamic_addons() -> str: + """List all currently loaded dynamic addons.""" + if not controller.dynamic_addons: + return json.dumps({"addons": [], "message": "No dynamic addons loaded."}) + return json.dumps({"addons": list(controller.dynamic_addons.keys())}, indent=2) + + +@mcp.tool() +async def remove_dynamic_addon(addon_name: str) -> str: + """ + Remove a previously loaded dynamic addon by name. + + Args: + addon_name: The name used when the addon was created via run_dynamic_addon + """ + instance = controller.dynamic_addons.get(addon_name) + if instance is None: + return json.dumps({"status": "error", "message": f"No addon named '{addon_name}' found."}) + + if controller.master is not None: + try: + controller.master.addons.remove(instance) + except Exception as e: + return json.dumps({"status": "error", "message": f"Failed to remove addon from proxy: {e}"}) + + del controller.dynamic_addons[addon_name] + return json.dumps({"status": "success", "message": f"Addon '{addon_name}' removed."}) + + @mcp.tool() async def set_scope(allowed_domains: List[str]) -> str: controller.scope_manager.update_domains(allowed_domains) @@ -442,15 +615,16 @@ async def load_traffic_file( [d.strip() for d in scope.split(",") if d.strip()] if scope else None ) - # Security: Prevent path traversal and restrict to working directory + # Security: Prevent path traversal via relative '..' components. + # Absolute paths are allowed so users can load exported flows from any location. try: - requested_path = Path(file_path).resolve() - base_dir = Path.cwd().resolve() - if not str(requested_path).startswith(str(base_dir)): + input_path = Path(file_path) + if not input_path.is_absolute() and ".." in input_path.parts: return json.dumps({ "status": "error", - "message": f"Security Error: Access denied to {file_path}. Path must be within the project directory." + "message": f"Security Error: Path traversal not allowed in '{file_path}'. Use an absolute path.", }) + requested_path = input_path.resolve() except Exception as e: return json.dumps({"status": "error", "message": f"Invalid path: {str(e)}"}) @@ -525,6 +699,7 @@ async def search_traffic( domain: str = None, method: str = None, limit: int = 50, + tag: str = None, ) -> str: """ Search captured traffic using filters. @@ -533,11 +708,45 @@ async def search_traffic( domain: Filter by domain name method: Filter by HTTP method (GET, POST, etc.) limit: Max results to return + tag: Filter by flow comment/tag (e.g. 'addon:my_fuzzer' to see addon-generated flows) """ - results = controller.recorder.search(query, domain, method, limit) + results = controller.recorder.search(query, domain, method, limit, comment=tag) return json.dumps(results, indent=2) +@mcp.tool() +async def get_addon_flows(addon_name: str, limit: int = 200) -> str: + """ + Return all flows tagged by a specific dynamic addon. + + Every flow touched or generated by a dynamic addon is tagged with + 'addon:' in its comment field. This tool filters by that tag. + + Args: + addon_name: The name used when the addon was created via run_dynamic_addon + limit: Max flows to return (default 200) + """ + results = controller.recorder.search(comment=f"addon:{addon_name}", limit=limit) + if not results: + return json.dumps({"addon_name": addon_name, "flows": [], "message": "No flows found for this addon yet."}) + return json.dumps({"addon_name": addon_name, "count": len(results), "flows": results}, indent=2) + + +@mcp.tool() +async def get_flow_tag_histogram() -> str: + """ + Return a histogram of flow comment tags, sorted by count descending. + + Shows how many flows each dynamic addon (or any other comment) has tagged. + Example output: + [{"comment": "addon:sql_fuzzer", "count": 42}, {"comment": "addon:probe_v2", "count": 7}] + """ + rows = controller.recorder.get_comment_histogram() + if not rows: + return json.dumps({"message": "No tagged flows found.", "histogram": []}) + return json.dumps({"histogram": rows}, indent=2) + + @mcp.tool() async def set_session_variable(name: str, value: str) -> str: """Manually set a session variable to use in replayed flows.""" diff --git a/uv.lock b/uv.lock index e626a3c..bfa4a91 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12, <3.14" [[package]] @@ -33,6 +33,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.109.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/b7/9a8e2f79011e89dd6eeb599c27332aed765dac9d6fbee3a55e68e4e3ec25/anthropic-0.109.2.tar.gz", hash = "sha256:d37db299597c7bc124b49b767ff135f1e6456b64af2b2fad4b63b2a1df333cf0", size = 927559, upload-time = "2026-06-15T17:30:25.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/f2/bee5de8a2699fc8a3cce34d61c7a2626a2c310ddde7ea5611327eb0ddbe9/anthropic-0.109.2-py3-none-any.whl", hash = "sha256:e0fb4ca5df0ed983248c9c6c3242adc81d9cfddb8725902da53698554117abac", size = 923800, upload-time = "2026-06-15T17:30:23.124Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -327,6 +346,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "flask" version = "3.1.2" @@ -516,6 +553,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + [[package]] name = "jsonpath-ng" version = "1.8.0" @@ -696,9 +778,10 @@ wheels = [ [[package]] name = "mitmproxy-mcp" -version = "0.5.1" +version = "0.6.1" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "beautifulsoup4" }, { name = "curl-cffi" }, { name = "google-re2" }, @@ -721,8 +804,15 @@ dev = [ { name = "pytest-asyncio" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.40.0" }, { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "google-re2", specifier = ">=1.1" }, @@ -742,6 +832,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + [[package]] name = "mitmproxy-rs" version = "0.12.9" @@ -1215,6 +1311,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" From 6e923a57d8efd52bff45085bb75b330730a18955 Mon Sep 17 00:00:00 2001 From: jdearmas Date: Mon, 15 Jun 2026 22:40:14 -0700 Subject: [PATCH 2/2] add direct addon behavior --- src/mitmproxy_mcp/core/server.py | 76 ++++++++++++++++++--------- tests/test_security_path_traversal.py | 58 ++++++++++---------- 2 files changed, 80 insertions(+), 54 deletions(-) diff --git a/src/mitmproxy_mcp/core/server.py b/src/mitmproxy_mcp/core/server.py index 9c93315..7035691 100644 --- a/src/mitmproxy_mcp/core/server.py +++ b/src/mitmproxy_mcp/core/server.py @@ -280,22 +280,50 @@ async def get_upstream_command( @mcp.tool() -async def run_dynamic_addon(description: str, addon_name: str = "dynamic_addon") -> str: +async def run_dynamic_addon( + description: str = "", + addon_name: str = "dynamic_addon", + code: Optional[str] = None, +) -> str: """ - Generate and load a live mitmproxy addon from a natural language description. + Load a live mitmproxy addon into the running proxy. + + Two modes: + - AI-generated (default): provide a natural-language `description` and Claude + (claude-opus-4-8) will write the addon class and hot-load it immediately. + - Direct: provide `code` containing a Python class named DynamicAddon and it + will be hot-loaded as-is, skipping LLM generation entirely. Use this when + you already have the addon code ready. - Claude will write a Python addon class called DynamicAddon with any combination - of hook methods (request, response, tls_start_client, etc.), then hot-load it - into the running proxy so it takes effect immediately. + The addon class MUST be named DynamicAddon and may implement any mitmproxy + hook methods (request, response, tls_start_client, etc.). + + Flow tagging: every hook that accepts a flow should set + flow.comment = "addon:" + so get_addon_flows() can find which flows this addon touched. Args: description: Natural language description of the desired proxy behaviour - addon_name: Logical name used to track / replace this addon (default: dynamic_addon) + (ignored when `code` is supplied). + addon_name: Logical name used to track / replace this addon (default: dynamic_addon). + code: Raw Python source for the DynamicAddon class. When provided, the + LLM generation step is skipped and this code is loaded directly. """ if not controller.running or controller.master is None: return json.dumps({"status": "error", "message": "Proxy is not running. Start it first."}) - system_prompt = f"""You are an expert mitmproxy addon developer. Write a Python addon class + if code is not None: + # Direct mode: strip markdown fences if the caller included them + code = code.strip() + if code.startswith("```"): + lines = code.splitlines() + code = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + else: + # AI-generated mode + if not description: + return json.dumps({"status": "error", "message": "Provide either a description or code."}) + + system_prompt = f"""You are an expert mitmproxy addon developer. Write a Python addon class called DynamicAddon that implements the described behaviour using mitmproxy hooks. Rules: @@ -339,25 +367,25 @@ def response(self, flow: http.HTTPFlow): pass # implement here """ - client = AsyncAnthropic() - response = await client.messages.create( - model="claude-opus-4-8", - max_tokens=4096, - thinking={"type": "adaptive"}, - system=system_prompt, - messages=[{"role": "user", "content": description}], - ) + client = AsyncAnthropic() + response = await client.messages.create( + model="claude-opus-4-8", + max_tokens=4096, + thinking={"type": "adaptive"}, + system=system_prompt, + messages=[{"role": "user", "content": description}], + ) - code = "" - for block in response.content: - if block.type == "text": - code = block.text.strip() - break + code = "" + for block in response.content: + if block.type == "text": + code = block.text.strip() + break - # Strip markdown fences if the model included them anyway - if code.startswith("```"): - lines = code.splitlines() - code = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + # Strip markdown fences if the model included them anyway + if code.startswith("```"): + lines = code.splitlines() + code = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() # Syntax-check before exec try: diff --git a/tests/test_security_path_traversal.py b/tests/test_security_path_traversal.py index 1ee4b27..9d5ec3e 100644 --- a/tests/test_security_path_traversal.py +++ b/tests/test_security_path_traversal.py @@ -6,42 +6,40 @@ from mitmproxy_mcp.core.server import load_traffic_file @pytest.mark.asyncio -async def test_path_traversal_denied(): - """Verify that accessing files outside the project root is blocked.""" - # Create a dummy file in /tmp - target_path = Path("/tmp/mitm_traversal_test.har") - with open(target_path, "w") as f: - f.write('{"log": {"entries": []}}') - - try: - # Attempt to access it via relative traversal - # We know we are in /home/snap/Development/mitmproxy-mcp/tests or similar - result_str = await load_traffic_file("../../../../../tmp/mitm_traversal_test.har") - result = json.loads(result_str) - - assert result["status"] == "error" - assert "Security Error" in result["message"] - assert "Access denied" in result["message"] - - finally: - if target_path.exists(): - os.remove(target_path) +async def test_relative_path_traversal_denied(): + """Relative '..' traversal is still blocked.""" + result_str = await load_traffic_file("../../../../../tmp/mitm_traversal_test.har") + result = json.loads(result_str) + + assert result["status"] == "error" + assert "Security Error" in result["message"] + assert "Path traversal" in result["message"] + + +@pytest.mark.asyncio +async def test_absolute_path_allowed(tmp_path): + """Absolute paths outside CWD are accepted (common when loading exported flows).""" + target = tmp_path / "capture.har" + target.write_text('{"log": {"entries": []}}') + + result_str = await load_traffic_file(str(target)) + result = json.loads(result_str) + + # Should import successfully (0 entries is fine — empty HAR) + assert result["status"] == "ok" + @pytest.mark.asyncio -async def test_valid_path_allowed(tmp_path): - """Verify that accessing files within the project root still works.""" - # Create a file inside the project (using tmp_path which pytest handles) - # However, our fix restricts to CWD, so let's create it in the current dir +async def test_valid_relative_path_allowed(): + """A plain relative path (no '..') within the project still works.""" local_file = Path("test_safe_import.har") - with open(local_file, "w") as f: - f.write('{"log": {"entries": []}}') - + local_file.write_text('{"log": {"entries": []}}') + try: result_str = await load_traffic_file("test_safe_import.har") result = json.loads(result_str) - - # Should NOT be a security error + assert result["status"] == "ok" finally: if local_file.exists(): - os.remove(local_file) + local_file.unlink()