From 4e411924d505594d082365443604aa9578da26b0 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:28:37 +0200 Subject: [PATCH 01/13] feat: v1 core - capture, storage, analysis, web API and MCP server Rebuild MCPHawk around one shared core for the 2026-07-28 spec: - store: sessions/exchanges/messages schema (WAL, multi-process), recorder with request/response pairing, multi round-trip chains, cancellation and identity from both the initialize handshake and modern _meta - capture: stdio shim (wrap), recording reverse proxy (incl. legacy HTTP+SSE endpoint rewriting), rewritten passive sniffer with TCP ordering and an incremental HTTP/1.x + SSE parser - install/uninstall for Claude Desktop, Claude Code, Cursor and VS Code with backups and structural undo; likely-OAuth remote servers are skipped - analysis: context cost, problems (errors, hung, slow, loops), spec lint per protocol era, tool drift between sessions - web API + live updates by tailing the DB; MCPHawk MCP server with six read-only tools returning capped summaries and deep links - secrets masked before they are written - drop the old logger/sniffer/wrapper/web modules and their tests --- mcphawk/__init__.py | 3 + mcphawk/__main__.py | 3 + mcphawk/analysis/__init__.py | 1 + mcphawk/analysis/cost.py | 139 ++++ mcphawk/analysis/drift.py | 103 +++ mcphawk/analysis/lint.py | 180 ++++++ mcphawk/analysis/problems.py | 113 ++++ mcphawk/capture/__init__.py | 1 + mcphawk/capture/http1.py | 145 +++++ mcphawk/capture/http_proxy.py | 186 ++++++ mcphawk/capture/http_sessions.py | 120 ++++ mcphawk/capture/process.py | 60 ++ mcphawk/capture/sniff.py | 279 +++++++++ mcphawk/capture/sse.py | 81 +++ mcphawk/capture/stdio.py | 176 ++++++ mcphawk/cli.py | 512 +++++++-------- mcphawk/install/__init__.py | 1 + mcphawk/install/clients.py | 102 +++ mcphawk/install/installer.py | 270 ++++++++ mcphawk/links.py | 27 + mcphawk/logger.py | 445 ------------- mcphawk/mcp_server.py | 159 +++++ mcphawk/mcp_server/__init__.py | 1 - mcphawk/mcp_server/server.py | 121 ---- mcphawk/models.py | 14 - mcphawk/paths.py | 25 + mcphawk/protocol/__init__.py | 1 + mcphawk/protocol/jsonrpc.py | 49 ++ mcphawk/protocol/masking.py | 94 +++ mcphawk/protocol/mcp.py | 207 ++++++ mcphawk/protocol/tokens.py | 50 ++ mcphawk/query.py | 426 +++++++++++++ mcphawk/sniffer.py | 335 ---------- mcphawk/stdio_server_detector_fallback.py | 140 ----- mcphawk/store/__init__.py | 6 + mcphawk/store/db.py | 99 +++ mcphawk/store/recorder.py | 314 ++++++++++ mcphawk/tcp_reassembly.py | 421 ------------- mcphawk/transport_detector.py | 197 ------ mcphawk/utils.py | 103 --- mcphawk/web/app.py | 207 ++++++ mcphawk/web/broadcaster.py | 38 -- mcphawk/web/server.py | 165 ----- mcphawk/wrapper.py | 306 --------- pyproject.toml | 2 + requirements-dev.txt | 5 +- requirements.txt | 12 +- tests/conftest.py | 32 + tests/fixtures/echo_server.py | 19 + tests/fixtures/sdk_server.py | 23 + tests/integration/capture/__init__.py | 0 tests/integration/capture/test_http_proxy.py | 200 ++++++ tests/integration/capture/test_stdio_shim.py | 186 ++++++ tests/integration/cli/test_cli.py | 592 ++++-------------- tests/integration/cli/test_wrapper.py | 269 -------- tests/integration/db/test_analysis.py | 201 ++++++ tests/integration/db/test_logger.py | 445 ------------- tests/integration/db/test_query.py | 146 +++++ tests/integration/db/test_recorder.py | 157 +++++ tests/integration/db/test_traffic_type.py | 99 --- tests/integration/e2e/__init__.py | 0 tests/integration/e2e/test_real_sdk.py | 107 ++++ tests/integration/install/__init__.py | 0 tests/integration/install/test_installer.py | 201 ++++++ tests/integration/mcp/test_mcp_http_simple.py | 133 ---- tests/integration/mcp/test_mcp_server.py | 353 +++-------- .../mcp/test_mcp_stdio_integration.py | 286 --------- .../network/test_ipv4_ipv6_capture.py | 129 ---- tests/integration/network/test_sniff.py | 194 ++++++ tests/integration/network/test_sniffer.py | 347 ---------- .../network/test_sniffer_traffic_type.py | 70 --- tests/integration/web/test_app.py | 123 ++++ tests/integration/web/test_broadcaster.py | 74 --- tests/integration/web/test_web.py | 268 -------- tests/integration/web/test_web_server.py | 203 ------ tests/traffic.py | 123 ++++ tests/unit/test_jsonrpc.py | 30 + tests/unit/test_masking_and_tokens.py | 61 ++ tests/unit/test_mcp_protocol.py | 84 +++ tests/unit/test_paths.py | 18 + tests/unit/test_sse_and_http1.py | 100 +++ .../test_stdio_server_detector_fallback.py | 175 ------ tests/unit/test_tcp_reassembly.py | 226 ------- tests/unit/test_transport_detector.py | 179 ------ tests/unit/test_utils.py | 340 ---------- 85 files changed, 6140 insertions(+), 6497 deletions(-) create mode 100644 mcphawk/__main__.py create mode 100644 mcphawk/analysis/__init__.py create mode 100644 mcphawk/analysis/cost.py create mode 100644 mcphawk/analysis/drift.py create mode 100644 mcphawk/analysis/lint.py create mode 100644 mcphawk/analysis/problems.py create mode 100644 mcphawk/capture/__init__.py create mode 100644 mcphawk/capture/http1.py create mode 100644 mcphawk/capture/http_proxy.py create mode 100644 mcphawk/capture/http_sessions.py create mode 100644 mcphawk/capture/process.py create mode 100644 mcphawk/capture/sniff.py create mode 100644 mcphawk/capture/sse.py create mode 100644 mcphawk/capture/stdio.py create mode 100644 mcphawk/install/__init__.py create mode 100644 mcphawk/install/clients.py create mode 100644 mcphawk/install/installer.py create mode 100644 mcphawk/links.py delete mode 100644 mcphawk/logger.py create mode 100644 mcphawk/mcp_server.py delete mode 100644 mcphawk/mcp_server/__init__.py delete mode 100644 mcphawk/mcp_server/server.py delete mode 100644 mcphawk/models.py create mode 100644 mcphawk/paths.py create mode 100644 mcphawk/protocol/__init__.py create mode 100644 mcphawk/protocol/jsonrpc.py create mode 100644 mcphawk/protocol/masking.py create mode 100644 mcphawk/protocol/mcp.py create mode 100644 mcphawk/protocol/tokens.py create mode 100644 mcphawk/query.py delete mode 100644 mcphawk/sniffer.py delete mode 100644 mcphawk/stdio_server_detector_fallback.py create mode 100644 mcphawk/store/__init__.py create mode 100644 mcphawk/store/db.py create mode 100644 mcphawk/store/recorder.py delete mode 100644 mcphawk/tcp_reassembly.py delete mode 100644 mcphawk/transport_detector.py delete mode 100644 mcphawk/utils.py create mode 100644 mcphawk/web/app.py delete mode 100644 mcphawk/web/broadcaster.py delete mode 100644 mcphawk/web/server.py delete mode 100644 mcphawk/wrapper.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/echo_server.py create mode 100644 tests/fixtures/sdk_server.py create mode 100644 tests/integration/capture/__init__.py create mode 100644 tests/integration/capture/test_http_proxy.py create mode 100644 tests/integration/capture/test_stdio_shim.py delete mode 100644 tests/integration/cli/test_wrapper.py create mode 100644 tests/integration/db/test_analysis.py delete mode 100644 tests/integration/db/test_logger.py create mode 100644 tests/integration/db/test_query.py create mode 100644 tests/integration/db/test_recorder.py delete mode 100644 tests/integration/db/test_traffic_type.py create mode 100644 tests/integration/e2e/__init__.py create mode 100644 tests/integration/e2e/test_real_sdk.py create mode 100644 tests/integration/install/__init__.py create mode 100644 tests/integration/install/test_installer.py delete mode 100644 tests/integration/mcp/test_mcp_http_simple.py delete mode 100644 tests/integration/mcp/test_mcp_stdio_integration.py delete mode 100644 tests/integration/network/test_ipv4_ipv6_capture.py create mode 100644 tests/integration/network/test_sniff.py delete mode 100644 tests/integration/network/test_sniffer.py delete mode 100644 tests/integration/network/test_sniffer_traffic_type.py create mode 100644 tests/integration/web/test_app.py delete mode 100644 tests/integration/web/test_broadcaster.py delete mode 100644 tests/integration/web/test_web.py delete mode 100644 tests/integration/web/test_web_server.py create mode 100644 tests/traffic.py create mode 100644 tests/unit/test_jsonrpc.py create mode 100644 tests/unit/test_masking_and_tokens.py create mode 100644 tests/unit/test_mcp_protocol.py create mode 100644 tests/unit/test_paths.py create mode 100644 tests/unit/test_sse_and_http1.py delete mode 100644 tests/unit/test_stdio_server_detector_fallback.py delete mode 100644 tests/unit/test_tcp_reassembly.py delete mode 100644 tests/unit/test_transport_detector.py delete mode 100644 tests/unit/test_utils.py diff --git a/mcphawk/__init__.py b/mcphawk/__init__.py index e69de29..49ea0dc 100644 --- a/mcphawk/__init__.py +++ b/mcphawk/__init__.py @@ -0,0 +1,3 @@ +"""MCPHawk: DevTools for the Model Context Protocol.""" + +__version__ = "1.0.0" diff --git a/mcphawk/__main__.py b/mcphawk/__main__.py new file mode 100644 index 0000000..a505866 --- /dev/null +++ b/mcphawk/__main__.py @@ -0,0 +1,3 @@ +from mcphawk.cli import app + +app() diff --git a/mcphawk/analysis/__init__.py b/mcphawk/analysis/__init__.py new file mode 100644 index 0000000..9d0f8de --- /dev/null +++ b/mcphawk/analysis/__init__.py @@ -0,0 +1 @@ +"""Analyses over captured traffic, shared by the web UI and the MCP server.""" diff --git a/mcphawk/analysis/cost.py b/mcphawk/analysis/cost.py new file mode 100644 index 0000000..7bf27e0 --- /dev/null +++ b/mcphawk/analysis/cost.py @@ -0,0 +1,139 @@ +"""Context cost: what MCP servers put into a model's context window. + +Two kinds of cost matter to agent builders: + +* **fixed** - tool definitions (and server instructions) that clients inject + into the prompt on every turn, whether or not a tool is used; +* **variable** - tool results that land in the conversation when called. +""" + +from collections import defaultdict +from typing import Any + +from mcphawk.protocol import mcp as proto +from mcphawk.protocol.tokens import estimate_json, estimate_text +from mcphawk.query import Query, resolve_scope + +LARGE_RESULT_TOKENS = 10_000 +LARGE_DEFINITION_TOKENS = 800 + + +def tool_definition_tokens(tool: dict[str, Any]) -> dict[str, int]: + description = estimate_text(str(tool.get("description") or "")) + schema = estimate_json(tool.get("inputSchema")) + output_schema = estimate_json(tool.get("outputSchema")) + name = estimate_text(str(tool.get("name") or "")) + return { + "description": description, + "schema": schema + output_schema, + "total": name + description + schema + output_schema, + } + + +def _latest_tools(q: Query, session_ids: list[str]) -> list[dict[str, Any]]: + """Tool set from the newest session that listed tools (pages merged).""" + responses = q.responses_for("tools/list", session_ids) + if not responses: + return [] + newest = responses[-1]["session_id"] + tools: dict[str, dict[str, Any]] = {} + for response in responses: + if response["session_id"] != newest: + continue + for tool in proto.tools_from_list(response["body"]): + tools[str(tool.get("name"))] = tool + return list(tools.values()) + + +def _instructions_tokens(q: Query, session_ids: list[str]) -> int: + for method in ("server/discover", "initialize"): + responses = q.responses_for(method, session_ids) + if responses: + return estimate_text(proto.instructions_of(responses[-1]["body"]) or "") + return 0 + + +def context_cost( + q: Query, + *, + session_id: str | None = None, + run_key: str | None = None, + server: str | None = None, + top: int = 10, +) -> dict[str, Any]: + sessions = resolve_scope(q, session_id=session_id, run_key=run_key, server=server) + by_server: dict[str, list[str]] = defaultdict(list) + for session in sessions: + by_server[session["display_name"]].append(session["id"]) + + servers = [] + heaviest: list[dict[str, Any]] = [] + for label, ids in by_server.items(): + tools = _latest_tools(q, ids) + definitions = sorted( + ({"name": t.get("name"), **tool_definition_tokens(t)} for t in tools), + key=lambda d: d["total"], reverse=True) + calls: dict[str, dict[str, Any]] = {} + for session_id_ in ids: + for exchange in q.exchanges(session_id=session_id_, method="tools/call"): + stats = calls.setdefault(exchange["target"] or "?", { + "tool": exchange["target"] or "?", "calls": 0, "errors": 0, + "result_tokens": 0, "max_result_tokens": 0}) + stats["calls"] += 1 + stats["errors"] += exchange["status"] in ("error", "tool_error") + stats["result_tokens"] += exchange["response_tokens"] + stats["max_result_tokens"] = max( + stats["max_result_tokens"], exchange["response_tokens"]) + heaviest.append({ + "exchange_id": exchange["id"], "session_id": exchange["session_id"], + "server": label, "tool": exchange["target"], + "tokens": exchange["response_tokens"]}) + for stats in calls.values(): + stats["avg_result_tokens"] = round(stats["result_tokens"] / stats["calls"]) + + called = set(calls) + instructions = _instructions_tokens(q, ids) + fixed = sum(d["total"] for d in definitions) + instructions + servers.append({ + "server": label, + "session_ids": ids, + "fixed_tokens_per_turn": fixed, + "instructions_tokens": instructions, + "tool_count": len(definitions), + "definitions": definitions, + "unused_tools": [d["name"] for d in definitions if d["name"] not in called], + "calls": sorted(calls.values(), key=lambda c: c["result_tokens"], reverse=True), + "result_tokens": sum(c["result_tokens"] for c in calls.values()), + }) + + servers.sort(key=lambda s: s["fixed_tokens_per_turn"], reverse=True) + heaviest.sort(key=lambda h: h["tokens"], reverse=True) + return { + "estimate": "≈4 chars/token; images counted as a flat 1500", + "fixed_tokens_per_turn": sum(s["fixed_tokens_per_turn"] for s in servers), + "result_tokens": sum(s["result_tokens"] for s in servers), + "servers": servers, + "heaviest_results": heaviest[:top], + "findings": _findings(servers, heaviest), + } + + +def _findings(servers: list[dict[str, Any]], heaviest: list[dict[str, Any]]) -> list[str]: + findings = [] + for server in servers: + big = [d for d in server["definitions"] if d["total"] >= LARGE_DEFINITION_TOKENS] + for definition in big: + findings.append( + f"{server['server']}: tool '{definition['name']}' definition costs " + f"≈{definition['total']} tokens every turn") + unused = server["unused_tools"] + if server["calls"] and unused: + wasted = sum(d["total"] for d in server["definitions"] if d["name"] in unused) + findings.append( + f"{server['server']}: {len(unused)} of {server['tool_count']} tools were " + f"never called here (≈{wasted} tokens/turn)") + findings.extend( + f"{h['server']}: a '{h['tool']}' result was ≈{h['tokens']} tokens " + f"(exchange {h['exchange_id']})" + for h in heaviest if h["tokens"] >= LARGE_RESULT_TOKENS) + return findings diff --git a/mcphawk/analysis/drift.py b/mcphawk/analysis/drift.py new file mode 100644 index 0000000..3c99b63 --- /dev/null +++ b/mcphawk/analysis/drift.py @@ -0,0 +1,103 @@ +"""What changed between two captures: tool definitions and behaviour.""" + +import json +from typing import Any + +from mcphawk.analysis.cost import tool_definition_tokens +from mcphawk.protocol import mcp as proto +from mcphawk.query import Query + +_TOOL_FIELDS = ("title", "description", "inputSchema", "outputSchema", "annotations") + + +def tools_of_session(q: Query, session_id: str) -> dict[str, dict[str, Any]] | None: + """Tools a session listed (all pages of its last listing), or None.""" + responses = q.responses_for("tools/list", [session_id]) + if not responses: + return None + tools: dict[str, dict[str, Any]] = {} + for response in responses: + for tool in proto.tools_from_list(response["body"]): + tools[str(tool.get("name"))] = tool + return tools + + +def tool_drift(old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]]) -> dict[str, Any]: + changed = [] + for name in sorted(set(old) & set(new)): + fields = [ + f for f in _TOOL_FIELDS + if json.dumps(old[name].get(f), sort_keys=True) + != json.dumps(new[name].get(f), sort_keys=True) + ] + if fields: + changed.append({ + "name": name, "fields": fields, + "token_delta": tool_definition_tokens(new[name])["total"] + - tool_definition_tokens(old[name])["total"], + }) + old_tokens = sum(tool_definition_tokens(t)["total"] for t in old.values()) + new_tokens = sum(tool_definition_tokens(t)["total"] for t in new.values()) + return { + "added": sorted(set(new) - set(old)), + "removed": sorted(set(old) - set(new)), + "changed": changed, + "definition_tokens": {"before": old_tokens, "after": new_tokens, + "delta": new_tokens - old_tokens}, + } + + +def _behaviour(q: Query, session_id: str) -> dict[str, dict[str, Any]]: + stats: dict[str, dict[str, Any]] = {} + for exchange in q.exchanges(session_id=session_id): + key = exchange["method"] + (f" {exchange['target']}" if exchange["target"] else "") + entry = stats.setdefault(key, {"calls": 0, "errors": 0, "total_ms": 0.0, + "result_tokens": 0}) + entry["calls"] += 1 + entry["errors"] += exchange["status"] in ("error", "tool_error") + entry["total_ms"] += exchange["duration_ms"] or 0 + entry["result_tokens"] += exchange["response_tokens"] + for entry in stats.values(): + entry["avg_ms"] = round(entry.pop("total_ms") / entry["calls"], 1) + return stats + + +def compare_sessions(q: Query, before_id: str, after_id: str) -> dict[str, Any]: + before = q.get_session_header(before_id) + after = q.get_session_header(after_id) + if not before or not after: + missing = before_id if not before else after_id + return {"error": f"session {missing} not found"} + + old_tools = tools_of_session(q, before_id) + new_tools = tools_of_session(q, after_id) + tools = (tool_drift(old_tools, new_tools) + if old_tools is not None and new_tools is not None else None) + + old_calls, new_calls = _behaviour(q, before_id), _behaviour(q, after_id) + calls = [] + for key in sorted(set(old_calls) | set(new_calls)): + a, b = old_calls.get(key), new_calls.get(key) + calls.append({"call": key, "before": a, "after": b}) + + def header(session: dict[str, Any]) -> dict[str, Any]: + return {k: session.get(k) for k in ( + "id", "display_name", "server_name", "server_version", "protocol_version", + "started_at")} + + return { + "before": header(before), + "after": header(after), + "server_version_changed": before.get("server_version") != after.get("server_version"), + "tools": tools, + "calls": calls, + } + + +def latest_drift(q: Query, server: str) -> dict[str, Any]: + """Compare the two most recent sessions of a server that listed tools.""" + sessions = [s for s in q.list_sessions(server=server, limit=50) + if tools_of_session(q, s["id"]) is not None] + if len(sessions) < 2: + return {"error": f"need two sessions of {server!r} that listed tools"} + return compare_sessions(q, sessions[1]["id"], sessions[0]["id"]) diff --git a/mcphawk/analysis/lint.py b/mcphawk/analysis/lint.py new file mode 100644 index 0000000..01beb70 --- /dev/null +++ b/mcphawk/analysis/lint.py @@ -0,0 +1,180 @@ +"""Spec conformance checks for captured sessions. + +Rules are keyed to the protocol era a session negotiated: a 2025 server is +not faulted for lacking ``resultType``, but a 2026-07-28 one is. +""" + +from collections import OrderedDict +from typing import Any + +from mcphawk.protocol import jsonrpc +from mcphawk.protocol import mcp as proto +from mcphawk.query import Query + +ERROR = "error" +WARNING = "warning" +INFO = "info" + +SEVERITY_ORDER = {ERROR: 0, WARNING: 1, INFO: 2} + +RULES: dict[str, tuple[str, str]] = { + "non-json-output": (ERROR, "Non-JSON output on the protocol stream"), + "invalid-jsonrpc": (ERROR, "Message is not valid JSON-RPC 2.0"), + "unmatched-response": (WARNING, "Response without a matching request"), + "missing-result-type": (ERROR, "Result is missing required resultType"), + "missing-cache-hints": (ERROR, "Cacheable result is missing ttlMs/cacheScope"), + "removed-method": (ERROR, "Method was removed in 2026-07-28"), + "server-initiated-request": (ERROR, "Server-initiated request; use multi round-trip"), + "missing-server-info": (INFO, "Result does not identify the server in _meta"), + "missing-client-info": (INFO, "Request does not identify the client in _meta"), + "missing-mcp-headers": (ERROR, "HTTP request is missing Mcp-Method/Mcp-Name headers"), + "header-mismatch": (ERROR, "Mcp-Method/Mcp-Name header does not match the body"), + "legacy-not-found-code": (WARNING, "Resource not found should use -32602"), + "deprecated-feature": (INFO, "Uses a deprecated feature"), + "deprecated-transport": (WARNING, "HTTP+SSE transport is deprecated"), + "nondeterministic-tools": (WARNING, "tools/list order changed between calls"), +} + + +class _Findings: + """Collects rule hits, folding repeats into one finding with a count.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self._items: OrderedDict[tuple[str, str], dict[str, Any]] = OrderedDict() + + def add(self, rule: str, detail: str, message_id: int | None = None, + exchange_id: int | None = None) -> None: + key = (rule, detail) + if key in self._items: + self._items[key]["count"] += 1 + return + severity, title = RULES[rule] + self._items[key] = { + "rule": rule, "severity": severity, "title": title, "detail": detail, + "session_id": self.session_id, "message_id": message_id, + "exchange_id": exchange_id, "count": 1, + } + + def items(self) -> list[dict[str, Any]]: + return sorted(self._items.values(), key=lambda f: SEVERITY_ORDER[f["severity"]]) + + +def _header(headers: Any, name: str) -> str | None: + if not isinstance(headers, dict): + return None + for key, value in headers.items(): + if key.lower() == name.lower(): + return value + return None + + +def lint_session(q: Query, session_id: str) -> list[dict[str, Any]]: + session = q.get_session_header(session_id) + if not session: + return [] + findings = _Findings(session_id) + modern = session.get("era") == proto.MODERN + http = session["transport"] in ("streamable_http", "http_sse") + request_methods: dict[int, str] = {} + tool_orders: list[list[str]] = [] + + if session["transport"] == "http_sse": + findings.add("deprecated-transport", "Migrate to Streamable HTTP") + + for message in q.messages_for(session_id): + body = message["body"] + mid, xid = message["id"], message["exchange_id"] + kind, method = message["kind"], message["method"] + + if kind == jsonrpc.INVALID: + if message["note"] == "not JSON": + where = "stdout" if session["transport"] == "stdio" else "the stream" + findings.add("non-json-output", f"Stray output on {where}", mid) + else: + findings.add("invalid-jsonrpc", "Missing jsonrpc/id/method structure", mid) + continue + if message["note"] == "no matching request": + findings.add("unmatched-response", f"id {message['rpc_id']}", mid) + + if method in proto.DEPRECATED_FEATURES: + findings.add("deprecated-feature", + f"{proto.DEPRECATED_FEATURES[method]} ({method})", mid, xid) + + if kind == jsonrpc.REQUEST: + request_methods[xid] = method + if modern: + _lint_modern_request(findings, message, body, http) + elif kind == jsonrpc.NOTIFICATION and modern and method in proto.REMOVED_IN_MODERN: + findings.add("removed-method", method, mid, xid) + elif kind == jsonrpc.RESPONSE: + request_method = request_methods.get(xid) + if request_method == "tools/list": + tool_orders.append([str(t.get("name")) for t in proto.tools_from_list(body)]) + if modern: + _lint_modern_result(findings, message, body, request_method) + elif kind == jsonrpc.ERROR and modern: + code, _ = proto.error_of(body) + if code == -32002 and request_methods.get(xid) == "resources/read": + findings.add("legacy-not-found-code", "resources/read returned -32002", mid, xid) + + _lint_tool_order(findings, tool_orders) + return findings.items() + + +def _lint_modern_request(findings: _Findings, message, body, http: bool) -> None: + mid, xid, method = message["id"], message["exchange_id"], message["method"] + if method in proto.REMOVED_IN_MODERN: + findings.add("removed-method", method, mid, xid) + if message["direction"] == "s2c": + detail = method if method in proto.REPLACED_BY_MRTR else f"{method} sent by server" + findings.add("server-initiated-request", detail, mid, xid) + return + if not proto.client_info(body): + findings.add("missing-client-info", method, mid, xid) + if http: + header_method = _header(message["headers"], "Mcp-Method") + if header_method is None: + findings.add("missing-mcp-headers", f"Mcp-Method on {method}", mid, xid) + elif header_method != method: + findings.add("header-mismatch", f"Mcp-Method {header_method!r} vs {method!r}", + mid, xid) + target = proto.target_of(body) + if target is not None: + header_name = _header(message["headers"], "Mcp-Name") + if header_name is None: + findings.add("missing-mcp-headers", f"Mcp-Name on {method}", mid, xid) + elif header_name != target: + findings.add("header-mismatch", f"Mcp-Name {header_name!r} vs {target!r}", + mid, xid) + + +def _lint_modern_result(findings: _Findings, message, body, request_method) -> None: + mid, xid = message["id"], message["exchange_id"] + result = body.get("result") if isinstance(body.get("result"), dict) else {} + label = request_method or "response" + if "resultType" not in result: + findings.add("missing-result-type", label, mid, xid) + if (request_method in proto.CACHEABLE_METHODS + and result.get("resultType", proto.RESULT_COMPLETE) == proto.RESULT_COMPLETE + and ("ttlMs" not in result or "cacheScope" not in result)): + findings.add("missing-cache-hints", label, mid, xid) + if not proto.server_info(body, request_method): + findings.add("missing-server-info", label, mid, xid) + + +def _lint_tool_order(findings: _Findings, orders: list[list[str]]) -> None: + for previous, current in zip(orders, orders[1:]): + if set(previous) == set(current) and previous != current: + findings.add("nondeterministic-tools", + "Same tools returned in a different order (hurts caching)") + return + + +def lint(q: Query, session_ids: list[str], min_severity: str = INFO) -> list[dict[str, Any]]: + limit = SEVERITY_ORDER[min_severity] + findings = [] + for session_id in session_ids: + findings.extend(f for f in lint_session(q, session_id) + if SEVERITY_ORDER[f["severity"]] <= limit) + return sorted(findings, key=lambda f: SEVERITY_ORDER[f["severity"]]) diff --git a/mcphawk/analysis/problems.py b/mcphawk/analysis/problems.py new file mode 100644 index 0000000..ee70e60 --- /dev/null +++ b/mcphawk/analysis/problems.py @@ -0,0 +1,113 @@ +"""One list of everything that went wrong, most severe first.""" + +import hashlib +import json +from typing import Any + +from mcphawk.analysis import lint as lint_mod +from mcphawk.query import Query, resolve_scope + +SLOW_MS = 10_000 +LOOP_REPEATS = 3 + +_EXCHANGE_PROBLEMS = { + "error": (lint_mod.ERROR, "JSON-RPC error"), + "tool_error": (lint_mod.ERROR, "Tool reported an error"), + "hung": (lint_mod.ERROR, "No response yet"), + "abandoned": (lint_mod.WARNING, "Never answered before the session ended"), + "cancelled": (lint_mod.INFO, "Cancelled"), +} + + +def _exchange_problem(exchange: dict[str, Any], server: str) -> dict[str, Any] | None: + what = exchange["method"] + (f" {exchange['target']}" if exchange["target"] else "") + if exchange["status"] in _EXCHANGE_PROBLEMS: + severity, title = _EXCHANGE_PROBLEMS[exchange["status"]] + detail = exchange["error_message"] or "" + if exchange["error_code"] is not None: + detail = f"[{exchange['error_code']}] {detail}".strip() + kind = exchange["status"] + elif (exchange["duration_ms"] or 0) >= SLOW_MS: + severity, title, kind = lint_mod.WARNING, "Slow call", "slow" + detail = f"{exchange['duration_ms'] / 1000:.1f}s" + else: + return None + return { + "kind": kind, "severity": severity, "title": f"{title}: {what}", "detail": detail, + "server": server, "session_id": exchange["session_id"], + "exchange_id": exchange["id"], "message_id": exchange["request_msg_id"], + "at": exchange["started_at"], "count": 1, + } + + +def _loops(q: Query, exchanges: list[dict[str, Any]], server: str) -> list[dict[str, Any]]: + """Same tool called with identical arguments several times in a row.""" + problems = [] + streak: list[dict[str, Any]] = [] + last_key = None + for exchange in [*exchanges, None]: # sentinel flushes the last streak + key = None + if exchange and exchange["method"] == "tools/call": + request = q.get_message(exchange["request_msg_id"]) or {} + params = (request.get("body") or {}).get("params") or {} + payload = json.dumps(params.get("arguments"), sort_keys=True) + key = (exchange["target"], hashlib.sha1(payload.encode()).hexdigest()) + if key is not None and key == last_key: + streak.append(exchange) + continue + if len(streak) >= LOOP_REPEATS: + first = streak[0] + problems.append({ + "kind": "loop", "severity": lint_mod.WARNING, + "title": f"Repeated identical call: tools/call {first['target']}", + "detail": f"{len(streak)} times in a row with the same arguments", + "server": server, "session_id": first["session_id"], + "exchange_id": first["id"], "message_id": first["request_msg_id"], + "at": first["started_at"], "count": len(streak), + }) + streak = [exchange] if key is not None else [] + last_key = key + return problems + + +def find_problems( + q: Query, + *, + session_id: str | None = None, + run_key: str | None = None, + server: str | None = None, + include_lint: bool = True, + min_severity: str = lint_mod.WARNING, + limit: int = 100, +) -> dict[str, Any]: + sessions = resolve_scope(q, session_id=session_id, run_key=run_key, server=server) + names = {s["id"]: s["display_name"] for s in sessions} + threshold = lint_mod.SEVERITY_ORDER[min_severity] + problems: list[dict[str, Any]] = [] + for session in sessions: + exchanges = q.exchanges(session_id=session["id"], live=session["live"]) + for exchange in exchanges: + problem = _exchange_problem(exchange, session["display_name"]) + if problem: + problems.append(problem) + problems.extend(_loops(q, exchanges, session["display_name"])) + if include_lint: + for finding in lint_mod.lint(q, list(names), min_severity=min_severity): + problems.append({ + "kind": "lint:" + finding["rule"], "severity": finding["severity"], + "title": finding["title"], "detail": finding["detail"], + "server": names[finding["session_id"]], "session_id": finding["session_id"], + "exchange_id": finding["exchange_id"], "message_id": finding["message_id"], + "at": None, "count": finding["count"], + }) + problems = [p for p in problems if lint_mod.SEVERITY_ORDER[p["severity"]] <= threshold] + problems.sort(key=lambda p: (lint_mod.SEVERITY_ORDER[p["severity"]], -(p["at"] or 0))) + summary: dict[str, int] = {} + for problem in problems: + summary[problem["severity"]] = summary.get(problem["severity"], 0) + 1 + return { + "sessions_checked": len(sessions), + "summary": summary, + "total": len(problems), + "problems": problems[:limit], + } diff --git a/mcphawk/capture/__init__.py b/mcphawk/capture/__init__.py new file mode 100644 index 0000000..04f6e0f --- /dev/null +++ b/mcphawk/capture/__init__.py @@ -0,0 +1 @@ +"""Capture modes: stdio wrapper, HTTP proxy and passive sniffer.""" diff --git a/mcphawk/capture/http1.py b/mcphawk/capture/http1.py new file mode 100644 index 0000000..ecdb95e --- /dev/null +++ b/mcphawk/capture/http1.py @@ -0,0 +1,145 @@ +"""Incremental HTTP/1.x parser for one direction of a TCP connection.""" + +from collections.abc import Callable + +HEAD, LENGTH, CHUNK_SIZE, CHUNK_DATA, CHUNK_END, TRAILERS, UNTIL_CLOSE = range(7) + +MAX_HEAD = 64 * 1024 + +REQUEST_METHODS = (b"GET ", b"POST ", b"PUT ", b"DELETE ", b"PATCH ", b"OPTIONS ", b"HEAD ") + + +def looks_like_request(data: bytes) -> bool: + return data.startswith(REQUEST_METHODS) + + +def looks_like_response(data: bytes) -> bool: + return data.startswith(b"HTTP/1.") + + +class Http1Stream: + """Feed bytes in order; receive head/data/end callbacks per message. + + ``is_response`` controls body framing rules: a response without + Content-Length or chunked encoding runs until the connection closes + (typical for SSE), while such a request has no body. + """ + + def __init__( + self, + is_response: bool, + on_head: Callable[[str, dict[str, str]], None], + on_data: Callable[[bytes], None], + on_end: Callable[[], None], + ): + self.is_response = is_response + self._on_head = on_head + self._on_data = on_data + self._on_end = on_end + self._state = HEAD + self._buffer = b"" + self._remaining = 0 + self.broken = False + + def feed(self, data: bytes) -> None: + if self.broken: + return + self._buffer += data + while self._buffer and not self.broken: + if not self._step(): + break + + def close(self) -> None: + if self._state == UNTIL_CLOSE: + self._state = HEAD + self._on_end() + + def _step(self) -> bool: + """Consume what the current state can; False when more data is needed.""" + if self._state == HEAD: + end = self._buffer.find(b"\r\n\r\n") + if end < 0: + if len(self._buffer) > MAX_HEAD: + self.broken = True + return False + head, self._buffer = self._buffer[:end].decode("latin-1"), self._buffer[end + 4:] + self._start_message(head) + return True + if self._state == LENGTH: + take = self._buffer[:self._remaining] + self._buffer = self._buffer[len(take):] + self._remaining -= len(take) + if take: + self._on_data(take) + if self._remaining == 0: + self._finish() + return bool(self._buffer) or self._remaining == 0 + if self._state == UNTIL_CLOSE: + self._on_data(self._buffer) + self._buffer = b"" + return False + if self._state in (CHUNK_SIZE, CHUNK_END, TRAILERS): + end = self._buffer.find(b"\r\n") + if end < 0: + return False + line, self._buffer = self._buffer[:end], self._buffer[end + 2:] + if self._state == CHUNK_END: + self._state = CHUNK_SIZE + elif self._state == TRAILERS: + if not line: + self._finish() + else: + try: + size = int(line.split(b";")[0].strip(), 16) + except ValueError: + self.broken = True + return False + if size == 0: + self._state = TRAILERS + else: + self._remaining = size + self._state = CHUNK_DATA + return True + # CHUNK_DATA + take = self._buffer[:self._remaining] + self._buffer = self._buffer[len(take):] + self._remaining -= len(take) + if take: + self._on_data(take) + if self._remaining == 0: + self._state = CHUNK_END + return True + return False + + def _start_message(self, head: str) -> None: + lines = head.split("\r\n") + headers: dict[str, str] = {} + for line in lines[1:]: + name, sep, value = line.partition(":") + if sep: + headers[name.strip()] = value.strip() + self._on_head(lines[0], headers) + lowered = {k.lower(): v for k, v in headers.items()} + status = 0 + if self.is_response: + parts = lines[0].split(" ") + status = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 + if "chunked" in lowered.get("transfer-encoding", "").lower(): + self._state = CHUNK_SIZE + elif "content-length" in lowered: + try: + self._remaining = int(lowered["content-length"]) + except ValueError: + self.broken = True + return + self._state = LENGTH + if self._remaining == 0: + self._finish() + elif self.is_response and not (100 <= status < 200 or status in (204, 304)): + self._state = UNTIL_CLOSE + else: + self._finish() + + def _finish(self) -> None: + self._state = HEAD + self._on_end() diff --git a/mcphawk/capture/http_proxy.py b/mcphawk/capture/http_proxy.py new file mode 100644 index 0000000..82b02a3 --- /dev/null +++ b/mcphawk/capture/http_proxy.py @@ -0,0 +1,186 @@ +"""Recording reverse proxy for HTTP MCP servers. + +Routes: + +* ``/p/`` -> the upstream URL registered as ```` +* ``/p//~/`` -> ```` on the upstream's origin (used when a + legacy HTTP+SSE server announces an absolute endpoint such as + ``/messages?session_id=...``, which we rewrite to stay on the proxy) + +Unlike passive sniffing this works for HTTPS upstreams and remote servers. +""" + +import json +import logging +from collections.abc import AsyncIterator, Callable +from typing import Any +from urllib.parse import urljoin, urlsplit, urlunsplit + +import httpx +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from mcphawk.capture.http_sessions import SESSION_HEADER, HttpSessions, header +from mcphawk.capture.sse import SSEEvent, SSEParser +from mcphawk.protocol import jsonrpc +from mcphawk.store import C2S, S2C, Recorder + +logger = logging.getLogger(__name__) + +PROXY_PREFIX = "/p" +ROOT_MARKER = "~" + +_HOP_BY_HOP = frozenset({ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", + "trailers", "transfer-encoding", "upgrade", "host", "content-length", + "accept-encoding", "content-encoding", +}) + + +def _forward_headers(headers: Any) -> dict[str, str]: + return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP} + + +def _origin(url: str) -> str: + parts = urlsplit(url) + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + +def _is_event_stream(headers: Any) -> bool: + return "text/event-stream" in (header(dict(headers), "content-type") or "") + + +class Proxy: + def __init__( + self, + upstreams: dict[str, str] | Callable[[], dict[str, str]], + recorder: Recorder, + transport: httpx.AsyncBaseTransport | None = None, + ): + self._upstreams = upstreams + self.recorder = recorder + self.sessions = HttpSessions(recorder, capture="proxy") + self._client = httpx.AsyncClient( + transport=transport, timeout=httpx.Timeout(30.0, read=None), + follow_redirects=False) + + def upstreams(self) -> dict[str, str]: + return self._upstreams() if callable(self._upstreams) else self._upstreams + + def routes(self) -> list[Route]: + methods = ["GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"] + return [ + Route(PROXY_PREFIX + "/{name}", self.handle, methods=methods), + Route(PROXY_PREFIX + "/{name}/{rest:path}", self.handle, methods=methods), + ] + + async def aclose(self) -> None: + await self._client.aclose() + + def _upstream_url(self, name: str, rest: str, query: str) -> str | None: + base = self.upstreams().get(name) + if base is None: + return None + if rest.startswith(ROOT_MARKER + "/") or rest == ROOT_MARKER: + url = _origin(base) + "/" + rest[len(ROOT_MARKER):].lstrip("/") + elif rest: + url = base.rstrip("/") + "/" + rest + else: + url = base + return url + ("?" + query if query else "") + + async def handle(self, request: Request) -> Response: + name = request.path_params["name"] + rest = request.path_params.get("rest", "") + query = request.url.query + url = self._upstream_url(name, rest, query) + if url is None: + return JSONResponse({"error": f"no upstream named {name!r}"}, status_code=404) + upstream = self.upstreams()[name] + + body = await request.body() + req_headers = dict(request.headers) + session_id = None + messages = jsonrpc.parse_frame(body.decode("utf-8", "replace")) if body else None + if request.method == "POST" and body: + session_id = self.sessions.for_request( + upstream, req_headers, messages or [], name=name, query=query) + self.recorder.record(session_id, C2S, body, headers=req_headers) + + forward = _forward_headers(request.headers) + forward["accept-encoding"] = "identity" + upstream_request = self._client.build_request( + request.method, url, headers=forward, content=body) + try: + response = await self._client.send(upstream_request, stream=True) + except httpx.HTTPError as exc: + logger.warning("upstream %s failed: %s", url, exc) + if session_id and messages: + self._record_gateway_error(session_id, messages, str(exc)) + return JSONResponse({"error": f"upstream unreachable: {exc}"}, status_code=502) + + resp_headers = _forward_headers(response.headers) + if session_id: + self.sessions.bind_session_header( + session_id, upstream, header(dict(response.headers), SESSION_HEADER)) + + if _is_event_stream(response.headers): + if session_id is None: + session_id = self.sessions.for_sse_stream(upstream, req_headers, name) + return StreamingResponse( + self._stream(response, upstream, name, session_id), + status_code=response.status_code, headers=resp_headers) + + content = await response.aread() + await response.aclose() + if session_id and content and response.status_code != 202: + self.recorder.record(session_id, S2C, content) + return Response(content, status_code=response.status_code, headers=resp_headers) + + def _record_gateway_error(self, session_id: str, messages: list[dict[str, Any]], + reason: str) -> None: + for msg in messages: + if jsonrpc.classify(msg) == jsonrpc.REQUEST: + self.recorder.record(session_id, S2C, json.dumps({ + "jsonrpc": "2.0", "id": msg["id"], + "error": {"code": -32000, + "message": f"mcphawk proxy: upstream unreachable: {reason}"}})) + + async def _stream(self, response: httpx.Response, upstream: str, name: str, + session_id: str | None) -> AsyncIterator[bytes]: + parser = SSEParser() + # A stream of a known session is passed through byte for byte. An + # unknown GET stream may be legacy HTTP+SSE, whose endpoint event must + # be rewritten, so its events are re-serialized instead. + passthrough = session_id is not None + try: + async for chunk in response.aiter_raw(): + events = parser.feed(chunk) + for event in events: + if event.event == "endpoint" and session_id is None: + endpoint = urljoin(upstream, event.data) + session_id = self.sessions.open_legacy_sse(upstream, endpoint, name) + elif session_id and event.data.strip(): + self.recorder.record(session_id, S2C, event.data) + if passthrough: + yield chunk + else: + for event in events: + yield self._serialize(event, name, upstream) + finally: + await response.aclose() + + @staticmethod + def _serialize(event: SSEEvent, name: str, upstream: str) -> bytes: + data = event.data + if event.event == "endpoint": + endpoint = urlsplit(urljoin(upstream, data)) + if _origin(urlunsplit(endpoint)) == _origin(upstream): + path = endpoint.path + ("?" + endpoint.query if endpoint.query else "") + data = f"{PROXY_PREFIX}/{name}/{ROOT_MARKER}{path}" + lines = [f"event: {event.event}"] + if event.id is not None: + lines.append(f"id: {event.id}") + lines.extend(f"data: {line}" for line in data.split("\n")) + return ("\n".join(lines) + "\n\n").encode() diff --git a/mcphawk/capture/http_sessions.py b/mcphawk/capture/http_sessions.py new file mode 100644 index 0000000..3f1bfd9 --- /dev/null +++ b/mcphawk/capture/http_sessions.py @@ -0,0 +1,120 @@ +"""Grouping HTTP exchanges into sessions. + +Over stdio a session is simply the lifetime of the process. Over HTTP it has +to be inferred, and each protocol generation needs a different key: + +* 2025-era Streamable HTTP: the ``Mcp-Session-Id`` header; +* legacy HTTP+SSE: the query string of the POST endpoint announced on the + SSE stream (e.g. ``/messages?session_id=abc``); +* 2026-07-28 (stateless): nothing protocol-level, so the client identity from + ``_meta``, with a fresh session after a period of inactivity. +""" + +import threading +import time +import uuid +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from mcphawk.protocol import mcp as proto +from mcphawk.store import Recorder + +SESSION_HEADER = "mcp-session-id" +IDLE_ROLLOVER_S = 30 * 60 + + +def _endpoint_key(upstream: str, query: str) -> tuple[str, str]: + """Legacy SSE streams and their POSTs use different paths on one origin.""" + parts = urlsplit(upstream) + return (urlunsplit((parts.scheme, parts.netloc, "", "", "")), "ep:" + query) + + +def header(headers: dict[str, str] | None, name: str) -> str | None: + if not headers: + return None + name = name.lower() + for key, value in headers.items(): + if key.lower() == name: + return value + return None + + +class HttpSessions: + def __init__(self, recorder: Recorder, capture: str, now=time.time): + self.recorder = recorder + self.capture = capture + self._now = now + self._lock = threading.Lock() + self._keys: dict[tuple[str, str], str] = {} + self._last_seen: dict[str, float] = {} + + def _open(self, upstream: str, name: str | None, transport: str, + run_key: str | None = None) -> str: + return self.recorder.open_session( + capture=self.capture, transport=transport, name=name, target=upstream, + run_key=run_key) + + def _touch(self, session_id: str) -> str: + self._last_seen[session_id] = self._now() + return session_id + + def for_request( + self, + upstream: str, + headers: dict[str, str] | None, + messages: list[dict[str, Any]], + *, + name: str | None = None, + query: str = "", + ) -> str: + """Session a client->server POST belongs to (created if needed).""" + with self._lock: + session_header = header(headers, SESSION_HEADER) + if session_header: + key = (upstream, "sid:" + session_header) + if key not in self._keys: + self._keys[key] = self._open(upstream, name, "streamable_http") + return self._touch(self._keys[key]) + + if query: + existing = self._keys.get(_endpoint_key(upstream, query)) + if existing: + return self._touch(existing) + + if any(m.get("method") == "initialize" for m in messages): + # legacy handshake: the session id arrives on the response + return self._touch(self._open(upstream, name, "streamable_http")) + + client = next((c for c in map(proto.client_info, messages) if c), None) + label = client["name"] if client else "anonymous" + key = (upstream, "client:" + label) + current = self._keys.get(key) + if current and self._now() - self._last_seen.get(current, 0) < IDLE_ROLLOVER_S: + return self._touch(current) + run_key = f"client:{label}:{uuid.uuid4().hex[:8]}" + self._keys[key] = self._open(upstream, name, "streamable_http", run_key) + return self._touch(self._keys[key]) + + def bind_session_header(self, session_id: str, upstream: str, value: str | None) -> None: + """Remember the ``Mcp-Session-Id`` a server assigned to a session.""" + if value: + with self._lock: + self._keys.setdefault((upstream, "sid:" + value), session_id) + + def for_sse_stream(self, upstream: str, headers: dict[str, str] | None, + name: str | None = None) -> str | None: + """Session of a GET event stream, if it belongs to a known session.""" + session_header = header(headers, SESSION_HEADER) + if not session_header: + return None + with self._lock: + session_id = self._keys.get((upstream, "sid:" + session_header)) + return self._touch(session_id) if session_id else None + + def open_legacy_sse(self, upstream: str, endpoint: str, name: str | None = None) -> str: + """A legacy HTTP+SSE stream announced its POST endpoint.""" + query = urlsplit(endpoint).query + with self._lock: + session_id = self._open(upstream, name, "http_sse") + self._keys[_endpoint_key(upstream, query)] = session_id + return self._touch(session_id) diff --git a/mcphawk/capture/process.py b/mcphawk/capture/process.py new file mode 100644 index 0000000..81877ed --- /dev/null +++ b/mcphawk/capture/process.py @@ -0,0 +1,60 @@ +"""Which MCP client launched us? Used to group stdio sessions into runs.""" + +import os +from dataclasses import dataclass + +import psutil + +# Launchers that can sit between an MCP client and ``mcphawk wrap``. Node is +# deliberately absent: Node-based clients (Claude Code, VS Code) *are* node. +_LAUNCHERS = frozenset({ + "uv", "uvx", "pipx", "python", "python3", "sh", "bash", "zsh", "dash", "fish", + "env", "cmd.exe", "powershell.exe", "pwsh", "mcphawk", "timeout", "nice", + "caffeinate", +}) + + +@dataclass +class ClientProcess: + pid: int + name: str + run_key: str + + +def _is_launcher(proc: psutil.Process) -> bool: + name = proc.name().lower() + stem = name.removesuffix(".exe") + if name in _LAUNCHERS or stem in _LAUNCHERS or stem.startswith("python3."): + return True + try: + cmdline = " ".join(proc.cmdline()).lower() + except (psutil.Error, OSError): + return False + return "mcphawk" in cmdline and "wrap" in cmdline + + +def find_client(start_pid: int | None = None, max_depth: int = 8) -> ClientProcess | None: + """Walk up from our parent to the first process that is not a launcher. + + If every ancestor looks like a launcher we fall back to the direct parent. + """ + try: + proc = psutil.Process(start_pid or os.getppid()) + first = proc + for _ in range(max_depth): + if not _is_launcher(proc): + break + parent = proc.parent() + if parent is None or parent.pid <= 1: + proc = first + break + proc = parent + else: + proc = first + return ClientProcess( + pid=proc.pid, + name=proc.name(), + run_key=f"pid:{proc.pid}:{int(proc.create_time())}", + ) + except (psutil.Error, OSError): + return None diff --git a/mcphawk/capture/sniff.py b/mcphawk/capture/sniff.py new file mode 100644 index 0000000..a85b93d --- /dev/null +++ b/mcphawk/capture/sniff.py @@ -0,0 +1,279 @@ +"""Passive capture: reconstruct MCP traffic from packets, no config changes. + +Only plaintext traffic can be read, which in practice means local HTTP +servers; use the proxy for HTTPS or remote servers. Needs packet-capture +privileges (usually ``sudo``). +""" + +import logging +import platform +from collections import deque +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlsplit + +from mcphawk.capture.http1 import Http1Stream, looks_like_request, looks_like_response +from mcphawk.capture.http_sessions import SESSION_HEADER, HttpSessions, header +from mcphawk.capture.sse import SSEParser +from mcphawk.capture.stdio import LineFramer +from mcphawk.protocol import jsonrpc +from mcphawk.store import C2S, S2C, Recorder + +logger = logging.getLogger(__name__) + +FIN, SYN, RST = 0x01, 0x02, 0x04 +MAX_OUT_OF_ORDER = 256 +SEQ_MOD = 2**32 + +Endpoint = tuple[str, int] + + +def _mcp_messages(body: bytes) -> list[dict[str, Any]] | None: + """JSON-RPC messages in a body, or None if it is not MCP traffic.""" + messages = jsonrpc.parse_frame(body.decode("utf-8", "replace")) + if messages and all("jsonrpc" in m for m in messages): + return messages + return None + + +@dataclass +class _Direction: + next_seq: int | None = None + out_of_order: dict[int, bytes] = field(default_factory=dict) + + def accept(self, seq: int, payload: bytes, syn: bool) -> list[bytes]: + """In-order payload segments released by this packet.""" + if syn: + self.next_seq = (seq + 1) % SEQ_MOD + seq = self.next_seq + if not payload: + return [] + if self.next_seq is None: + self.next_seq = seq + offset = (seq - self.next_seq) % SEQ_MOD + if offset >= SEQ_MOD // 2: # retransmission overlapping acknowledged data + overlap = SEQ_MOD - offset + if overlap >= len(payload): + return [] + payload, seq = payload[overlap:], self.next_seq + elif offset: + if len(self.out_of_order) < MAX_OUT_OF_ORDER: + self.out_of_order[seq] = payload + return [] + released = [payload] + self.next_seq = (seq + len(payload)) % SEQ_MOD + while self.next_seq in self.out_of_order: + segment = self.out_of_order.pop(self.next_seq) + released.append(segment) + self.next_seq = (self.next_seq + len(segment)) % SEQ_MOD + return released + + +@dataclass +class _Request: + method: str + path: str + headers: dict[str, str] + session_id: str | None + + +class _Connection: + """One TCP connection; figures out its protocol from the first bytes.""" + + def __init__(self, sniffer: "Sniffer", a: Endpoint, b: Endpoint): + self.sniffer = sniffer + self.directions = {a: _Direction(), b: _Direction()} + self.client: Endpoint | None = None + self.server: Endpoint | None = None + self.mode: str | None = None # "http" | "json" | "other" + self.streams: dict[Endpoint, Any] = {} + self.pending: deque[_Request] = deque() + self.json_session: str | None = None + + def packet(self, src: Endpoint, seq: int, flags: int, payload: bytes) -> None: + for segment in self.directions[src].accept(seq, payload, bool(flags & SYN)): + self._data(src, segment) + + def _data(self, src: Endpoint, data: bytes) -> None: + if self.mode is None: + self._detect(src, data) + if self.mode in ("http", "json"): + self.streams[src].feed(data) + + def _detect(self, src: Endpoint, data: bytes) -> None: + other = next(e for e in self.directions if e != src) + stripped = data.lstrip() + if looks_like_request(data): + self.client, self.server = src, other + elif looks_like_response(data): + self.client, self.server = other, src + elif stripped.startswith((b"{", b"[")): + self.mode = "json" + self.client, self.server = src, other + self.streams = { + src: LineFramer(lambda line: self._json_line(C2S, line)), + other: LineFramer(lambda line: self._json_line(S2C, line)), + } + return + else: + self.mode = "other" + return + self.mode = "http" + self.streams = { + self.client: _RequestStream(self), + self.server: _ResponseStream(self), + } + + def _json_line(self, direction: str, line: bytes) -> None: + if _mcp_messages(line) is None: + return + if self.json_session is None: + host, port = self.server + self.json_session = self.sniffer.recorder.open_session( + capture="sniff", transport="unknown", target=f"tcp://{host}:{port}") + self.sniffer.recorder.record(self.json_session, direction, line) + + def upstream(self, path: str) -> str: + host, port = self.server + host = f"[{host}]" if ":" in host else host + return f"http://{host}:{port}{urlsplit(path).path}" + + def close(self) -> None: + for stream in self.streams.values(): + if isinstance(stream, _ResponseStream): + stream.parser.close() + elif isinstance(stream, LineFramer): + stream.flush() + + +class _RequestStream: + def __init__(self, conn: _Connection): + self.conn = conn + self.head: tuple[str, dict[str, str]] | None = None + self.body = bytearray() + self.parser = Http1Stream(False, self._head, self.body.extend, self._end) + + def feed(self, data: bytes) -> None: + self.parser.feed(data) + + def _head(self, start: str, headers: dict[str, str]) -> None: + self.head = (start, headers) + self.body.clear() + + def _end(self) -> None: + start, headers = self.head or ("", {}) + parts = start.split(" ") + method, path = (parts[0], parts[1]) if len(parts) > 1 else ("", "/") + session_id = None + messages = _mcp_messages(bytes(self.body)) if self.body else None + if messages is not None: + sessions = self.conn.sniffer.sessions + session_id = sessions.for_request( + self.conn.upstream(path), headers, messages, query=urlsplit(path).query) + self.conn.sniffer.recorder.record(session_id, C2S, bytes(self.body), + headers=headers) + self.conn.pending.append(_Request(method, path, headers, session_id)) + + +class _ResponseStream: + def __init__(self, conn: _Connection): + self.conn = conn + self.request: _Request | None = None + self.sse: SSEParser | None = None + self.session_id: str | None = None + self.body = bytearray() + self.parser = Http1Stream(True, self._head, self._data, self._end) + + def feed(self, data: bytes) -> None: + self.parser.feed(data) + + def _head(self, start: str, headers: dict[str, str]) -> None: + self.request = self.conn.pending.popleft() if self.conn.pending else None + self.body.clear() + self.sse = None + self.session_id = self.request.session_id if self.request else None + if self.request is None: + return + upstream = self.conn.upstream(self.request.path) + sessions = self.conn.sniffer.sessions + if self.session_id: + sessions.bind_session_header(self.session_id, upstream, + header(headers, SESSION_HEADER)) + if "text/event-stream" in (header(headers, "content-type") or ""): + self.sse = SSEParser() + if self.session_id is None: + self.session_id = sessions.for_sse_stream(upstream, self.request.headers) + + def _data(self, data: bytes) -> None: + if self.sse is None: + self.body.extend(data) + return + for event in self.sse.feed(data): + if event.event == "endpoint" and self.session_id is None and self.request: + self.session_id = self.conn.sniffer.sessions.open_legacy_sse( + self.conn.upstream(self.request.path), event.data) + elif self.session_id and _mcp_messages(event.data.encode()) is not None: + self.conn.sniffer.recorder.record(self.session_id, S2C, event.data) + + def _end(self) -> None: + if (self.sse is None and self.session_id and self.body + and _mcp_messages(bytes(self.body)) is not None): + self.conn.sniffer.recorder.record(self.session_id, S2C, bytes(self.body)) + self.body.clear() + + +class Sniffer: + def __init__(self, recorder: Recorder, excluded_ports: set[int] | None = None): + self.recorder = recorder + self.sessions = HttpSessions(recorder, capture="sniff") + self.excluded_ports = excluded_ports or set() + self._connections: dict[frozenset[Endpoint], _Connection] = {} + + def process(self, src: Endpoint, dst: Endpoint, seq: int, flags: int, + payload: bytes) -> None: + if src[1] in self.excluded_ports or dst[1] in self.excluded_ports: + return + key = frozenset((src, dst)) + conn = self._connections.get(key) + if conn is None: + if not payload and not flags & SYN: + return + conn = self._connections[key] = _Connection(self, src, dst) + try: + conn.packet(src, seq, flags, payload) + except Exception: + logger.exception("dropping connection %s <-> %s after parse error", src, dst) + self._connections.pop(key, None) + return + if flags & (FIN | RST): + conn.close() + self._connections.pop(key, None) + + def handle_packet(self, pkt: Any) -> None: + """Scapy callback.""" + from scapy.all import IP, TCP, IPv6 + + if not pkt.haslayer(TCP): + return + ip = pkt[IP] if pkt.haslayer(IP) else pkt[IPv6] if pkt.haslayer(IPv6) else None + if ip is None: + return + tcp = pkt[TCP] + self.process((ip.src, int(tcp.sport)), (ip.dst, int(tcp.dport)), int(tcp.seq), + int(tcp.flags), bytes(tcp.payload)) + + +def default_interface() -> str | None: + return "lo0" if platform.system() == "Darwin" else None + + +def run_sniffer(filter_expr: str, recorder: Recorder, interface: str | None = None, + excluded_ports: set[int] | None = None) -> None: # pragma: no cover + """Blocking capture loop (needs packet-capture privileges).""" + logging.getLogger("scapy.runtime").setLevel(logging.ERROR) + from scapy.all import conf, sniff + + conf.use_pcap = True + sniffer = Sniffer(recorder, excluded_ports) + sniff(filter=filter_expr, iface=interface or default_interface(), + prn=sniffer.handle_packet, store=False) diff --git a/mcphawk/capture/sse.py b/mcphawk/capture/sse.py new file mode 100644 index 0000000..9cbc579 --- /dev/null +++ b/mcphawk/capture/sse.py @@ -0,0 +1,81 @@ +"""Incremental Server-Sent Events parser (WHATWG event-stream format).""" + +from dataclasses import dataclass + + +@dataclass +class SSEEvent: + event: str + data: str + id: str | None = None + + +class SSEParser: + """Feed arbitrary byte chunks; get complete events back. + + Handles LF, CRLF and CR line endings, multi-line ``data:`` fields, + comments and events split across chunk boundaries. + """ + + def __init__(self) -> None: + self._buffer = "" + self._pending = b"" + self._event = "" + self._data: list[str] = [] + self._id: str | None = None + + def feed(self, chunk: bytes | str) -> list[SSEEvent]: + if isinstance(chunk, bytes): + chunk = self._pending + chunk + try: + text = chunk.decode("utf-8") + self._pending = b"" + except UnicodeDecodeError as exc: + # keep a trailing partial multi-byte sequence for the next chunk + text = chunk[:exc.start].decode("utf-8") + self._pending = chunk[exc.start:] + else: + text = chunk + self._buffer += text + events: list[SSEEvent] = [] + while True: + line, found = self._next_line() + if not found: + break + event = self._process_line(line) + if event: + events.append(event) + return events + + def _next_line(self) -> tuple[str, bool]: + for i, char in enumerate(self._buffer): + if char == "\n": + line, self._buffer = self._buffer[:i], self._buffer[i + 1:] + return line, True + if char == "\r": + if i + 1 == len(self._buffer): + return "", False # might be the first half of CRLF + skip = 2 if self._buffer[i + 1] == "\n" else 1 + line, self._buffer = self._buffer[:i], self._buffer[i + skip:] + return line, True + return "", False + + def _process_line(self, line: str) -> SSEEvent | None: + if line == "": + if not self._data: + self._event = "" + return None + event = SSEEvent(self._event or "message", "\n".join(self._data), self._id) + self._event, self._data = "", [] + return event + if line.startswith(":"): + return None + name, _, value = line.partition(":") + value = value[1:] if value.startswith(" ") else value + if name == "data": + self._data.append(value) + elif name == "event": + self._event = value + elif name == "id": + self._id = value + return None diff --git a/mcphawk/capture/stdio.py b/mcphawk/capture/stdio.py new file mode 100644 index 0000000..7058b72 --- /dev/null +++ b/mcphawk/capture/stdio.py @@ -0,0 +1,176 @@ +"""Transparent stdio shim: ``mcphawk wrap -- ``. + +Bytes are forwarded first and recorded second, so a slow disk or a bug in +recording can never stall or corrupt the traffic between client and server. +""" + +import contextlib +import logging +import os +import shlex +import signal +import subprocess +import sys +import threading +from collections.abc import Callable +from typing import BinaryIO + +from mcphawk.capture.process import find_client +from mcphawk.protocol.masking import mask_argv +from mcphawk.store import C2S, S2C, Recorder + +logger = logging.getLogger(__name__) + +CHUNK = 64 * 1024 +MAX_LINE = 64 * 1024 * 1024 + + +class LineFramer: + """Splits a byte stream into newline-delimited frames.""" + + def __init__(self, on_line: Callable[[bytes], None]): + self._buffer = bytearray() + self._on_line = on_line + + def feed(self, chunk: bytes) -> None: + self._buffer.extend(chunk) + while True: + index = self._buffer.find(b"\n") + if index < 0: + break + line = bytes(self._buffer[:index]) + del self._buffer[:index + 1] + self._emit(line) + if len(self._buffer) > MAX_LINE: + self._emit(bytes(self._buffer)) + self._buffer.clear() + + def flush(self) -> None: + if self._buffer: + self._emit(bytes(self._buffer)) + self._buffer.clear() + + def _emit(self, line: bytes) -> None: + if line.strip(): + try: + self._on_line(line) + except Exception: # recording must never break forwarding + logger.exception("failed to record frame") + + +def _read_chunk(stream: BinaryIO) -> bytes: + reader = getattr(stream, "read1", None) + return reader(CHUNK) if reader else stream.read(CHUNK) + + +def _pump(source: BinaryIO, sink: BinaryIO | None, framer: LineFramer, + close_sink: bool) -> None: + try: + while True: + chunk = _read_chunk(source) + if not chunk: + break + if sink is not None: + try: + sink.write(chunk) + sink.flush() + except (BrokenPipeError, OSError, ValueError): + sink = None # peer is gone; keep draining for the record + framer.feed(chunk) + except (OSError, ValueError): + pass + finally: + framer.flush() + if close_sink and sink is not None: + with contextlib.suppress(OSError): + sink.close() + + +class StdioShim: + def __init__( + self, + command: list[str], + *, + name: str | None = None, + recorder: Recorder | None = None, + stdin: BinaryIO | None = None, + stdout: BinaryIO | None = None, + stderr: BinaryIO | None = None, + mask: bool = True, + ): + if not command: + raise ValueError("no server command given") + self.command = command + self.name = name + self.recorder = recorder or Recorder(mask=mask) + self._mask = mask + self._stdin = stdin or sys.stdin.buffer + self._stdout = stdout or sys.stdout.buffer + self._stderr = stderr or sys.stderr.buffer + self.proc: subprocess.Popen | None = None + self.session_id: str | None = None + + def run(self) -> int: + try: + self.proc = subprocess.Popen( + self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, bufsize=0) + except OSError as exc: + self._stderr.write(f"mcphawk: cannot start {self.command[0]!r}: {exc}\n".encode()) + self._stderr.flush() + return 127 + + client = find_client() + argv = mask_argv(self.command) if self._mask else self.command + self.session_id = self.recorder.open_session( + capture="wrap", transport="stdio", + name=self.name or os.path.basename(self.command[0]), + target=shlex.join(argv), pid=os.getpid(), + client_pid=client.pid if client else None, + client_app=client.name if client else None, + run_key=client.run_key if client else None, + ) + sid = self.session_id + to_server = LineFramer(lambda line: self.recorder.record(sid, C2S, line)) + to_client = LineFramer(lambda line: self.recorder.record(sid, S2C, line)) + + threads = [ + threading.Thread(target=_pump, args=(self._stdin, self.proc.stdin, to_server, True), + daemon=True), + threading.Thread(target=_pump, args=(self.proc.stdout, self._stdout, to_client, + False), daemon=True), + threading.Thread(target=_pump, args=(self.proc.stderr, self._stderr, + LineFramer(lambda _: None), False), + daemon=True), + ] + for thread in threads: + thread.start() + try: + code = self.proc.wait() + except KeyboardInterrupt: + self.terminate() + code = self.proc.wait() + # stdout/stderr hit EOF once the server exits; the stdin pump may + # block on the client forever, so it is not joined. + for thread in threads[1:]: + thread.join(timeout=2) + self.recorder.end_session(sid) + return code + + def terminate(self, *_: object) -> None: + if self.proc and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def run_wrap(command: list[str], name: str | None = None, mask: bool = True) -> int: + shim = StdioShim(command, name=name, mask=mask) + signal.signal(signal.SIGTERM, shim.terminate) + if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, shim.terminate) + code = shim.run() + # a server killed by a signal reports -N; shells expect 128 + N + return 128 - code if code < 0 else code diff --git a/mcphawk/cli.py b/mcphawk/cli.py index 3178ed4..5b273bb 100644 --- a/mcphawk/cli.py +++ b/mcphawk/cli.py @@ -1,277 +1,301 @@ +"""MCPHawk command line.""" + import asyncio import logging +import os import sys import threading +import webbrowser +from pathlib import Path import typer -from mcphawk.logger import init_db -from mcphawk.mcp_server.server import MCPHawkServer -from mcphawk.sniffer import start_sniffer -from mcphawk.web.server import run_web -from mcphawk.wrapper import run_wrapper +from mcphawk import __version__ +from mcphawk.install import clients as cl +from mcphawk.install import installer +from mcphawk.paths import db_path -# Suppress Scapy warnings about network interfaces -logging.getLogger("scapy.runtime").setLevel(logging.ERROR) +app = typer.Typer( + help="MCPHawk: DevTools for MCP. See what your MCP clients and servers really say.", + no_args_is_help=False, + add_completion=False, +) -# Setup logger for CLI -logger = logging.getLogger("mcphawk.cli") +DEFAULT_PORT = installer.DEFAULT_PORT -# ✅ Typer multi-command app -app = typer.Typer(help="MCPHawk: Passive MCP traffic sniffer + dashboard") -# Initialize database once when CLI starts -init_db() +def _log_to_stderr(debug: bool) -> None: + logging.basicConfig( + stream=sys.stderr, level=logging.DEBUG if debug else logging.WARNING, + format="mcphawk: %(message)s") -@app.command() -def sniff( - port: int = typer.Option(None, "--port", "-p", help="TCP port to monitor"), - filter: str = typer.Option(None, "--filter", "-f", help="Custom BPF filter expression"), - auto_detect: bool = typer.Option(False, "--auto-detect", "-a", help="Auto-detect MCP traffic on any port"), - with_mcp: bool = typer.Option(False, "--with-mcp", help="Start MCP server alongside sniffer"), - mcp_transport: str = typer.Option("http", "--mcp-transport", help="MCP transport type: stdio or http"), - mcp_port: int = typer.Option(8765, "--mcp-port", help="Port for MCP HTTP server (ignored for stdio)"), - debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug output") -): - """Start sniffing MCP traffic (console output only).""" - # Configure logging - clear existing handlers first - logger.handlers.clear() - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - logger.setLevel(logging.DEBUG if debug else logging.INFO) - - # Validate that user specified either port, filter, or auto-detect - if not any([port, filter, auto_detect]): - logger.error("You must specify either --port, --filter, or --auto-detect") - logger.error("Examples:") - logger.error(" mcphawk sniff --port 3000") - logger.error(" mcphawk sniff --filter 'tcp port 3000 or tcp port 3001'") - logger.error(" mcphawk sniff --auto-detect") - raise typer.Exit(1) +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", help="Show the version and exit."), +) -> None: + """With no command, starts the web UI (same as ``mcphawk up``).""" + if version: + typer.echo(f"mcphawk {__version__}") + raise typer.Exit() + if ctx.invoked_subcommand is None: + _serve() - # Determine filter expression - if filter: - # User provided custom filter - filter_expr = filter - elif port: - # User provided specific port - filter_expr = f"tcp port {port}" - elif auto_detect: - # Auto-detect mode - capture all TCP traffic - filter_expr = "tcp" - logger.info("Auto-detect mode: monitoring all TCP traffic for MCP messages") - else: - # Default to tcp - filter_expr = "tcp" - - # Start MCP server if requested - mcp_thread = None - excluded_ports = [] - if with_mcp: - server = MCPHawkServer() - - if mcp_transport == "http": - logger.info(f"Starting MCP HTTP server on http://localhost:{mcp_port}/mcp") - # MCPHawk's own MCP traffic will be captured and identified by server name - def run_mcp(): - asyncio.run(server.run_http(port=mcp_port)) - else: - logger.info("Starting MCP server on stdio (configure in your MCP client)") - if debug: - logger.info("Note: stdio MCP traffic cannot be captured (use HTTP transport for debugging)") - def run_mcp(): - asyncio.run(server.run_stdio()) - - mcp_thread = threading.Thread(target=run_mcp, daemon=True) - mcp_thread.start() - - logger.info(f"Starting sniffer with filter: {filter_expr}") - logger.info("Press Ctrl+C to stop...") - - try: - start_sniffer( - filter_expr=filter_expr, - auto_detect=auto_detect, - debug=debug, - excluded_ports=excluded_ports - ) - except KeyboardInterrupt: - logger.info("Sniffer stopped.") - sys.exit(0) + +def _sniff_filter(ports: list[int] | None, custom: str | None, auto: bool) -> str | None: + if custom: + return custom + if ports: + return " or ".join(f"tcp port {p}" for p in ports) + return "tcp" if auto else None + + +def _start_sniffer(filter_expr: str, interface: str | None, excluded: set[int], + mask: bool) -> None: # pragma: no cover - needs capture privileges + from mcphawk.capture.sniff import run_sniffer + from mcphawk.store import Recorder + + def target() -> None: + try: + run_sniffer(filter_expr, Recorder(mask=mask), interface, excluded) + except PermissionError: + typer.echo("mcphawk: packet capture needs root (try sudo)", err=True) + + threading.Thread(target=target, daemon=True, name="sniffer").start() @app.command() -def web( - port: int = typer.Option(None, "--port", "-p", help="TCP port to monitor for MCP traffic"), - filter: str = typer.Option(None, "--filter", "-f", help="Custom BPF filter expression"), - auto_detect: bool = typer.Option(False, "--auto-detect", "-a", help="Auto-detect MCP traffic on any port"), - no_sniffer: bool = typer.Option(False, "--no-sniffer", help="Disable sniffer (view historical logs only)"), - host: str = typer.Option("127.0.0.1", "--host", help="Web server host"), - web_port: int = typer.Option(8000, "--web-port", help="Web server port"), - with_mcp: bool = typer.Option(False, "--with-mcp", help="Start MCP server alongside web UI"), - mcp_transport: str = typer.Option("http", "--mcp-transport", help="MCP transport type: stdio or http"), - mcp_port: int = typer.Option(8765, "--mcp-port", help="Port for MCP HTTP server (ignored for stdio)"), - debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug output") -): - """Start the MCPHawk dashboard with sniffer.""" - # Configure logging - clear existing handlers first - logger.handlers.clear() - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - logger.setLevel(logging.DEBUG if debug else logging.INFO) - - # If sniffer is enabled, validate that user specified either port, filter, or auto-detect - if not no_sniffer and not any([port, filter, auto_detect]): - logger.error("You must specify either --port, --filter, or --auto-detect (or use --no-sniffer)") - logger.error("Examples:") - logger.error(" mcphawk web --port 3000") - logger.error(" mcphawk web --filter 'tcp port 3000 or tcp port 3001'") - logger.error(" mcphawk web --auto-detect") - logger.error(" mcphawk web --no-sniffer # View historical logs only") - raise typer.Exit(1) +def up( + port: int = typer.Option(DEFAULT_PORT, "--port", help="Port for UI, API, proxy and /mcp."), + host: str = typer.Option("127.0.0.1", "--host", help="Interface to bind."), + sniff: list[int] = typer.Option(None, "--sniff", help="Also sniff this TCP port (sudo)."), + sniff_filter: str = typer.Option(None, "--sniff-filter", help="Custom BPF filter (sudo)."), + no_mcp: bool = typer.Option(False, "--no-mcp", help="Do not serve the MCPHawk MCP endpoint."), + no_mask: bool = typer.Option(False, "--no-mask", help="Store secrets unmasked."), + open_browser: bool = typer.Option(False, "--open", help="Open the UI in a browser."), + debug: bool = typer.Option(False, "--debug", help="Verbose logging."), +) -> None: + """Start the web UI (plus proxy and MCP endpoint) on one local port.""" + _serve(port, host, sniff, sniff_filter, no_mcp, no_mask, open_browser, debug) + + +def _serve(port: int = DEFAULT_PORT, host: str = "127.0.0.1", sniff: list[int] | None = None, + sniff_filter: str | None = None, no_mcp: bool = False, no_mask: bool = False, + open_browser: bool = False, debug: bool = False) -> None: + import uvicorn + + from mcphawk.web.app import create_app + + _log_to_stderr(debug) + url = f"http://{'127.0.0.1' if host in ('0.0.0.0', '::') else host}:{port}" + os.environ["MCPHAWK_URL"] = url + filter_expr = _sniff_filter(sniff, sniff_filter, False) + if filter_expr: + _start_sniffer(filter_expr, None, {port}, not no_mask) + typer.echo(f"MCPHawk {__version__} -> {url}") + typer.echo(f" database {db_path()}") + if not no_mcp: + typer.echo(f" MCP {url}/mcp (or: claude mcp add mcphawk -- mcphawk mcp)") + proxies = installer.load_registry() + if proxies: + typer.echo(f" proxying {', '.join(sorted(proxies))}") + if open_browser: # pragma: no cover + threading.Timer(1.0, webbrowser.open, (url,)).start() + uvicorn.run(create_app(with_mcp=not no_mcp, mask=not no_mask), host=host, port=port, + log_level="debug" if debug else "warning") + + +@app.command(context_settings={"allow_extra_args": True, "allow_interspersed_args": False, + "ignore_unknown_options": True}) +def wrap( + ctx: typer.Context, + name: str = typer.Option(None, "--name", help="Server name shown in MCPHawk."), + no_mask: bool = typer.Option(False, "--no-mask", help="Store secrets unmasked."), + debug: bool = typer.Option(False, "--debug", help="Log to stderr."), +) -> None: + """Run an stdio MCP server and record its traffic: mcphawk wrap -- """ + from mcphawk.capture.stdio import run_wrap + + command = list(ctx.args) + if command and command[0] == "--": + command = command[1:] + if not command: + typer.echo("usage: mcphawk wrap [--name NAME] -- [args...]", err=True) + raise typer.Exit(2) + _log_to_stderr(debug) + raise typer.Exit(run_wrap(command, name=name, mask=not no_mask)) - # Prepare filter expression - if no_sniffer: - filter_expr = None # No sniffer - elif filter: - filter_expr = filter - elif port: - filter_expr = f"tcp port {port}" - elif auto_detect: - filter_expr = "tcp" - else: - filter_expr = "tcp" # Default - - # Start MCP server if requested - mcp_thread = None - excluded_ports = [] - if with_mcp: - server = MCPHawkServer() - - if mcp_transport == "http": - logger.info(f"Starting MCP HTTP server on http://localhost:{mcp_port}/mcp") - # MCPHawk's own MCP traffic will be captured and identified by server name - def run_mcp(): - asyncio.run(server.run_http(port=mcp_port)) - else: - logger.info("Starting MCP server on stdio (configure in your MCP client)") - if debug: - logger.info("Note: stdio MCP traffic cannot be captured (use HTTP transport for debugging)") - def run_mcp(): - asyncio.run(server.run_stdio()) - - mcp_thread = threading.Thread(target=run_mcp, daemon=True) - mcp_thread.start() - - run_web( - sniffer=not no_sniffer, - host=host, - port=web_port, - filter_expr=filter_expr, - auto_detect=auto_detect, - debug=debug, - excluded_ports=excluded_ports, - with_mcp=with_mcp - ) + +@app.command() +def proxy( + target: str = typer.Option(..., "--target", help="Upstream MCP server URL."), + name: str = typer.Option("server", "--name", help="Name shown in MCPHawk."), + port: int = typer.Option(8485, "--port", help="Local port to listen on."), + no_mask: bool = typer.Option(False, "--no-mask", help="Store secrets unmasked."), +) -> None: + """Record one HTTP MCP server without running the UI.""" + import uvicorn + from starlette.applications import Starlette + + from mcphawk.capture.http_proxy import Proxy + from mcphawk.store import Recorder + + recorder = Recorder(mask=not no_mask) + proxy_ = Proxy({name: target}, recorder) + typer.echo(f"Point your client at: http://127.0.0.1:{port}/p/{name}") + uvicorn.run(Starlette(routes=proxy_.routes()), host="127.0.0.1", port=port, + log_level="warning") + + +def _print_plan(changes: list[installer.Change]) -> None: + if not changes: + typer.echo("No MCP client configurations found.") + return + current = None + for change in changes: + label = change.table.label() + if label != current: + typer.echo(f"\n{label} {change.table.path}") + current = label + mark = {"wrap": "+", "proxy": "+", "unwrap": "-", "unproxy": "-"}.get(change.action, " ") + detail = change.action if change.after is not None else change.reason + typer.echo(f" {mark} {change.server:<28} {detail}") + + +def _run_plan(uninstall: bool, clients: list[str] | None, project: Path | None, + include_http: bool, force_http: bool, port: int, dry_run: bool, + yes: bool) -> None: + chosen = tuple(clients) if clients else cl.ALL_CLIENTS + unknown = [c for c in chosen if c not in cl.ALL_CLIENTS] + if unknown: + typer.echo(f"unknown client(s): {', '.join(unknown)}; " + f"choose from {', '.join(cl.ALL_CLIENTS)}", err=True) + raise typer.Exit(2) + registry = installer.load_registry() + changes = installer.plan(uninstall=uninstall, clients=chosen, project=project, + include_http=include_http, force_http=force_http, port=port, + registry=registry) + _print_plan(changes) + effective = [c for c in changes if c.after is not None] + if not effective: + typer.echo("\nNothing to change.") + return + if dry_run: + typer.echo(f"\n{len(effective)} change(s) planned (dry run, nothing written).") + return + if not yes and not typer.confirm(f"\nApply {len(effective)} change(s)?", default=True): + raise typer.Exit(1) + for note in installer.apply(changes, registry): + typer.echo(note) + typer.echo("\nRestart your MCP clients to pick up the change.") + if not uninstall and any(c.action == installer.PROXY for c in effective): + typer.echo("HTTP servers now go through MCPHawk: keep `mcphawk up` running.") @app.command() -def mcp( - transport: str = typer.Option("stdio", "--transport", "-t", help="Transport type: stdio or tcp"), - mcp_port: int = typer.Option(8765, "--mcp-port", help="Port for TCP transport (ignored for stdio)"), - debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug output") -): - """Run MCPHawk MCP server standalone (query existing captured data).""" - # Configure logging based on transport and debug flag - clear existing handlers first - logger.handlers.clear() - if transport == "stdio": - # For stdio, all logs must go to stderr to avoid interfering with JSON-RPC on stdout - handler = logging.StreamHandler(sys.stderr) - else: - # For other transports, use stdout - handler = logging.StreamHandler(sys.stdout) +def install( + client: list[str] = typer.Option(None, "--client", help="Only these clients."), + project: Path = typer.Option(None, "--project", help="Also edit this project's configs."), + include_http: bool = typer.Option(False, "--include-http", + help="Route local/header-auth HTTP servers via the proxy."), + force_http: bool = typer.Option(False, "--force-http", + help="Proxy every HTTP server, even likely-OAuth ones."), + port: int = typer.Option(DEFAULT_PORT, "--port", help="Port `mcphawk up` listens on."), + dry_run: bool = typer.Option(False, "--dry-run", help="Show the plan only."), + yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."), +) -> None: + """Route every MCP server of your clients through MCPHawk (with backups).""" + _run_plan(False, client, project, include_http, force_http, port, dry_run, yes) - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - logger.setLevel(logging.DEBUG if debug else logging.INFO) - logger.info(f"Starting MCP server (transport: {transport})") +@app.command() +def uninstall( + client: list[str] = typer.Option(None, "--client", help="Only these clients."), + project: Path = typer.Option(None, "--project", help="Also edit this project's configs."), + port: int = typer.Option(DEFAULT_PORT, "--port", help="Port used at install time."), + dry_run: bool = typer.Option(False, "--dry-run", help="Show the plan only."), + yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."), +) -> None: + """Undo `mcphawk install`, restoring the original server entries.""" + _run_plan(True, client, project, False, False, port, dry_run, yes) - if transport == "stdio": - logger.debug("Use this server with MCP clients by configuring stdio transport") - logger.debug("Example MCP client configuration:") - logger.debug(""" -{ - "mcpServers": { - "mcphawk": { - "command": "mcphawk", - "args": ["mcp"] - } - } -} - """) - elif transport == "http": - logger.info(f"MCP server will listen on http://localhost:{mcp_port}/mcp") - logger.debug("Example test command:") - logger.debug(f"curl -X POST http://localhost:{mcp_port}/mcp -H 'Content-Type: application/json' -d '{{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{{}},\"clientInfo\":{{\"name\":\"test\",\"version\":\"1.0\"}}}},\"id\":1}}'") - else: - logger.error(f"Unknown transport: {transport}") - raise typer.Exit(1) - # Create and run MCP server - server = MCPHawkServer() +@app.command() +def status() -> None: + """Show where data lives, what was captured and which servers are routed.""" + from mcphawk.query import Query + + q = Query() + stats = q.stats() + q.close() + typer.echo(f"database {db_path()}") + typer.echo(f"captured {stats.get('sessions', 0)} sessions, " + f"{stats.get('exchanges', 0)} calls, {stats.get('errors', 0)} errors") + routed = [c for c in installer.plan(uninstall=True) if c.after is not None] + typer.echo(f"routed {len(routed)} server(s) through MCPHawk") + for change in routed: + typer.echo(f" {change.table.label():<32} {change.server}") + - try: - if transport == "stdio": - asyncio.run(server.run_stdio()) - elif transport == "http": - asyncio.run(server.run_http(port=mcp_port)) - except KeyboardInterrupt: - logger.info("MCP server stopped.") - sys.exit(0) +@app.command() +def mcp( + transport: str = typer.Option("stdio", "--transport", help="stdio or http."), + port: int = typer.Option(8765, "--port", help="Port for --transport http."), +) -> None: + """Serve MCPHawk's own MCP server so agents can query captured traffic.""" + from mcphawk.mcp_server import build_server + + _log_to_stderr(False) + server = build_server() + if transport == "stdio": + asyncio.run(server.run_stdio_async()) + elif transport == "http": # pragma: no cover - blocking server + import uvicorn + uvicorn.run(server.streamable_http_app(), host="127.0.0.1", port=port, + log_level="warning") + else: + typer.echo("transport must be stdio or http", err=True) + raise typer.Exit(2) -@app.command(context_settings={"allow_extra_args": True, "allow_interspersed_args": False}) -def wrap( - ctx: typer.Context, - debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug output") -): - """Wrap an MCP server to capture stdio traffic transparently. - - Usage: - mcphawk wrap /path/to/mcp-server --arg1 --arg2 - - Configure in Claude Desktop settings: - Instead of: - "command": "/path/to/mcp-server", - "args": ["--arg1", "--arg2"] - - Use: - "command": "mcphawk", - "args": ["wrap", "/path/to/mcp-server", "--arg1", "--arg2"] - """ - # Get the command from remaining args - command = ctx.args - if not command: - logger.error("No command specified to wrap") - raise typer.Exit(1) - # Configure logging - logger.handlers.clear() - handler = logging.StreamHandler(sys.stderr) # Use stderr to avoid interfering with stdio - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - logger.setLevel(logging.DEBUG if debug else logging.INFO) +@app.command() +def sniff( + port: list[int] = typer.Option(None, "--port", "-p", help="TCP port(s) to watch."), + filter_: str = typer.Option(None, "--filter", "-f", help="Custom BPF filter."), + auto_detect: bool = typer.Option(False, "--auto-detect", "-a", + help="Watch all TCP traffic for MCP."), + interface: str = typer.Option(None, "--interface", "-i", help="Capture interface."), + no_mask: bool = typer.Option(False, "--no-mask", help="Store secrets unmasked."), +) -> None: + """Passively capture plaintext HTTP/TCP MCP traffic (needs sudo).""" + filter_expr = _sniff_filter(port, filter_, auto_detect) + if not filter_expr: + typer.echo("choose --port, --filter or --auto-detect", err=True) + raise typer.Exit(2) + from mcphawk.capture.sniff import run_sniffer + from mcphawk.store import Recorder + + typer.echo(f"sniffing '{filter_expr}' -> {db_path()} (Ctrl+C to stop)") + try: # pragma: no cover - needs capture privileges + run_sniffer(filter_expr, Recorder(mask=not no_mask), interface) + except PermissionError: + typer.echo("packet capture needs root: try `sudo mcphawk sniff ...`", err=True) + raise typer.Exit(1) from None + except KeyboardInterrupt: # pragma: no cover + pass - logger.info(f"Starting MCP wrapper for: {' '.join(command)}") - # Run the wrapper - exit_code = run_wrapper(command, debug=debug) - sys.exit(exit_code) +@app.command() +def clear(yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask.")) -> None: + """Delete all captured traffic.""" + from mcphawk.query import Query + if not yes and not typer.confirm(f"Delete everything in {db_path()}?", default=False): + raise typer.Exit(1) + q = Query() + q.clear() + q.close() + typer.echo("cleared") -if __name__ == "__main__": - app() diff --git a/mcphawk/install/__init__.py b/mcphawk/install/__init__.py new file mode 100644 index 0000000..f159eb9 --- /dev/null +++ b/mcphawk/install/__init__.py @@ -0,0 +1 @@ +"""Routing MCP client configs through MCPHawk.""" diff --git a/mcphawk/install/clients.py b/mcphawk/install/clients.py new file mode 100644 index 0000000..c7f8ce4 --- /dev/null +++ b/mcphawk/install/clients.py @@ -0,0 +1,102 @@ +"""Where MCP clients keep their server configuration, and in what shape.""" + +import json +import os +import platform +import re +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +CLAUDE_DESKTOP = "claude-desktop" +CLAUDE_CODE = "claude-code" +CURSOR = "cursor" +VSCODE = "vscode" +ALL_CLIENTS = (CLAUDE_DESKTOP, CLAUDE_CODE, CURSOR, VSCODE) + + +@dataclass(frozen=True) +class ServerTable: + """One ``{name: server}`` mapping inside a config file.""" + + client: str + scope: str + path: Path + keys: tuple[str, ...] # JSON path to the mapping + + def label(self) -> str: + return f"{self.client} ({self.scope})" + + +def _app_support(home: Path) -> Path: + system = platform.system() + if system == "Darwin": + return home / "Library" / "Application Support" + if system == "Windows": + return Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) + return Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) + + +def config_files(client: str, home: Path, project: Path | None) -> list[tuple[str, Path]]: + """(scope, path) pairs a client reads, whether or not they exist.""" + support = _app_support(home) + if client == CLAUDE_DESKTOP: + return [("user", support / "Claude" / "claude_desktop_config.json")] + if client == CLAUDE_CODE: + files = [("user", home / ".claude.json")] + if project: + files.append(("project", project / ".mcp.json")) + return files + if client == CURSOR: + files = [("user", home / ".cursor" / "mcp.json")] + if project: + files.append(("project", project / ".cursor" / "mcp.json")) + return files + if client == VSCODE: + files = [("user", support / "Code" / "User" / "mcp.json")] + if project: + files.append(("project", project / ".vscode" / "mcp.json")) + return files + raise ValueError(f"unknown client {client!r}") + + +_COMMENTS = re.compile( + r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*[\s\S]*?\*/', re.MULTILINE) +_TRAILING_COMMA = re.compile(r'"(?:\\.|[^"\\])*"|,(\s*[}\]])') + + +def loads_jsonc(text: str) -> Any: + """Parse JSON with comments and trailing commas (VS Code's mcp.json).""" + def strip_comment(match: re.Match[str]) -> str: + token = match.group(0) + return token if token.startswith('"') else "" + + def strip_comma(match: re.Match[str]) -> str: + return match.group(1) if match.group(1) is not None else match.group(0) + + text = _COMMENTS.sub(strip_comment, text) + text = _TRAILING_COMMA.sub(strip_comma, text) + return json.loads(text) if text.strip() else {} + + +def has_comments(text: str) -> bool: + return any(not m.group(0).startswith('"') for m in _COMMENTS.finditer(text)) + + +def server_tables(client: str, data: dict[str, Any], scope: str, + path: Path) -> Iterator[ServerTable]: + key = "servers" if client == VSCODE else "mcpServers" + if isinstance(data.get(key), dict): + yield ServerTable(client, scope, path, (key,)) + if client == CLAUDE_CODE and scope == "user": + for project, settings in (data.get("projects") or {}).items(): + if isinstance(settings, dict) and isinstance(settings.get("mcpServers"), dict): + yield ServerTable(client, f"local:{project}", path, + ("projects", project, "mcpServers")) + + +def resolve(data: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]: + for key in keys: + data = data[key] + return data diff --git a/mcphawk/install/installer.py b/mcphawk/install/installer.py new file mode 100644 index 0000000..e18a44c --- /dev/null +++ b/mcphawk/install/installer.py @@ -0,0 +1,270 @@ +"""``mcphawk install`` / ``uninstall``: route client configs through MCPHawk. + +stdio servers get ``mcphawk wrap --`` put in front of their command. HTTP +servers are pointed at the MCPHawk proxy (``mcphawk up`` must then be +running). Every file is backed up before it is written, and uninstall undoes +the change structurally, so edits made in between survive. +""" + +import json +import os +import shutil +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from mcphawk.install import clients as cl +from mcphawk.paths import data_dir + +DEFAULT_PORT = 8484 +WRAP_MARKER = "wrap" +LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"}) + +WRAP = "wrap" +PROXY = "proxy" +UNWRAP = "unwrap" +UNPROXY = "unproxy" +SKIP = "skip" + + +@dataclass +class Change: + table: cl.ServerTable + server: str + action: str + reason: str = "" + before: dict[str, Any] = field(default_factory=dict) + after: dict[str, Any] | None = None + + +# -- proxy registry ------------------------------------------------------- + +def registry_path() -> Path: + return data_dir() / "proxies.json" + + +def load_registry() -> dict[str, str]: + """Proxy name -> upstream URL, as used by ``mcphawk up``.""" + try: + data = json.loads(registry_path().read_text()) + except (OSError, json.JSONDecodeError): + return {} + return {k: v for k, v in data.items() if isinstance(v, str)} + + +def save_registry(registry: dict[str, str]) -> None: + registry_path().write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n") + + +# -- command resolution --------------------------------------------------- + +def mcphawk_command() -> list[str]: + """How a GUI app (without the user's shell PATH) should launch mcphawk.""" + if "/uv/archive-" in sys.executable or "/.cache/uv/" in sys.executable: + uvx = shutil.which("uvx") + return [uvx or "uvx", "mcphawk"] + script = Path(sys.argv[0]) + if script.name in ("mcphawk", "mcphawk.exe") and script.exists(): + return [str(script.resolve())] + found = shutil.which("mcphawk") + return [found] if found else [sys.executable, "-m", "mcphawk"] + + +def _is_mcphawk(command: str | None, args: list[str]) -> bool: + if not command: + return False + tokens = [os.path.basename(command), *args[:2]] + return any(t in ("mcphawk", "mcphawk.exe") for t in tokens) or ( + args[:2] == ["-m", "mcphawk"]) + + +def wrapped_original(server: dict[str, Any]) -> tuple[str, list[str]] | None: + """The original command of a wrapped stdio server, or None.""" + args = [str(a) for a in server.get("args") or []] + if not _is_mcphawk(server.get("command"), args): + return None + if WRAP_MARKER not in args or "--" not in args: + return None + rest = args[args.index("--") + 1:] + return (rest[0], rest[1:]) if rest else None + + +def _url_key(server: dict[str, Any]) -> str | None: + for key in ("url", "serverUrl"): + if isinstance(server.get(key), str): + return key + return None + + +def proxy_url(port: int, name: str) -> str: + return f"http://127.0.0.1:{port}/p/{name}" + + +def _proxied_name(url: str, port: int) -> str | None: + prefix = f"http://127.0.0.1:{port}/p/" + return url[len(prefix):].split("/")[0] if url.startswith(prefix) else None + + +# -- planning ------------------------------------------------------------- + +def _plan_install(table: cl.ServerTable, name: str, server: dict[str, Any], *, + command: list[str], include_http: bool, force_http: bool, port: int, + registry: dict[str, str]) -> Change: + change = Change(table, name, SKIP, before=dict(server)) + url_key = _url_key(server) + if server.get("disabled"): + change.reason = "disabled" + elif url_key: + url = server[url_key] + if _proxied_name(url, port): + change.reason = "already proxied" + elif not include_http: + change.reason = "HTTP server (use --include-http)" + elif (not force_http and urlsplit(url).hostname not in LOCAL_HOSTS + and not server.get("headers")): + change.reason = "remote server without static headers, probably OAuth (--force-http)" + else: + proxy_name = _unique_name(name, url, registry) + registry[proxy_name] = url + change.action = PROXY + change.after = {**server, url_key: proxy_url(port, proxy_name)} + elif isinstance(server.get("command"), str): + args = [str(a) for a in server.get("args") or []] + if wrapped_original(server): + change.reason = "already wrapped" + elif _is_mcphawk(server["command"], args): + change.reason = "this is MCPHawk itself" + else: + change.action = WRAP + change.after = { + **server, + "command": command[0], + "args": [*command[1:], WRAP_MARKER, "--name", name, "--", + server["command"], *args], + } + else: + change.reason = "unrecognised server entry" + return change + + +def _unique_name(name: str, url: str, registry: dict[str, str]) -> str: + safe = "".join(c if c.isalnum() or c in "-_." else "-" for c in name) or "server" + candidate, n = safe, 2 + while registry.get(candidate) not in (None, url): + candidate, n = f"{safe}-{n}", n + 1 + return candidate + + +def _plan_uninstall(table: cl.ServerTable, name: str, server: dict[str, Any], *, + port: int, registry: dict[str, str]) -> Change: + change = Change(table, name, SKIP, before=dict(server)) + original = wrapped_original(server) + url_key = _url_key(server) + if original: + command, args = original + change.action = UNWRAP + change.after = {**server, "command": command, "args": args} + elif url_key and _proxied_name(server[url_key], port): + proxy_name = _proxied_name(server[url_key], port) + if proxy_name in registry: + change.action = UNPROXY + change.after = {**server, url_key: registry[proxy_name]} + else: + change.reason = f"proxy {proxy_name!r} missing from registry" + else: + change.reason = "not routed through MCPHawk" + return change + + +def _read(path: Path) -> tuple[dict[str, Any] | None, str]: + try: + text = path.read_text() + except OSError: + return None, "" + try: + data = cl.loads_jsonc(text) + except json.JSONDecodeError: + return None, text + return (data if isinstance(data, dict) else None), text + + +def plan( + *, + uninstall: bool = False, + clients: tuple[str, ...] = cl.ALL_CLIENTS, + home: Path | None = None, + project: Path | None = None, + include_http: bool = False, + force_http: bool = False, + port: int = DEFAULT_PORT, + command: list[str] | None = None, + registry: dict[str, str] | None = None, +) -> list[Change]: + home = home or Path.home() + command = command or mcphawk_command() + registry = registry if registry is not None else load_registry() + changes = [] + for client in clients: + for scope, path in cl.config_files(client, home, project): + data, _ = _read(path) + if data is None: + continue + for table in cl.server_tables(client, data, scope, path): + for name, server in cl.resolve(data, table.keys).items(): + if not isinstance(server, dict): + continue + if uninstall: + changes.append(_plan_uninstall( + table, name, server, port=port, registry=registry)) + else: + changes.append(_plan_install( + table, name, server, command=command, + include_http=include_http or force_http, + force_http=force_http, port=port, registry=registry)) + return changes + + +# -- applying ------------------------------------------------------------- + +def backup(path: Path) -> Path: + folder = data_dir() / "backups" + folder.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + parent = path.parent.name.strip(".") or "root" + target = folder / f"{stamp}-{parent}-{path.name}" + n = 1 + while target.exists(): + target = folder / f"{stamp}-{parent}-{n}-{path.name}" + n += 1 + shutil.copy2(path, target) + return target + + +def apply(changes: list[Change], registry: dict[str, str] | None = None) -> list[str]: + """Write the planned changes. Returns human-readable notes.""" + notes = [] + effective = [c for c in changes if c.after is not None] + by_file: dict[Path, list[Change]] = {} + for change in effective: + by_file.setdefault(change.table.path, []).append(change) + for path, file_changes in by_file.items(): + data, text = _read(path) + if data is None: + notes.append(f"skipped {path}: could not parse it") + continue + if cl.has_comments(text): + notes.append(f"{path}: comments are not preserved when rewriting") + saved = backup(path) + for change in file_changes: + cl.resolve(data, change.table.keys)[change.server] = change.after + tmp = path.with_suffix(path.suffix + ".mcphawk-tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") + shutil.copymode(path, tmp) + os.replace(tmp, path) + notes.append(f"updated {path} (backup: {saved})") + if registry is not None and any(c.action == PROXY for c in effective): + save_registry(registry) + return notes diff --git a/mcphawk/links.py b/mcphawk/links.py new file mode 100644 index 0000000..b9d023a --- /dev/null +++ b/mcphawk/links.py @@ -0,0 +1,27 @@ +"""Stable web UI URLs, handed out by the MCP server so agents can point +people at exactly what they are talking about.""" + +import os +from urllib.parse import quote + +DEFAULT_URL = "http://127.0.0.1:8484" + + +def base_url() -> str: + return os.environ.get("MCPHAWK_URL", DEFAULT_URL).rstrip("/") + + +def session_url(session_id: str) -> str: + return f"{base_url()}/s/{session_id}" + + +def exchange_url(exchange_id: int) -> str: + return f"{base_url()}/x/{exchange_id}" + + +def run_url(run_key: str) -> str: + return f"{base_url()}/r/{quote(run_key, safe='')}" + + +def compare_url(before: str, after: str) -> str: + return f"{base_url()}/compare?before={before}&after={after}" diff --git a/mcphawk/logger.py b/mcphawk/logger.py deleted file mode 100644 index c8679d0..0000000 --- a/mcphawk/logger.py +++ /dev/null @@ -1,445 +0,0 @@ -import logging -import sqlite3 -from collections.abc import Generator -from contextlib import contextmanager -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -# Set up logger for this module -logger = logging.getLogger(__name__) - -# Database file (shared with tests) -DB_FILE = "mcphawk_logs.db" -_DEFAULT_DB_PATH = Path(__file__).resolve().parent.parent / DB_FILE -DB_PATH = _DEFAULT_DB_PATH - -# Module initialization flag -_db_initialized = False - - -@contextmanager -def get_db_connection(db_path: Path | None = None) -> Generator[sqlite3.Connection, None, None]: - """ - Context manager for SQLite database connections. - - Args: - db_path: Optional path to database. Uses DB_PATH if not provided. - - Yields: - sqlite3.Connection: Database connection with row_factory set. - - Example: - with get_db_connection() as conn: - cur = conn.cursor() - cur.execute("SELECT * FROM logs") - """ - path = db_path or DB_PATH - if not path: - raise ValueError("No database path provided") - - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - try: - yield conn - finally: - conn.close() - - -def init_db() -> None: - """ - Initialize the SQLite database and ensure the logs table exists. - """ - logger.debug(f"init_db: Using DB_PATH = {DB_PATH}") - if not DB_PATH or not str(DB_PATH).strip(): - raise ValueError("DB_PATH is not set or is empty") - - with get_db_connection() as conn: - cur = conn.cursor() - cur.execute( - """ - CREATE TABLE IF NOT EXISTS logs ( - log_id TEXT PRIMARY KEY, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - src_ip TEXT, - dst_ip TEXT, - src_port INTEGER, - dst_port INTEGER, - direction TEXT CHECK(direction IN ('incoming', 'outgoing', 'unknown')), - message TEXT, - transport_type TEXT, - metadata TEXT, - pid INTEGER - ) - """ - ) - conn.commit() - - -def log_message(entry: dict[str, Any]) -> None: - """ - Insert a new log entry. - - Args: - entry (Dict[str, Any]): Must contain MCPMessageLog fields: - log_id (str) - UUID for the log entry - timestamp (datetime) - If missing, current time is used - src_ip (str) - dst_ip (str) - src_port (int) - dst_port (int) - direction (str): 'incoming', 'outgoing', or 'unknown' - message (str) - transport_type (str): 'streamable_http', 'http_sse', 'stdio', or 'unknown' (optional, defaults to 'unknown') - metadata (str): JSON string with additional metadata (optional) - pid (int): Process ID for stdio transport (optional) - """ - timestamp = entry.get("timestamp", datetime.now(tz=timezone.utc)) - log_id = entry.get("log_id") - if not log_id: - raise ValueError("log_id is required") - - with get_db_connection() as conn: - cur = conn.cursor() - cur.execute( - """ - INSERT INTO logs (log_id, timestamp, src_ip, dst_ip, src_port, dst_port, direction, message, transport_type, metadata, pid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - log_id, - timestamp.isoformat(), - entry.get("src_ip"), - entry.get("dst_ip"), - entry.get("src_port"), - entry.get("dst_port"), - entry.get("direction", "unknown"), - entry.get("message"), - entry.get("transport_type", "unknown"), - entry.get("metadata"), - entry.get("pid"), - ), - ) - conn.commit() - - -def fetch_logs(limit: int = 100) -> list[dict[str, Any]]: - """ - Fetch the latest logs. - - Args: - limit (int): Max number of logs to retrieve. - - Returns: - List of dictionaries matching MCPMessageLog format. - """ - # Ensure we're using the correct path - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - logger.debug(f"fetch_logs: Using DB_PATH = {current_path}, exists = {current_path.exists() if current_path else False}") - - # If the database doesn't exist, it might have been deleted or path changed - if not current_path.exists(): - logger.warning(f"Database file not found at {current_path}") - return [] - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - cur.execute( - """ - SELECT log_id, timestamp, src_ip, dst_ip, src_port, dst_port, direction, message, transport_type, metadata, pid - FROM logs - ORDER BY timestamp DESC - LIMIT ? - """, - (limit,), - ) - rows = cur.fetchall() - - return [ - { - "log_id": row["log_id"], - "timestamp": datetime.fromisoformat(row["timestamp"]), - "src_ip": row["src_ip"], - "dst_ip": row["dst_ip"], - "src_port": row["src_port"], - "dst_port": row["dst_port"], - "direction": row["direction"], - "message": row["message"], - "transport_type": row["transport_type"] if row["transport_type"] is not None else "unknown", - "metadata": row["metadata"], - "pid": row["pid"], - } - for row in rows - ] - - -# Add this near the top of logger.py (after DB_PATH definition) -def set_db_path(path: str) -> None: - """ - Override the default DB path (used in tests). - """ - global DB_PATH, _db_initialized - if path: - DB_PATH = Path(path) - _db_initialized = False # Reset initialization flag when path changes - logger.debug(f"set_db_path: Changed DB_PATH to {DB_PATH}") - else: - logger.warning(f"set_db_path: Ignoring empty path, keeping DB_PATH as {DB_PATH}") - - -def clear_logs() -> None: - """ - Clear all logs from the database. - Mainly used in tests. - """ - with get_db_connection() as conn: - cur = conn.cursor() - cur.execute("DELETE FROM logs;") - conn.commit() - - -def get_log_by_id(log_id: str) -> dict[str, Any] | None: - """ - Fetch a specific log entry by ID. - - Args: - log_id (str): The UUID of the log entry to retrieve. - - Returns: - Dictionary matching MCPMessageLog format or None if not found. - """ - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - if not current_path.exists(): - return None - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - cur.execute( - """ - SELECT log_id, timestamp, src_ip, dst_ip, src_port, dst_port, direction, message, transport_type, metadata, pid - FROM logs - WHERE log_id = ? - """, - (log_id,), - ) - row = cur.fetchone() - - if not row: - return None - - return { - "log_id": row["log_id"], - "timestamp": datetime.fromisoformat(row["timestamp"]), - "src_ip": row["src_ip"], - "dst_ip": row["dst_ip"], - "src_port": row["src_port"], - "dst_port": row["dst_port"], - "direction": row["direction"], - "message": row["message"], - "transport_type": row["transport_type"] if row["transport_type"] is not None else "unknown", - "metadata": row["metadata"], - "pid": row["pid"], - } - - -def fetch_logs_with_offset(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: - """ - Fetch logs with limit and offset for pagination. - - Args: - limit: Maximum number of logs to return - offset: Number of logs to skip - - Returns: - List of dictionaries matching MCPMessageLog format. - """ - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - if not current_path.exists(): - return [] - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - cur.execute( - """ - SELECT log_id, timestamp, src_ip, dst_ip, src_port, dst_port, direction, message, transport_type, metadata, pid - FROM logs - ORDER BY log_id DESC - LIMIT ? OFFSET ? - """, - (limit, offset), - ) - rows = cur.fetchall() - - return [ - { - "log_id": row["log_id"], - "timestamp": datetime.fromisoformat(row["timestamp"]), - "src_ip": row["src_ip"], - "dst_ip": row["dst_ip"], - "src_port": row["src_port"], - "dst_port": row["dst_port"], - "direction": row["direction"], - "message": row["message"], - "transport_type": row["transport_type"] if row["transport_type"] is not None else "unknown", - "metadata": row["metadata"], - "pid": row["pid"], - } - for row in rows - ] - - -def search_logs(search_term: str = "", message_type: str | None = None, - transport_type: str | None = None, limit: int = 100) -> list[dict[str, Any]]: - """ - Search logs by various criteria. - - Args: - search_term: Text to search for in messages - message_type: Filter by JSON-RPC message type (request, response, notification) - transport_type: Filter by transport type (streamable_http, http_sse, stdio, unknown) - limit: Maximum number of results - - Returns: - List of matching log entries. - """ - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - if not current_path.exists(): - return [] - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - - query = "SELECT * FROM logs WHERE 1=1" - params = [] - - if search_term: - query += " AND message LIKE ?" - params.append(f"%{search_term}%") - - if transport_type: - query += " AND transport_type = ?" - params.append(transport_type) - - query += " ORDER BY log_id DESC LIMIT ?" - params.append(limit) - - cur.execute(query, params) - rows = cur.fetchall() - - # Filter by message type if specified - results = [] - for row in rows: - log_dict = { - "log_id": row["log_id"], - "timestamp": datetime.fromisoformat(row["timestamp"]), - "src_ip": row["src_ip"], - "dst_ip": row["dst_ip"], - "src_port": row["src_port"], - "dst_port": row["dst_port"], - "direction": row["direction"], - "message": row["message"], - "transport_type": row["transport_type"] if row["transport_type"] is not None else "unknown", - "metadata": row["metadata"], - "pid": row["pid"], - } - - # If message_type filter is specified, check it - if message_type: - from .utils import get_message_type - if get_message_type(row["message"]) != message_type: - continue - - results.append(log_dict) - - return results - - -def get_traffic_stats() -> dict[str, Any]: - """ - Get statistics about captured traffic. - - Returns: - Dictionary with traffic statistics. - """ - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - if not current_path.exists(): - return { - "total_logs": 0, - "requests": 0, - "responses": 0, - "notifications": 0, - "errors": 0, - "by_transport_type": {} - } - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - - # Get all messages for analysis - cur.execute("SELECT message, transport_type FROM logs") - logs = cur.fetchall() - - stats = { - "total_logs": len(logs), - "requests": 0, - "responses": 0, - "notifications": 0, - "errors": 0, - "by_transport_type": {} - } - - from .utils import get_message_type - - for message, transport_type in logs: - # Count by message type - msg_type = get_message_type(message) - if msg_type == "request": - stats["requests"] += 1 - elif msg_type == "response": - stats["responses"] += 1 - elif msg_type == "notification": - stats["notifications"] += 1 - - # Check for errors - try: - import json - msg_data = json.loads(message) - if "error" in msg_data: - stats["errors"] += 1 - except Exception: - pass - - # Count by transport type - if transport_type: - stats["by_transport_type"][transport_type] = stats["by_transport_type"].get(transport_type, 0) + 1 - - return stats - - -def get_unique_methods() -> list[str]: - """ - Get all unique JSON-RPC methods from captured traffic. - - Returns: - Sorted list of unique method names. - """ - current_path = DB_PATH if DB_PATH else _DEFAULT_DB_PATH - if not current_path.exists(): - return [] - - with get_db_connection(current_path) as conn: - cur = conn.cursor() - cur.execute("SELECT message FROM logs") - logs = cur.fetchall() - - methods = set() - for (message,) in logs: - try: - import json - msg_data = json.loads(message) - if "method" in msg_data: - methods.add(msg_data["method"]) - except Exception: - pass - - return sorted(methods) diff --git a/mcphawk/mcp_server.py b/mcphawk/mcp_server.py new file mode 100644 index 0000000..657b1d7 --- /dev/null +++ b/mcphawk/mcp_server.py @@ -0,0 +1,159 @@ +"""MCPHawk's own MCP server: lets an agent debug with the same data you see. + +Results are compact summaries with deep links into the web UI; full payloads +are only returned when asked for one message at a time, and always capped. +Everything is read-only. +""" + +import json +from pathlib import Path +from typing import Any + +from mcp.server.mcpserver import MCPServer + +from mcphawk import links +from mcphawk.analysis import cost, drift, problems +from mcphawk.query import Query + +DEFAULT_MAX_CHARS = 6000 +HARD_MAX_CHARS = 40_000 + +INSTRUCTIONS = """\ +MCPHawk records real traffic between MCP clients and servers. Use it to see +what tools were actually called, what failed, and what each server costs in +context. Start with list_sessions or find_problems; drill into an exchange +with get_exchange. Every result links to the MCPHawk web UI - share the link +when pointing the user at something. +""" + + +def _dump(value: Any, max_chars: int) -> str: + text = json.dumps(value, indent=1, ensure_ascii=False, default=str) + if len(text) <= max_chars: + return text + return text[:max_chars] + f"\n… truncated ({len(text) - max_chars} more chars)" + + +def _session_row(session: dict[str, Any]) -> dict[str, Any]: + return { + "id": session["id"], + "server": session["display_name"], + "client": session.get("client_name") or session.get("client_app"), + "transport": session["transport"], + "protocol": session.get("protocol_version"), + "live": session["live"], + "exchanges": session.get("exchange_count"), + "errors": session.get("error_count"), + "url": links.session_url(session["id"]), + } + + +def _exchange_row(exchange: dict[str, Any]) -> dict[str, Any]: + row = { + "id": exchange["id"], + "call": exchange["method"] + (f" {exchange['target']}" if exchange["target"] else ""), + "status": exchange["status"], + "ms": round(exchange["duration_ms"]) if exchange["duration_ms"] is not None else None, + "result_tokens": exchange["response_tokens"], + } + if exchange.get("error_message"): + row["error"] = exchange["error_message"][:200] + return row + + +def build_server(db: Path | str | None = None) -> MCPServer: + q = Query(db) + server = MCPServer("mcphawk", instructions=INSTRUCTIONS, version="1.0.0") + + @server.tool() + def list_sessions(server_name: str | None = None, run_key: str | None = None, + limit: int = 20) -> str: + """Recent captured sessions (one per client<->server connection), newest first.""" + sessions = q.list_sessions(server=server_name, run_key=run_key, limit=min(limit, 100)) + return _dump([_session_row(s) for s in sessions], DEFAULT_MAX_CHARS) + + @server.tool() + def get_session(session_id: str, limit: int = 50) -> str: + """One session: who talked to whom, and its most recent calls with status and timing.""" + session = q.get_session(session_id) + if not session: + return f"No session {session_id!r}." + exchanges = session["exchanges"][-min(limit, 200):] + report = { + **_session_row({**session, + "exchange_count": len(session["exchanges"]), + "error_count": sum(e["status"] in ("error", "tool_error") + for e in session["exchanges"])}), + "server_version": session.get("server_version"), + "era": session.get("era"), + "shown": f"last {len(exchanges)} of {len(session['exchanges'])} exchanges", + "exchanges": [_exchange_row(e) for e in exchanges], + } + return _dump(report, DEFAULT_MAX_CHARS) + + @server.tool() + def get_exchange(exchange_id: int, max_chars: int = DEFAULT_MAX_CHARS) -> str: + """Full request and response of one call (truncated to max_chars).""" + exchange = q.get_exchange(exchange_id) + if not exchange: + return f"No exchange {exchange_id}." + report = { + **_exchange_row(exchange), + "server": exchange["session"]["display_name"] if exchange["session"] else None, + "session_id": exchange["session_id"], + "url": links.exchange_url(exchange_id), + "request": (exchange["request"] or {}).get("body"), + "response": (exchange["response"] or {}).get("body"), + } + if exchange["chain"]: + report["round_trips"] = [_exchange_row(e) for e in exchange["chain"]] + return _dump(report, max(500, min(max_chars, HARD_MAX_CHARS))) + + @server.tool() + def find_problems(session_id: str | None = None, run_key: str | None = None, + server_name: str | None = None, min_severity: str = "warning", + limit: int = 20) -> str: + """Errors, tool failures, hung or slow calls, repeated identical calls and spec + violations, most severe first. min_severity: error | warning | info.""" + if min_severity not in ("error", "warning", "info"): + return "min_severity must be error, warning or info." + report = problems.find_problems( + q, session_id=session_id, run_key=run_key, server=server_name, + min_severity=min_severity, limit=min(limit, 100)) + for problem in report["problems"]: + problem.pop("at", None) + if problem.get("exchange_id"): + problem["url"] = links.exchange_url(problem["exchange_id"]) + return _dump(report, DEFAULT_MAX_CHARS) + + @server.tool() + def context_cost(session_id: str | None = None, run_key: str | None = None, + server_name: str | None = None) -> str: + """Estimated tokens each server adds to the model's context: tool definitions on + every turn, and tool results when called. Includes concrete findings.""" + report = cost.context_cost(q, session_id=session_id, run_key=run_key, + server=server_name) + for entry in report["servers"]: + entry.pop("session_ids", None) + entry["definitions"] = entry["definitions"][:15] + entry["calls"] = entry["calls"][:15] + return _dump(report, DEFAULT_MAX_CHARS) + + @server.tool() + def compare_sessions(before_session_id: str | None = None, + after_session_id: str | None = None, + server_name: str | None = None) -> str: + """What changed between two sessions: tool definitions (added, removed, changed, + token delta) and per-call behaviour. Give two session ids, or just server_name + to compare its two most recent sessions.""" + if before_session_id and after_session_id: + report = drift.compare_sessions(q, before_session_id, after_session_id) + elif server_name: + report = drift.latest_drift(q, server_name) + else: + return "Give before_session_id and after_session_id, or server_name." + if "error" not in report: + report["url"] = links.compare_url(report["before"]["id"], report["after"]["id"]) + return _dump(report, DEFAULT_MAX_CHARS) + + return server diff --git a/mcphawk/mcp_server/__init__.py b/mcphawk/mcp_server/__init__.py deleted file mode 100644 index 6e3a591..0000000 --- a/mcphawk/mcp_server/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""MCPHawk MCP Server - Query and analyze captured MCP traffic.""" diff --git a/mcphawk/mcp_server/server.py b/mcphawk/mcp_server/server.py deleted file mode 100644 index 37718e0..0000000 --- a/mcphawk/mcp_server/server.py +++ /dev/null @@ -1,121 +0,0 @@ -"""MCP server implementation using SDK's built-in HTTP transport.""" - -import json -import logging -from typing import Optional - -from mcp.server.fastmcp import FastMCP - -from .. import logger as mcphawk_logger - -# Set up logging -logger = logging.getLogger(__name__) - - -class MCPHawkServer: - """MCP server using SDK's HTTP transport.""" - - def __init__(self, db_path: Optional[str] = None, host: str = "127.0.0.1", port: int = 8765): - # Store configuration - self.http_host = host - self.http_port = port - - # FastMCP accepts host and port in constructor - self.mcp = FastMCP("mcphawk-mcp", host=host, port=port) - if db_path: - mcphawk_logger.set_db_path(db_path) - self._setup_handlers() - - def _setup_handlers(self): - """Setup MCP protocol handlers.""" - - @self.mcp.tool() - async def query_traffic(limit: int = 100, offset: int = 0) -> str: - """Query captured MCP traffic with optional limit and offset.""" - logs = mcphawk_logger.fetch_logs_with_offset(limit=limit, offset=offset) - - # Convert timestamps to ISO format for JSON serialization - for log in logs: - if log.get("timestamp"): - log["timestamp"] = log["timestamp"].isoformat() - - return json.dumps(logs, indent=2) - - @self.mcp.tool() - async def get_log(log_id: str) -> str: - """Get a specific log entry by ID.""" - log = mcphawk_logger.get_log_by_id(log_id) - - if not log: - return f"No log found with ID: {log_id}" - - # Convert timestamp to ISO format - if log.get("timestamp"): - log["timestamp"] = log["timestamp"].isoformat() - - return json.dumps(log, indent=2) - - @self.mcp.tool() - async def search_traffic( - search_term: str = "", - message_type: Optional[str] = None, - transport_type: Optional[str] = None, - limit: int = 100 - ) -> str: - """Search traffic by message content or type. - - Args: - search_term: Term to search for in message content - message_type: Filter by message type (request, response, notification) - transport_type: Filter by transport type (streamable_http/http_sse/stdio/unknown) - limit: Maximum number of results - """ - logs = mcphawk_logger.search_logs( - search_term=search_term, - message_type=message_type, - transport_type=transport_type, - limit=limit - ) - - # Convert timestamps to ISO format for JSON serialization - for log in logs: - if log.get("timestamp"): - log["timestamp"] = log["timestamp"].isoformat() - - return json.dumps(logs, indent=2) - - @self.mcp.tool() - async def get_stats() -> str: - """Get statistics about captured traffic.""" - stats = mcphawk_logger.get_traffic_stats() - return json.dumps(stats, indent=2) - - @self.mcp.tool() - async def list_methods() -> str: - """List all unique JSON-RPC methods seen in traffic.""" - methods = mcphawk_logger.get_unique_methods() - - result = { - "methods": methods, - "count": len(methods) - } - - return json.dumps(result, indent=2) - - async def run_stdio(self): - """Run the MCP server using stdio transport.""" - await self.mcp.run_stdio_async() - - async def run_http(self, host: str = "127.0.0.1", port: int = 8765): - """Run the MCP server using SDK's Streamable HTTP transport.""" - # If different host/port specified, we need to recreate FastMCP - if host != self.http_host or port != self.http_port: - self.http_host = host - self.http_port = port - # Recreate FastMCP with new settings - self.mcp = FastMCP("mcphawk-mcp", host=host, port=port) - self._setup_handlers() - - # The SDK handles all the HTTP server setup internally - await self.mcp.run_streamable_http_async() - diff --git a/mcphawk/models.py b/mcphawk/models.py deleted file mode 100644 index d0211c1..0000000 --- a/mcphawk/models.py +++ /dev/null @@ -1,14 +0,0 @@ -from datetime import datetime -from typing import Literal, TypedDict - -Direction = Literal["incoming", "outgoing", "unknown"] - - -class MCPMessageLog(TypedDict): - timestamp: datetime - src_ip: str - dst_ip: str - src_port: int - dst_port: int - direction: Direction - message: str diff --git a/mcphawk/paths.py b/mcphawk/paths.py new file mode 100644 index 0000000..88abf71 --- /dev/null +++ b/mcphawk/paths.py @@ -0,0 +1,25 @@ +"""Filesystem locations used by MCPHawk.""" + +import os +from pathlib import Path + + +def data_dir() -> Path: + """Directory holding the database, backups and proxy registry. + + Overridable with ``MCPHAWK_HOME``; defaults to ``~/.mcphawk``. + """ + override = os.environ.get("MCPHAWK_HOME") + path = Path(override).expanduser() if override else Path.home() / ".mcphawk" + path.mkdir(parents=True, exist_ok=True) + return path + + +def db_path() -> Path: + """Path of the capture database (``MCPHAWK_DB`` overrides it).""" + override = os.environ.get("MCPHAWK_DB") + if override: + path = Path(override).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + return path + return data_dir() / "mcphawk.db" diff --git a/mcphawk/protocol/__init__.py b/mcphawk/protocol/__init__.py new file mode 100644 index 0000000..03f4d51 --- /dev/null +++ b/mcphawk/protocol/__init__.py @@ -0,0 +1 @@ +"""Protocol knowledge: JSON-RPC framing and MCP semantics across eras.""" diff --git a/mcphawk/protocol/jsonrpc.py b/mcphawk/protocol/jsonrpc.py new file mode 100644 index 0000000..53c8d6e --- /dev/null +++ b/mcphawk/protocol/jsonrpc.py @@ -0,0 +1,49 @@ +"""JSON-RPC 2.0 framing helpers.""" + +import json +from typing import Any + +REQUEST = "request" +NOTIFICATION = "notification" +RESPONSE = "response" +ERROR = "error" +INVALID = "invalid" + + +def parse_frame(text: str) -> list[dict[str, Any]] | None: + """Parse one wire frame into a list of JSON-RPC messages. + + Returns ``None`` when the frame is not JSON at all, and a list (one entry + per message, batches flattened) otherwise. Non-object batch members are + kept as ``{}`` so they surface as invalid rather than disappearing. + """ + try: + data = json.loads(text) + except (json.JSONDecodeError, TypeError): + return None + if isinstance(data, list): + return [m if isinstance(m, dict) else {} for m in data] + if isinstance(data, dict): + return [data] + return None + + +def classify(msg: dict[str, Any]) -> str: + """Classify a single JSON-RPC message.""" + if msg.get("jsonrpc") != "2.0": + return INVALID + has_id = "id" in msg and msg["id"] is not None + if isinstance(msg.get("method"), str): + return REQUEST if has_id else NOTIFICATION + if "error" in msg and isinstance(msg["error"], dict): + return ERROR + if "result" in msg and has_id: + return RESPONSE + return INVALID + + +def id_key(rpc_id: Any) -> str | None: + """Stable string form of a JSON-RPC id (``1`` and ``"1"`` stay distinct).""" + if rpc_id is None: + return None + return json.dumps(rpc_id, sort_keys=True) diff --git a/mcphawk/protocol/masking.py b/mcphawk/protocol/masking.py new file mode 100644 index 0000000..1cb25ee --- /dev/null +++ b/mcphawk/protocol/masking.py @@ -0,0 +1,94 @@ +"""Secret masking applied before anything is written to disk. + +Captured traffic is shown to people and, through the MCPHawk MCP server, to +agents; credentials in it should not travel further than the wire they were +seen on. Masking is on by default and can be disabled with ``--no-mask``. +""" + +import re +from typing import Any + +MASK = "••••" + +_SECRET_KEY = re.compile( + r"^(.*[_\-.])?(" + r"token|access[_-]?token|refresh[_-]?token|id[_-]?token|secret|client[_-]?secret|" + r"password|passwd|pwd|api[_-]?key|apikey|authorization|auth|cookie|" + r"credentials?|private[_-]?key|session[_-]?key|signature" + r")$", + re.IGNORECASE, +) + +_SECRET_HEADERS = frozenset({ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "x-auth-token", +}) + +_SECRET_PATTERNS = [ + re.compile(r"\b(sk|pk|rk)-[A-Za-z0-9_\-]{16,}"), # OpenAI/Anthropic/Stripe style + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}"), # GitHub + re.compile(r"\bgithub_pat_[A-Za-z0-9_]{40,}"), + re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}"), # Slack + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id + re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), # Google API key + re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}"), # JWT + re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/\-]{12,}=*"), + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"), +] + + +def mask_text(text: str) -> str: + for pattern in _SECRET_PATTERNS: + text = pattern.sub(MASK, text) + return text + + +def mask_value(value: Any) -> Any: + """Recursively mask secret-looking keys and token patterns in JSON data.""" + if isinstance(value, dict): + masked = {} + for key, item in value.items(): + if isinstance(item, str) and item and _SECRET_KEY.match(str(key)): + masked[key] = MASK + else: + masked[key] = mask_value(item) + return masked + if isinstance(value, list): + return [mask_value(item) for item in value] + if isinstance(value, str): + return mask_text(value) + return value + + +def mask_headers(headers: dict[str, str]) -> dict[str, str]: + return { + name: MASK if name.lower() in _SECRET_HEADERS else mask_text(value) + for name, value in headers.items() + } + + +def mask_argv(argv: list[str]) -> list[str]: + """Mask ``--token=x`` / ``--api-key x`` style arguments in a command line.""" + masked: list[str] = [] + hide_next = False + for arg in argv: + if hide_next: + masked.append(MASK) + hide_next = False + continue + if arg.startswith("-"): + name, eq, _ = arg.lstrip("-").partition("=") + if _SECRET_KEY.match(name): + if eq: + masked.append(arg.split("=", 1)[0] + "=" + MASK) + else: + masked.append(arg) + hide_next = True + continue + masked.append(mask_text(arg)) + return masked diff --git a/mcphawk/protocol/mcp.py b/mcphawk/protocol/mcp.py new file mode 100644 index 0000000..b81eff4 --- /dev/null +++ b/mcphawk/protocol/mcp.py @@ -0,0 +1,207 @@ +"""MCP semantics on top of JSON-RPC, covering both protocol eras. + +* **legacy**: 2024-11-05 through 2025-11-25, stateful ``initialize`` handshake. +* **modern**: 2026-07-28+, stateless; identity and version travel in ``_meta``. +""" + +from typing import Any + +LEGACY = "legacy" +MODERN = "modern" + +MODERN_VERSIONS = ("2026-07-28",) +LEGACY_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25") + +META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" +META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" +META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" +META_LOG_LEVEL = "io.modelcontextprotocol/logLevel" +META_SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId" + +RESULT_COMPLETE = "complete" +RESULT_INPUT_REQUIRED = "input_required" + +# Methods whose params carry a name that the 2026 transport mirrors into the +# ``Mcp-Name`` header; also what we show as the "target" of an exchange. +NAME_FIELDS = { + "tools/call": "name", + "prompts/get": "name", + "resources/read": "uri", + "resources/subscribe": "uri", + "resources/unsubscribe": "uri", +} + +# Results that must carry ttlMs/cacheScope from 2026-07-28 on. +CACHEABLE_METHODS = frozenset({ + "tools/list", + "prompts/list", + "resources/list", + "resources/read", + "resources/templates/list", + "server/discover", +}) + +REMOVED_IN_MODERN = frozenset({ + "initialize", + "notifications/initialized", + "ping", + "logging/setLevel", + "notifications/roots/list_changed", + "resources/subscribe", + "resources/unsubscribe", + "notifications/elicitation/complete", + "tasks/result", + "tasks/list", +}) + +# Server-to-client requests replaced by multi round-trip requests. +REPLACED_BY_MRTR = frozenset({ + "roots/list", + "sampling/createMessage", + "elicitation/create", +}) + +DEPRECATED_FEATURES = { + "roots/list": "Roots", + "notifications/roots/list_changed": "Roots", + "sampling/createMessage": "Sampling", + "logging/setLevel": "Logging", + "notifications/message": "Logging", +} + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _meta(container: Any) -> dict[str, Any]: + return _dict(_dict(container).get("_meta")) + + +def _impl(value: Any) -> dict[str, str] | None: + info = _dict(value) + name = info.get("name") + if not isinstance(name, str) or not name: + return None + version = info.get("version") + return {"name": name, "version": str(version) if version is not None else ""} + + +def era_of_version(version: str | None) -> str | None: + if not version: + return None + if version in MODERN_VERSIONS or version > LEGACY_VERSIONS[-1]: + return MODERN + return LEGACY + + +def target_of(msg: dict[str, Any]) -> str | None: + """Tool name, prompt name or resource URI a request is about.""" + field = NAME_FIELDS.get(str(msg.get("method"))) + if not field: + return None + value = _dict(msg.get("params")).get(field) + return str(value) if value is not None else None + + +def protocol_version(msg: dict[str, Any]) -> str | None: + """Protocol version declared by a request, in either era.""" + params = _dict(msg.get("params")) + modern = _meta(params).get(META_PROTOCOL_VERSION) + if isinstance(modern, str): + return modern + if msg.get("method") == "initialize" and isinstance(params.get("protocolVersion"), str): + return params["protocolVersion"] + return None + + +def negotiated_version(msg: dict[str, Any]) -> str | None: + """Version a server settled on in an ``initialize`` response.""" + version = _dict(msg.get("result")).get("protocolVersion") + return version if isinstance(version, str) else None + + +def client_info(msg: dict[str, Any]) -> dict[str, str] | None: + """Client identity from a request (``initialize`` or modern ``_meta``).""" + params = _dict(msg.get("params")) + modern = _impl(_meta(params).get(META_CLIENT_INFO)) + if modern: + return modern + if msg.get("method") == "initialize": + return _impl(params.get("clientInfo")) + return None + + +def server_info(msg: dict[str, Any], request_method: str | None = None) -> dict[str, str] | None: + """Server identity from a response. + + Legacy servers identify themselves once in the ``initialize`` result; + modern ones SHOULD stamp every result's ``_meta`` and MUST answer + ``server/discover``. + """ + result = _dict(msg.get("result")) + modern = _impl(_meta(result).get(META_SERVER_INFO)) + if modern: + return modern + if request_method in ("initialize", "server/discover") or "serverInfo" in result: + return _impl(result.get("serverInfo")) + return None + + +def result_type(msg: dict[str, Any]) -> str: + """``resultType`` of a response; absent means complete (spec §MRTR).""" + value = _dict(msg.get("result")).get("resultType") + return value if isinstance(value, str) else RESULT_COMPLETE + + +def request_state(msg: dict[str, Any]) -> str | None: + """Opaque ``requestState`` from an input_required result or a retry.""" + for holder in (msg.get("result"), msg.get("params")): + value = _dict(holder).get("requestState") + if isinstance(value, str): + return value + return None + + +def input_requests(msg: dict[str, Any]) -> dict[str, Any]: + return _dict(_dict(msg.get("result")).get("inputRequests")) + + +def is_tool_error(msg: dict[str, Any]) -> bool: + """A ``tools/call`` that returned normally but reported ``isError``.""" + return bool(_dict(msg.get("result")).get("isError")) + + +def error_of(msg: dict[str, Any]) -> tuple[int | None, str | None]: + error = _dict(msg.get("error")) + code = error.get("code") + message = error.get("message") + return ( + code if isinstance(code, int) else None, + str(message) if message is not None else None, + ) + + +def tool_error_text(msg: dict[str, Any]) -> str | None: + """First text block of an ``isError`` tool result.""" + for block in _dict(msg.get("result")).get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return str(block.get("text", ""))[:500] + return None + + +def tools_from_list(msg: dict[str, Any]) -> list[dict[str, Any]]: + tools = _dict(msg.get("result")).get("tools") + return [t for t in tools if isinstance(t, dict)] if isinstance(tools, list) else [] + + +def instructions_of(msg: dict[str, Any]) -> str | None: + value = _dict(msg.get("result")).get("instructions") + return value if isinstance(value, str) else None + + +def trace_parent(msg: dict[str, Any]) -> str | None: + """W3C ``traceparent`` propagated in ``_meta`` (SEP-414).""" + value = _meta(msg.get("params")).get("traceparent") + return value if isinstance(value, str) else None diff --git a/mcphawk/protocol/tokens.py b/mcphawk/protocol/tokens.py new file mode 100644 index 0000000..ca61507 --- /dev/null +++ b/mcphawk/protocol/tokens.py @@ -0,0 +1,50 @@ +"""Rough token estimates. + +MCPHawk does not bundle a tokenizer; ~4 characters per token is close enough +for comparing tools against each other and is always labelled as an estimate. +""" + +import json +from typing import Any + +CHARS_PER_TOKEN = 4 +# Flat estimate for a base64 image block; providers bill images by pixels, not +# by payload length, so the raw base64 size would wildly overstate the cost. +IMAGE_TOKENS = 1500 + + +def estimate_text(text: str) -> int: + if not text: + return 0 + return max(1, -(-len(text) // CHARS_PER_TOKEN)) + + +def estimate_json(value: Any) -> int: + if value is None: + return 0 + return estimate_text(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) + + +def estimate_content(blocks: Any) -> int: + """Estimate tokens for MCP content blocks as a model would see them.""" + if not isinstance(blocks, list): + return estimate_json(blocks) + total = 0 + for block in blocks: + if not isinstance(block, dict): + total += estimate_json(block) + continue + kind = block.get("type") + if kind == "text": + total += estimate_text(str(block.get("text", ""))) + elif kind in ("image", "audio"): + total += IMAGE_TOKENS + elif kind == "resource": + resource = block.get("resource") or {} + if isinstance(resource, dict) and "text" in resource: + total += estimate_text(str(resource["text"])) + else: + total += IMAGE_TOKENS + else: + total += estimate_json(block) + return total diff --git a/mcphawk/query.py b/mcphawk/query.py new file mode 100644 index 0000000..ddcc0e3 --- /dev/null +++ b/mcphawk/query.py @@ -0,0 +1,426 @@ +"""Read side of the capture database. + +The web UI and the MCPHawk MCP server are thin views over this module; any +question either of them can answer should be answerable here first. +""" + +import json +import threading +import time +from pathlib import Path +from typing import Any + +import psutil + +from mcphawk.store.db import connect + +HUNG_AFTER_S = 30.0 +IDLE_AFTER_S = 600.0 + +_SESSION_COLUMNS = ( + "id, run_key, name, capture, transport, target, client_app, client_name, " + "client_version, server_name, server_version, protocol_version, era, pid, " + "client_pid, started_at, last_seen_at, ended_at, hidden" +) + +_EXCHANGE_COLUMNS = ( + "e.id, e.session_id, e.initiator, e.method, e.target, e.rpc_id, e.request_msg_id, " + "e.response_msg_id, e.started_at, e.ended_at, e.duration_ms, e.status, e.error_code, " + "e.error_message, e.request_tokens, e.response_tokens, e.parent_id, e.chain_root_id" +) + + +def _pid_alive(pid: int | None) -> bool: + if not pid: + return False + try: + return psutil.pid_exists(pid) + except Exception: # pragma: no cover - platform specific + return False + + +def _loads(text: str | None) -> Any: + if text is None: + return None + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + + +class Query: + """Read-only access to captured traffic.""" + + def __init__(self, db: Path | str | None = None, now=time.time): + self._conn = connect(db) + self._lock = threading.Lock() + self._now = now + + def close(self) -> None: + self._conn.close() + + def _all(self, sql: str, params: tuple | list = ()) -> list[dict[str, Any]]: + with self._lock: + return [dict(row) for row in self._conn.execute(sql, params).fetchall()] + + def _one(self, sql: str, params: tuple | list = ()) -> dict[str, Any] | None: + with self._lock: + row = self._conn.execute(sql, params).fetchone() + return dict(row) if row else None + + # -- liveness --------------------------------------------------------- + + def _is_live(self, session: dict[str, Any]) -> bool: + if session.get("ended_at"): + return False + if session.get("pid") is not None: + return _pid_alive(session["pid"]) + return self._now() - session["last_seen_at"] < IDLE_AFTER_S + + def effective_status(self, exchange: dict[str, Any], live: bool) -> str: + """Stored status, with stale pending calls surfaced as hung/abandoned.""" + if exchange["status"] != "pending": + return exchange["status"] + if not live: + return "abandoned" + if self._now() - exchange["started_at"] > HUNG_AFTER_S: + return "hung" + return "pending" + + def _decorate_session(self, session: dict[str, Any]) -> dict[str, Any]: + session["live"] = self._is_live(session) + session["hidden"] = bool(session["hidden"]) + session["display_name"] = ( + session.get("name") or session.get("server_name") or session.get("target") + or session["id"]) + return session + + def _decorate_exchange(self, exchange: dict[str, Any], live: bool) -> dict[str, Any]: + exchange["status"] = self.effective_status(exchange, live) + if exchange["status"] in ("hung", "pending"): + exchange["duration_ms"] = round((self._now() - exchange["started_at"]) * 1000, 3) + return exchange + + # -- sessions & runs -------------------------------------------------- + + def list_sessions( + self, + *, + run_key: str | None = None, + server: str | None = None, + limit: int = 100, + include_hidden: bool = False, + ) -> list[dict[str, Any]]: + where, params = ["1=1"], [] + if run_key: + where.append("s.run_key = ?") + params.append(run_key) + if server: + where.append("(s.server_name = ? OR s.name = ?)") + params += [server, server] + if not include_hidden: + where.append("s.hidden = 0") + sessions = self._all( + f"""SELECT {", ".join("s." + c.strip() for c in _SESSION_COLUMNS.split(","))}, + COUNT(e.id) AS exchange_count, + SUM(CASE WHEN e.status IN ('error', 'tool_error') THEN 1 ELSE 0 END) + AS error_count, + SUM(CASE WHEN e.status = 'pending' THEN 1 ELSE 0 END) AS pending_count, + COALESCE(SUM(e.request_tokens + e.response_tokens), 0) AS tokens + FROM sessions s LEFT JOIN exchanges e ON e.session_id = s.id + WHERE {" AND ".join(where)} + GROUP BY s.id ORDER BY s.last_seen_at DESC LIMIT ?""", + [*params, limit], + ) + for session in sessions: + self._decorate_session(session) + session["error_count"] = session["error_count"] or 0 + session["pending_count"] = session["pending_count"] or 0 + return sessions + + def get_session(self, session_id: str) -> dict[str, Any] | None: + session = self._one(f"SELECT {_SESSION_COLUMNS} FROM sessions WHERE id = ?", + (session_id,)) + if not session: + return None + self._decorate_session(session) + session["exchanges"] = self.exchanges(session_id=session_id, live=session["live"]) + counts = self._one( + """SELECT COUNT(*) AS messages, + SUM(CASE WHEN kind = 'notification' THEN 1 ELSE 0 END) AS notifications, + SUM(CASE WHEN kind = 'invalid' THEN 1 ELSE 0 END) AS invalid + FROM messages WHERE session_id = ?""", + (session_id,), + ) or {} + session["message_count"] = counts.get("messages") or 0 + session["notification_count"] = counts.get("notifications") or 0 + session["invalid_count"] = counts.get("invalid") or 0 + return session + + def list_runs(self, limit: int = 50, include_hidden: bool = False) -> list[dict[str, Any]]: + """Group sessions by the client process (or client identity) that drove them.""" + hidden = "" if include_hidden else "AND s.hidden = 0" + runs = self._all( + f"""SELECT COALESCE(s.run_key, 'session:' || s.id) AS run_key, + MAX(s.client_app) AS client_app, MAX(s.client_name) AS client_name, + MIN(s.started_at) AS started_at, MAX(s.last_seen_at) AS last_seen_at, + COUNT(DISTINCT s.id) AS session_count, + GROUP_CONCAT(DISTINCT COALESCE(s.name, s.server_name, s.target)) + AS servers + FROM sessions s WHERE 1=1 {hidden} + GROUP BY COALESCE(s.run_key, 'session:' || s.id) + ORDER BY last_seen_at DESC LIMIT ?""", + (limit,), + ) + for run in runs: + run["servers"] = sorted(filter(None, (run["servers"] or "").split(","))) + stats = self._one( + f"""SELECT COUNT(e.id) AS exchanges, + SUM(CASE WHEN e.status IN ('error', 'tool_error') THEN 1 ELSE 0 END) + AS errors, + COALESCE(SUM(e.request_tokens + e.response_tokens), 0) AS tokens + FROM exchanges e JOIN sessions s ON s.id = e.session_id + WHERE {self._run_clause()} {hidden}""", + self._run_params(run["run_key"]), + ) or {} + run["exchange_count"] = stats.get("exchanges") or 0 + run["error_count"] = stats.get("errors") or 0 + run["tokens"] = stats.get("tokens") or 0 + sessions = self._all( + f"SELECT {_SESSION_COLUMNS} FROM sessions s WHERE {self._run_clause()}", + self._run_params(run["run_key"])) + run["live"] = any(self._is_live(s) for s in sessions) + return runs + + @staticmethod + def _run_clause() -> str: + return "(s.run_key = ? OR (s.run_key IS NULL AND 'session:' || s.id = ?))" + + @staticmethod + def _run_params(run_key: str) -> tuple[str, str]: + return (run_key, run_key) + + def get_run(self, run_key: str, include_hidden: bool = False) -> dict[str, Any] | None: + hidden = "" if include_hidden else "AND s.hidden = 0" + sessions = self._all( + f"""SELECT {_SESSION_COLUMNS} FROM sessions s + WHERE {self._run_clause()} {hidden} ORDER BY started_at""", + self._run_params(run_key), + ) + if not sessions: + return None + live_by_session = {} + for session in sessions: + self._decorate_session(session) + live_by_session[session["id"]] = session["live"] + timeline = self._all( + f"""SELECT {_EXCHANGE_COLUMNS} FROM exchanges e + WHERE e.session_id IN ({",".join("?" * len(sessions))}) + ORDER BY e.started_at, e.id""", + [s["id"] for s in sessions], + ) + for exchange in timeline: + self._decorate_exchange(exchange, live_by_session[exchange["session_id"]]) + return { + "run_key": run_key, + "client_app": next((s["client_app"] for s in sessions if s["client_app"]), None), + "client_name": next((s["client_name"] for s in sessions if s["client_name"]), None), + "started_at": sessions[0]["started_at"], + "last_seen_at": max(s["last_seen_at"] for s in sessions), + "live": any(live_by_session.values()), + "sessions": sessions, + "timeline": timeline, + } + + # -- exchanges & messages -------------------------------------------- + + def exchanges( + self, + *, + session_id: str | None = None, + status: str | None = None, + method: str | None = None, + target: str | None = None, + text: str | None = None, + limit: int = 1000, + live: bool | None = None, + include_hidden: bool = False, + ) -> list[dict[str, Any]]: + where, params = ["1=1"], [] + if session_id: + where.append("e.session_id = ?") + params.append(session_id) + if method: + where.append("e.method = ?") + params.append(method) + if target: + where.append("e.target = ?") + params.append(target) + if status in ("ok", "error", "tool_error", "input_required", "cancelled"): + where.append("e.status = ?") + params.append(status) + elif status in ("pending", "hung", "abandoned"): + where.append("e.status = 'pending'") + if text: + where.append( + """EXISTS (SELECT 1 FROM messages m WHERE m.exchange_id = e.id + AND m.body LIKE ? ESCAPE '\\')""") + escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + params.append(f"%{escaped}%") + if not include_hidden: + where.append("s.hidden = 0") + rows = self._all( + f"""SELECT {_EXCHANGE_COLUMNS}, s.ended_at AS s_ended_at, s.pid AS s_pid, + s.last_seen_at AS s_last_seen_at, + COALESCE(s.name, s.server_name, s.target) AS server + FROM exchanges e JOIN sessions s ON s.id = e.session_id + WHERE {" AND ".join(where)} + ORDER BY e.started_at DESC, e.id DESC LIMIT ?""", + [*params, limit], + ) + live_cache: dict[str, bool] = {} + result = [] + for row in rows: + session_live = live + if session_live is None: + if row["session_id"] not in live_cache: + live_cache[row["session_id"]] = self._is_live({ + "ended_at": row["s_ended_at"], "pid": row["s_pid"], + "last_seen_at": row["s_last_seen_at"]}) + session_live = live_cache[row["session_id"]] + for key in ("s_ended_at", "s_pid", "s_last_seen_at"): + row.pop(key) + self._decorate_exchange(row, session_live) + if status in ("pending", "hung", "abandoned") and row["status"] != status: + continue + result.append(row) + result.reverse() + return result + + def get_exchange(self, exchange_id: int) -> dict[str, Any] | None: + exchange = self._one( + f"""SELECT {_EXCHANGE_COLUMNS} FROM exchanges e WHERE e.id = ?""", + (exchange_id,)) + if not exchange: + return None + session = self.get_session_header(exchange["session_id"]) + self._decorate_exchange(exchange, bool(session and session["live"])) + exchange["session"] = session + exchange["request"] = self.get_message(exchange["request_msg_id"]) + exchange["response"] = ( + self.get_message(exchange["response_msg_id"]) + if exchange["response_msg_id"] else None) + exchange["chain"] = self._chain(exchange) + return exchange + + def get_session_header(self, session_id: str) -> dict[str, Any] | None: + session = self._one(f"SELECT {_SESSION_COLUMNS} FROM sessions WHERE id = ?", + (session_id,)) + return self._decorate_session(session) if session else None + + def _chain(self, exchange: dict[str, Any]) -> list[dict[str, Any]]: + """All exchanges of a multi round-trip chain, in order.""" + root = exchange["chain_root_id"] or exchange["id"] + rows = self._all( + f"""SELECT {_EXCHANGE_COLUMNS} FROM exchanges e + WHERE e.id = ? OR e.chain_root_id = ? ORDER BY e.id""", + (root, root)) + return rows if len(rows) > 1 else [] + + def get_message(self, message_id: int) -> dict[str, Any] | None: + message = self._one("SELECT * FROM messages WHERE id = ?", (message_id,)) + if not message: + return None + message["body"] = _loads(message["body"]) + message["headers"] = _loads(message["headers"]) + return message + + def messages( + self, + *, + session_id: str | None = None, + after_id: int = 0, + limit: int = 500, + include_hidden: bool = False, + ) -> list[dict[str, Any]]: + where, params = ["m.id > ?"], [after_id] + if session_id: + where.append("m.session_id = ?") + params.append(session_id) + if not include_hidden: + where.append("s.hidden = 0") + rows = self._all( + f"""SELECT m.id, m.session_id, m.exchange_id, m.ts, m.direction, m.kind, + m.method, m.rpc_id, m.size, m.tokens, m.note, + COALESCE(s.name, s.server_name, s.target) AS server + FROM messages m JOIN sessions s ON s.id = m.session_id + WHERE {" AND ".join(where)} ORDER BY m.id LIMIT ?""", + [*params, limit], + ) + return rows + + def latest_message_id(self) -> int: + row = self._one("SELECT COALESCE(MAX(id), 0) AS id FROM messages") + return int(row["id"]) if row else 0 + + def messages_for(self, session_id: str, kind: str | None = None) -> list[dict[str, Any]]: + """Full message rows (with parsed bodies) of one session.""" + sql = "SELECT * FROM messages WHERE session_id = ?" + params: list[Any] = [session_id] + if kind: + sql += " AND kind = ?" + params.append(kind) + rows = self._all(sql + " ORDER BY id", params) + for row in rows: + row["body"] = _loads(row["body"]) + row["headers"] = _loads(row["headers"]) + return rows + + def responses_for(self, method: str, session_ids: list[str]) -> list[dict[str, Any]]: + """Response messages (parsed) of all exchanges of ``method`` in some sessions.""" + if not session_ids: + return [] + rows = self._all( + f"""SELECT e.id AS exchange_id, e.session_id, e.started_at, m.body + FROM exchanges e JOIN messages m ON m.id = e.response_msg_id + WHERE e.method = ? AND e.status = 'ok' + AND e.session_id IN ({",".join("?" * len(session_ids))}) + ORDER BY e.started_at""", + [method, *session_ids], + ) + for row in rows: + row["body"] = _loads(row["body"]) + return rows + + def stats(self) -> dict[str, Any]: + row = self._one( + """SELECT + (SELECT COUNT(*) FROM sessions WHERE hidden = 0) AS sessions, + (SELECT COUNT(*) FROM exchanges e JOIN sessions s ON s.id = e.session_id + WHERE s.hidden = 0) AS exchanges, + (SELECT COUNT(*) FROM messages m JOIN sessions s ON s.id = m.session_id + WHERE s.hidden = 0) AS messages, + (SELECT COUNT(*) FROM exchanges e JOIN sessions s ON s.id = e.session_id + WHERE s.hidden = 0 AND e.status IN ('error', 'tool_error')) AS errors""") + return row or {} + + def clear(self) -> None: + with self._lock: + self._conn.executescript( + "DELETE FROM messages; DELETE FROM exchanges; DELETE FROM sessions;") + self._conn.commit() + + +def resolve_scope( + q: Query, + *, + session_id: str | None = None, + run_key: str | None = None, + server: str | None = None, + limit: int = 200, +) -> list[dict[str, Any]]: + """Sessions an analysis should look at, newest first.""" + if session_id: + session = q.get_session_header(session_id) + return [session] if session else [] + return q.list_sessions(run_key=run_key, server=server, limit=limit) diff --git a/mcphawk/sniffer.py b/mcphawk/sniffer.py deleted file mode 100644 index ea2f8db..0000000 --- a/mcphawk/sniffer.py +++ /dev/null @@ -1,335 +0,0 @@ -import asyncio -import json -import logging -import platform -import uuid -from datetime import datetime, timezone - -# Suppress Scapy warnings before importing -logging.getLogger("scapy.runtime").setLevel(logging.ERROR) - -from scapy.all import IP, TCP, IPv6, Raw, conf, sniff # noqa: E402 - -from mcphawk.logger import log_message # noqa: E402 -from mcphawk.tcp_reassembly import TCPStreamReassembler # noqa: E402 -from mcphawk.web.broadcaster import broadcast_new_log # noqa: E402 - -# Set up logger for this module -logger = logging.getLogger(__name__) - -# Server registry to track server names by connection -_server_registry = {} # {connection_id: server_info} - - -def _get_connection_id(src_ip: str, src_port: int, dst_ip: str, dst_port: int) -> str: - """Generate unique connection identifier for HTTP connections.""" - return f"{src_ip}:{src_port}->{dst_ip}:{dst_port}" - - -async def _safe_broadcast(log_entry: dict) -> None: - try: - await broadcast_new_log(log_entry) - except Exception as e: - logger.debug(f"Broadcast failed: {e}") - - -def _broadcast_in_any_loop(log_entry: dict) -> None: - try: - loop = asyncio.get_running_loop() - _ = loop.create_task(_safe_broadcast(log_entry)) # noqa: RUF006 - except RuntimeError: - asyncio.run(_safe_broadcast(log_entry)) - - -# Global variable to track auto-detect mode -_auto_detect_mode = False - -# Global variable to track excluded ports -_excluded_ports = set() - -# TCP stream reassembler for handling SSE and chunked responses -_tcp_reassembler = TCPStreamReassembler() -logger.info("TCP stream reassembler initialized") - -def packet_callback(pkt): - """ - Callback for every sniffed packet. - Extract JSON-RPC messages from raw TCP payloads. - """ - # Skip packets to/from excluded ports - if pkt.haslayer(TCP) and _excluded_ports: - tcp_layer = pkt[TCP] - if tcp_layer.sport in _excluded_ports or tcp_layer.dport in _excluded_ports: - return - - # Try TCP stream reassembly first for SSE/chunked responses - if pkt.haslayer(TCP) and pkt.haslayer(Raw): - tcp = pkt[TCP] - if tcp.sport == 8765 or tcp.dport == 8765: - raw_data = bytes(pkt[Raw]) - logger.debug(f"TCP packet for reassembly: {tcp.sport}->{tcp.dport}, {len(raw_data)} bytes") - # Log first 100 bytes to see what we're getting - preview = raw_data[:100].decode('utf-8', errors='replace') - logger.debug(f"Packet preview: {preview!r}") - - reassembled_messages = _tcp_reassembler.process_packet(pkt) - if reassembled_messages: - logger.info(f"TCP reassembler found {len(reassembled_messages)} messages!") - elif pkt.haslayer(TCP) and pkt.haslayer(Raw): - tcp = pkt[TCP] - if tcp.sport == 8765 or tcp.dport == 8765: - logger.debug(f"TCP reassembler returned no messages for {tcp.sport}->{tcp.dport}") - for msg_info in reassembled_messages: - # Process reassembled SSE messages - if "jsonrpc" in msg_info["message"]: - logger.debug(f"Reassembled {msg_info['type']}: {msg_info['message'][:100]}...") - - ts = datetime.now(tz=timezone.utc) - log_id = str(uuid.uuid4()) - - # Use transport type from TCP reassembler - transport = msg_info.get("transport", "unknown") - - # Determine direction based on message type - from .utils import extract_server_info - direction = "unknown" - - # For HTTP, incoming = server->client, outgoing = client->server - if msg_info.get("type") == "HTTP Response": - direction = "incoming" - # Check for server info in response - server_info = extract_server_info(msg_info["message"]) - if server_info: - conn_id = _get_connection_id( - msg_info["dst_ip"], msg_info["dst_port"], - msg_info["src_ip"], msg_info["src_port"] - ) - _server_registry[conn_id] = server_info - logger.debug(f"Captured server info for {conn_id}: {server_info}") - else: - direction = "outgoing" - - entry = { - "log_id": log_id, - "timestamp": ts, - "src_ip": msg_info["src_ip"], - "src_port": msg_info["src_port"], - "dst_ip": msg_info["dst_ip"], - "dst_port": msg_info["dst_port"], - "direction": direction, - "message": msg_info["message"], - "transport_type": transport, - } - - # Build metadata - metadata = {} - - # Add server info if we have it - conn_id = _get_connection_id( - msg_info["src_ip"], msg_info["src_port"], - msg_info["dst_ip"], msg_info["dst_port"] - ) - if conn_id in _server_registry: - server_info = _server_registry[conn_id] - metadata["server_name"] = server_info["name"] - metadata["server_version"] = server_info["version"] - - # MCPHawk's own traffic will be identified by server_name in metadata - - if metadata: - entry["metadata"] = json.dumps(metadata) - - log_message(entry) - - # Convert timestamp to ISO only for WebSocket broadcast - broadcast_entry = dict(entry) - broadcast_entry["timestamp"] = ts.isoformat() - _broadcast_in_any_loop(broadcast_entry) - - # In auto-detect mode, log when we find MCP traffic - if _auto_detect_mode: - transport_name = { - "streamable_http": "Streamable HTTP", - "http_sse": "HTTP+SSE", - "stdio": "stdio", - "unknown": "Unknown" - }.get(transport, "Unknown") - logger.info(f"Detected {transport_name} MCP traffic on port {msg_info['src_port']} -> {msg_info['dst_port']}") - - if pkt.haslayer(Raw): - raw_payload = pkt[Raw].load - - # Check if this looks like SSE data - if raw_payload.startswith(b"data: "): - logger.debug(f"SSE data packet detected: {raw_payload[:100]}...") - - if not _auto_detect_mode: # Less verbose in auto-detect mode - logger.debug(f"Raw payload: {raw_payload[:60]}...") - - # Try to process as raw JSON-RPC or HTTP POST with JSON-RPC - try: - decoded = raw_payload.decode("utf-8", errors="ignore") - - # Debug log all HTTP traffic - if decoded.startswith("HTTP/1.1"): - logger.debug(f"HTTP Response: {decoded[:200]}...") - - # Check for standalone SSE data (not part of HTTP response) - if decoded.startswith("data: ") and "jsonrpc" in decoded: - # This is a standalone SSE data packet - sse_data = decoded[6:] # Skip "data: " - if "\n" in sse_data: - sse_data = sse_data[:sse_data.index("\n")] - if sse_data.startswith("{"): - logger.debug(f"Found standalone SSE data: {sse_data[:100]}...") - decoded = sse_data - # Process as regular JSON-RPC - - # Check for HTTP request/response with JSON-RPC content - if (decoded.startswith("POST") or decoded.startswith("HTTP/1.1")) and "\r\n\r\n" in decoded: - # Extract JSON body from HTTP request/response - body_start = decoded.find("\r\n\r\n") + 4 - json_body = decoded[body_start:] - - # Debug log HTTP responses - if decoded.startswith("HTTP/1.1") and "text/event-stream" in decoded: - logger.debug(f"SSE Response detected, body length: {len(json_body)}, body preview: {json_body[:100]}") - - # Check for Server-Sent Events (SSE) format used by MCP SDK - if "text/event-stream" in decoded and json_body.startswith("data: "): - # Extract JSON from SSE format: "data: {...}\n\n" - sse_data = json_body[6:] # Skip "data: " - if "\n" in sse_data: - sse_data = sse_data[:sse_data.index("\n")] - if sse_data.startswith("{") and "jsonrpc" in sse_data: - decoded = sse_data - logger.debug(f"Extracted JSON-RPC from SSE: {decoded[:100]}...") - elif json_body.startswith("{") and "jsonrpc" in json_body: - decoded = json_body # Use just the JSON body - logger.debug(f"Extracted JSON-RPC from HTTP: {decoded[:100]}...") - - # Process if we have JSON-RPC content - if decoded.startswith("{") and "jsonrpc" in decoded: - logger.debug(f"Sniffer captured: {decoded}") - - ts = datetime.now(tz=timezone.utc) - src_port = pkt[TCP].sport if pkt.haslayer(TCP) else 0 - dst_port = pkt[TCP].dport if pkt.haslayer(TCP) else 0 - - # In auto-detect mode, log when we find MCP traffic on a new port - if _auto_detect_mode: - logger.info(f"Detected MCP traffic on port {src_port} -> {dst_port}") - - # Get IP addresses - if pkt.haslayer(IP): - src_ip = pkt[IP].src - dst_ip = pkt[IP].dst - elif pkt.haslayer(IPv6): - src_ip = pkt[IPv6].src - dst_ip = pkt[IPv6].dst - else: - src_ip = "" - dst_ip = "" - - log_id = str(uuid.uuid4()) - - # Check if we know the transport type for this connection - transport = _tcp_reassembler.transport_tracker.get_transport( - src_ip, src_port, dst_ip, dst_port - ).value - - if _auto_detect_mode and transport != "unknown": - logger.debug(f"Auto-detect: Found transport {transport} for {src_ip}:{src_port} -> {dst_ip}:{dst_port}") - - # Determine direction and check for server info - from .utils import extract_server_info - direction = "unknown" - - # For raw TCP, we need to infer direction - # If it's a response (has result or error), it's incoming - try: - msg = json.loads(decoded) - if "result" in msg or "error" in msg: - direction = "incoming" - # Check for server info - server_info = extract_server_info(decoded) - if server_info: - conn_id = _get_connection_id(dst_ip, dst_port, src_ip, src_port) - _server_registry[conn_id] = server_info - logger.debug(f"Captured server info for {conn_id}: {server_info}") - elif "method" in msg: - direction = "outgoing" - except json.JSONDecodeError: - pass - - entry = { - "log_id": log_id, - "timestamp": ts, - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, - "direction": direction, - "message": decoded, - "transport_type": transport, - } - - # Build metadata - metadata = {} - - # Add server info if we have it - conn_id = _get_connection_id(src_ip, src_port, dst_ip, dst_port) - if conn_id in _server_registry: - server_info = _server_registry[conn_id] - metadata["server_name"] = server_info["name"] - metadata["server_version"] = server_info["version"] - - # MCPHawk's own traffic will be identified by server_name in metadata - - if metadata: - entry["metadata"] = json.dumps(metadata) - - log_message(entry) - - # Convert timestamp to ISO only for WebSocket broadcast - broadcast_entry = dict(entry) - broadcast_entry["timestamp"] = ts.isoformat() - _broadcast_in_any_loop(broadcast_entry) - except Exception as e: - logger.debug(f"JSON decode failed: {e}") - - -def start_sniffer(filter_expr: str = "tcp and port 12345", auto_detect: bool = False, debug: bool = False, excluded_ports: list[int] | None = None) -> None: - """ - Start sniffing packets on the appropriate interface. - - On macOS: use `lo0` - - On Linux: default interface - - Args: - filter_expr: BPF filter expression - auto_detect: If True, automatically detect MCP traffic on any port - debug: If True, enable debug logging - excluded_ports: List of ports to exclude from capture - """ - global _auto_detect_mode, _excluded_ports - _auto_detect_mode = auto_detect - _excluded_ports = set(excluded_ports) if excluded_ports else set() - - # Configure logging based on debug flag - if debug: - logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(message)s') - logger.setLevel(logging.DEBUG) - - logger.debug(f"Starting sniffer with filter: {filter_expr}") - if auto_detect: - logger.debug("Auto-detect mode enabled") - - # Ensure better pcap support on macOS - conf.use_pcap = True - - try: - iface = "lo0" if platform.system() == "Darwin" else None - sniff(filter=filter_expr, iface=iface, prn=packet_callback, store=False) - except KeyboardInterrupt: - logger.debug("Sniffer interrupted by user") - raise diff --git a/mcphawk/stdio_server_detector_fallback.py b/mcphawk/stdio_server_detector_fallback.py deleted file mode 100644 index d672e82..0000000 --- a/mcphawk/stdio_server_detector_fallback.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Fallback server detection for stdio transport when initialize message is not available.""" - -import re -from pathlib import Path -from typing import Optional - - -def detect_server_from_command(command: list[str]) -> Optional[dict[str, str]]: - """ - Try to detect MCP server info from the command being executed. - - This is a fallback when we don't have initialize message info. - - Args: - command: Command and arguments list - - Returns: - Dict with 'name' and 'version' if detected, None otherwise - """ - if not command: - return None - - # Get the executable name - exe = command[0] - exe_name = Path(exe).name - exe_path = Path(exe).stem # without extension - - # Check if running Python module with -m - if exe_name in ['python', 'python3', 'python3.exe', 'python.exe']: - # Look for -m module_name pattern - for i, arg in enumerate(command[1:], 1): - if arg == '-m' and i < len(command) - 1: - module = command[i + 1] - - # Special case for mcphawk - if module == 'mcphawk' and i + 2 < len(command) and command[i + 2] == 'mcp': - return {'name': 'MCPHawk Query Server', 'version': 'unknown'} - - # Extract name from module - name = extract_server_name(module) - if name: - return {'name': name, 'version': 'unknown'} - - # Check executable name - name = extract_server_name(exe_path) - if name: - return {'name': name, 'version': 'unknown'} - - # Check for .py files in arguments - for arg in command[1:]: - if arg.endswith('.py'): - script_name = Path(arg).stem - name = extract_server_name(script_name) - if name: - return {'name': name, 'version': 'unknown'} - - return None - - -def extract_server_name(text: str) -> Optional[str]: - """ - Extract a human-readable server name from various text patterns. - - Args: - text: Text to extract server name from (module name, exe name, etc) - - Returns: - Human-readable server name or None - """ - if not text or not isinstance(text, str): - return None - - # Pattern 1: mcp-server-{name} or mcp_server_{name} - match = re.match(r'^mcp[-_]server[-_](.+)$', text, re.IGNORECASE) - if match: - name_part = match.group(1) - # Convert to title case, handling both - and _ - words = re.split(r'[-_]', name_part) - return f"MCP {' '.join(word.capitalize() for word in words)} Server" - - # Pattern 2: {name}-mcp-server or {name}_mcp_server - match = re.match(r'^(.+?)[-_]mcp[-_]server$', text, re.IGNORECASE) - if match: - name_part = match.group(1) - words = re.split(r'[-_]', name_part) - return f"{' '.join(word.capitalize() for word in words)} MCP Server" - - # Pattern 3: mcp-{name} or mcp_{name} (but not mcp-server) - match = re.match(r'^mcp[-_](.+)$', text, re.IGNORECASE) - if match: - name_part = match.group(1) - # Skip if it's just "server" without additional parts - if name_part.lower() == 'server': - return None - words = re.split(r'[-_]', name_part) - return f"MCP {' '.join(word.capitalize() for word in words)}" - - # Pattern 4: {name}-mcp or {name}_mcp - match = re.match(r'^(.+?)[-_]mcp$', text, re.IGNORECASE) - if match: - name_part = match.group(1) - words = re.split(r'[-_]', name_part) - return f"{' '.join(word.capitalize() for word in words)} MCP" - - # Pattern 5: contains 'mcp' somewhere - if 'mcp' in text.lower(): - # Clean up and format - words = re.split(r'[-_]', text) - # Filter out empty strings from split - words = [w for w in words if w] - formatted_words = [] - for word in words: - if word.lower() == 'mcp': - formatted_words.append('MCP') - else: - formatted_words.append(word.capitalize()) - return ' '.join(formatted_words) - - return None - - -def merge_server_info( - detected: Optional[dict[str, str]], - from_protocol: Optional[dict[str, str]] -) -> Optional[dict[str, str]]: - """ - Merge server info from command detection and protocol messages. - - Protocol info takes precedence as it's more accurate. - - Args: - detected: Server info detected from command - from_protocol: Server info from initialize response - - Returns: - Merged server info or None - """ - if from_protocol: - return from_protocol - return detected diff --git a/mcphawk/store/__init__.py b/mcphawk/store/__init__.py new file mode 100644 index 0000000..7feb4af --- /dev/null +++ b/mcphawk/store/__init__.py @@ -0,0 +1,6 @@ +"""Capture storage.""" + +from mcphawk.store.db import connect +from mcphawk.store.recorder import C2S, S2C, Recorder + +__all__ = ["C2S", "S2C", "Recorder", "connect"] diff --git a/mcphawk/store/db.py b/mcphawk/store/db.py new file mode 100644 index 0000000..1b06b41 --- /dev/null +++ b/mcphawk/store/db.py @@ -0,0 +1,99 @@ +"""SQLite schema and connections. + +Several processes write at once (one ``mcphawk wrap`` per wrapped server, a +proxy, a sniffer) while the web UI and MCP server read, so the database runs +in WAL mode with a generous busy timeout. +""" + +import sqlite3 +from pathlib import Path + +from mcphawk.paths import db_path + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + run_key TEXT, + name TEXT, + capture TEXT NOT NULL, + transport TEXT NOT NULL, + target TEXT, + client_app TEXT, + client_name TEXT, + client_version TEXT, + server_name TEXT, + server_version TEXT, + protocol_version TEXT, + era TEXT, + pid INTEGER, + client_pid INTEGER, + started_at REAL NOT NULL, + last_seen_at REAL NOT NULL, + ended_at REAL, + hidden INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_sessions_run ON sessions(run_key, started_at); +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); + +CREATE TABLE IF NOT EXISTS exchanges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + initiator TEXT NOT NULL, + method TEXT NOT NULL, + target TEXT, + rpc_id TEXT, + request_msg_id INTEGER, + response_msg_id INTEGER, + started_at REAL NOT NULL, + ended_at REAL, + duration_ms REAL, + status TEXT NOT NULL DEFAULT 'pending', + error_code INTEGER, + error_message TEXT, + request_tokens INTEGER NOT NULL DEFAULT 0, + response_tokens INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER, + chain_root_id INTEGER +); +CREATE INDEX IF NOT EXISTS idx_exchanges_session ON exchanges(session_id, started_at); +CREATE INDEX IF NOT EXISTS idx_exchanges_status ON exchanges(status); +CREATE INDEX IF NOT EXISTS idx_exchanges_method ON exchanges(method, target); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + exchange_id INTEGER, + ts REAL NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('c2s', 's2c')), + kind TEXT NOT NULL, + method TEXT, + rpc_id TEXT, + size INTEGER NOT NULL, + tokens INTEGER NOT NULL, + body TEXT NOT NULL, + headers TEXT, + note TEXT +); +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id); +CREATE INDEX IF NOT EXISTS idx_messages_exchange ON messages(exchange_id); +""" + + +def connect(path: Path | str | None = None) -> sqlite3.Connection: + """Open (and if needed create) the capture database.""" + path = Path(path) if path else db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=10, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=10000") + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version < SCHEMA_VERSION: + conn.executescript(SCHEMA) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + conn.commit() + return conn diff --git a/mcphawk/store/recorder.py b/mcphawk/store/recorder.py new file mode 100644 index 0000000..c288091 --- /dev/null +++ b/mcphawk/store/recorder.py @@ -0,0 +1,314 @@ +"""Turns raw captured frames into sessions, exchanges and messages. + +Every capture mode (stdio wrapper, HTTP proxy, passive sniffer) feeds frames +through a :class:`Recorder`. It owns request/response pairing, multi +round-trip chains and identity extraction, so the capture code only has to +know which direction bytes flowed in. +""" + +import json +import threading +import time +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from mcphawk.protocol import jsonrpc +from mcphawk.protocol import mcp as proto +from mcphawk.protocol.masking import mask_headers, mask_text, mask_value +from mcphawk.protocol.tokens import estimate_content, estimate_json, estimate_text +from mcphawk.store.db import connect + +C2S = "c2s" +S2C = "s2c" + +STATUS_PENDING = "pending" +STATUS_OK = "ok" +STATUS_ERROR = "error" +STATUS_TOOL_ERROR = "tool_error" +STATUS_INPUT_REQUIRED = "input_required" +STATUS_CANCELLED = "cancelled" + +HIDDEN_SERVER_NAMES = frozenset({"mcphawk"}) + + +@dataclass +class _SessionState: + pending: dict[tuple[str, str], tuple[int, str, float]] = field(default_factory=dict) + # requestState token -> exchange that asked for more input + input_states: dict[str, int] = field(default_factory=dict) + identity: dict[str, Any] = field(default_factory=dict) + + +class Recorder: + """Thread-safe writer for one capturing process.""" + + def __init__( + self, + db: Path | str | None = None, + mask: bool = True, + on_record: Callable[[dict[str, Any]], None] | None = None, + ): + self._conn = connect(db) + self._lock = threading.Lock() + self._mask = mask + self._on_record = on_record + self._sessions: dict[str, _SessionState] = {} + + def close(self) -> None: + with self._lock: + self._conn.close() + + # -- sessions --------------------------------------------------------- + + def open_session( + self, + *, + capture: str, + transport: str, + name: str | None = None, + target: str | None = None, + pid: int | None = None, + client_pid: int | None = None, + client_app: str | None = None, + run_key: str | None = None, + ts: float | None = None, + ) -> str: + session_id = uuid.uuid4().hex[:12] + now = ts or time.time() + with self._lock: + self._conn.execute( + """INSERT INTO sessions (id, run_key, name, capture, transport, target, + client_app, pid, client_pid, started_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (session_id, run_key, name, capture, transport, + mask_text(target) if (target and self._mask) else target, + client_app, pid, client_pid, now, now), + ) + self._conn.commit() + self._sessions[session_id] = _SessionState() + return session_id + + def end_session(self, session_id: str, ts: float | None = None) -> None: + with self._lock: + self._conn.execute( + "UPDATE sessions SET ended_at = ? WHERE id = ?", + (ts or time.time(), session_id), + ) + self._conn.commit() + + def set_run_key(self, session_id: str, run_key: str) -> None: + with self._lock: + self._conn.execute( + "UPDATE sessions SET run_key = ? WHERE id = ? AND run_key IS NULL", + (run_key, session_id), + ) + self._conn.commit() + + # -- messages --------------------------------------------------------- + + def record( + self, + session_id: str, + direction: str, + raw: str | bytes, + *, + headers: dict[str, str] | None = None, + ts: float | None = None, + ) -> list[int]: + """Record one wire frame (a message or a batch). Returns message ids.""" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + raw = raw.strip() + if not raw: + return [] + now = ts or time.time() + header_json = None + if headers: + header_json = json.dumps(mask_headers(headers) if self._mask else headers) + + with self._lock: + state = self._sessions.setdefault(session_id, _SessionState()) + messages = jsonrpc.parse_frame(raw) + ids: list[int] = [] + if messages is None: + ids.append(self._insert_invalid(session_id, direction, raw, header_json, now)) + else: + batch_note = f"batch of {len(messages)}" if len(messages) > 1 else None + for msg in messages: + ids.append(self._record_one( + session_id, state, direction, msg, header_json, now, batch_note)) + self._conn.execute( + "UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now, session_id)) + self._conn.commit() + + if self._on_record: + for message_id in ids: + self._on_record({"session_id": session_id, "message_id": message_id}) + return ids + + def _insert_invalid(self, session_id, direction, raw, header_json, now) -> int: + body = mask_text(raw) if self._mask else raw + cur = self._conn.execute( + """INSERT INTO messages (session_id, ts, direction, kind, size, tokens, body, + headers, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (session_id, now, direction, jsonrpc.INVALID, len(raw.encode()), + estimate_text(raw), body, header_json, "not JSON"), + ) + return int(cur.lastrowid) + + def _record_one(self, session_id, state, direction, msg, header_json, now, note) -> int: + kind = jsonrpc.classify(msg) + method = msg.get("method") if isinstance(msg.get("method"), str) else None + rpc_key = jsonrpc.id_key(msg.get("id")) + stored = mask_value(msg) if self._mask else msg + body = json.dumps(stored, ensure_ascii=False) + size = len(json.dumps(msg, ensure_ascii=False).encode()) + + cur = self._conn.execute( + """INSERT INTO messages (session_id, ts, direction, kind, method, rpc_id, size, + tokens, body, headers, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (session_id, now, direction, kind, method, rpc_key, size, + estimate_text(body), body, header_json, note), + ) + message_id = int(cur.lastrowid) + + if kind == jsonrpc.REQUEST: + exchange_id = self._open_exchange( + session_id, state, direction, msg, method, rpc_key, message_id, now) + self._link(message_id, exchange_id) + self._absorb_request_identity(session_id, state, msg) + elif kind in (jsonrpc.RESPONSE, jsonrpc.ERROR): + initiator = "client" if direction == S2C else "server" + pending = state.pending.pop((initiator, rpc_key), None) if rpc_key else None + if pending: + exchange_id, request_method, started = pending + self._close_exchange( + session_id, state, exchange_id, request_method, msg, kind, + message_id, started, now) + self._link(message_id, exchange_id) + else: + self._conn.execute( + "UPDATE messages SET note = ? WHERE id = ?", + ("no matching request", message_id)) + elif kind == jsonrpc.NOTIFICATION and method == "notifications/cancelled": + self._cancel(state, direction, msg, now) + return message_id + + def _link(self, message_id: int, exchange_id: int) -> None: + self._conn.execute( + "UPDATE messages SET exchange_id = ? WHERE id = ?", (exchange_id, message_id)) + + def _open_exchange(self, session_id, state, direction, msg, method, rpc_key, + message_id, now) -> int: + initiator = "client" if direction == C2S else "server" + parent_id = root_id = None + token = proto.request_state(msg) + if token and token in state.input_states: + parent_id = state.input_states.pop(token) + row = self._conn.execute( + "SELECT chain_root_id FROM exchanges WHERE id = ?", (parent_id,)).fetchone() + root_id = (row["chain_root_id"] if row else None) or parent_id + cur = self._conn.execute( + """INSERT INTO exchanges (session_id, initiator, method, target, rpc_id, + request_msg_id, started_at, request_tokens, parent_id, chain_root_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (session_id, initiator, method, proto.target_of(msg), rpc_key, message_id, + now, estimate_json(msg.get("params")), parent_id, root_id), + ) + exchange_id = int(cur.lastrowid) + if rpc_key: + state.pending[(initiator, rpc_key)] = (exchange_id, method, now) + return exchange_id + + def _close_exchange(self, session_id, state, exchange_id, request_method, msg, kind, + message_id, started, now) -> None: + code = error_message = None + if kind == jsonrpc.ERROR: + status = STATUS_ERROR + code, error_message = proto.error_of(msg) + elif proto.result_type(msg) == proto.RESULT_INPUT_REQUIRED: + status = STATUS_INPUT_REQUIRED + token = proto.request_state(msg) + if token: + state.input_states[token] = exchange_id + elif request_method == "tools/call" and proto.is_tool_error(msg): + status = STATUS_TOOL_ERROR + error_message = proto.tool_error_text(msg) + else: + status = STATUS_OK + + result = msg.get("result") if isinstance(msg.get("result"), dict) else None + if request_method == "tools/call" and result is not None and "content" in result: + tokens = estimate_content(result.get("content")) + else: + tokens = estimate_json(result if result is not None else msg.get("error")) + + self._conn.execute( + """UPDATE exchanges SET response_msg_id = ?, ended_at = ?, duration_ms = ?, + status = ?, error_code = ?, error_message = ?, response_tokens = ? + WHERE id = ?""", + (message_id, now, round((now - started) * 1000, 3), status, code, + error_message, tokens, exchange_id), + ) + if kind == jsonrpc.RESPONSE: + self._absorb_response_identity(session_id, state, msg, request_method) + + def _cancel(self, state, direction, msg, now) -> None: + params = msg.get("params") if isinstance(msg.get("params"), dict) else {} + rpc_key = jsonrpc.id_key(params.get("requestId")) + initiator = "client" if direction == C2S else "server" + pending = state.pending.pop((initiator, rpc_key), None) if rpc_key else None + if pending: + exchange_id, _, started = pending + reason = params.get("reason") + self._conn.execute( + """UPDATE exchanges SET status = ?, ended_at = ?, duration_ms = ?, + error_message = ? WHERE id = ?""", + (STATUS_CANCELLED, now, round((now - started) * 1000, 3), + str(reason) if reason else None, exchange_id), + ) + + # -- identity --------------------------------------------------------- + + def _absorb_request_identity(self, session_id, state, msg) -> None: + updates: dict[str, Any] = {} + client = proto.client_info(msg) + if client: + updates["client_name"] = client["name"] + updates["client_version"] = client["version"] + version = proto.protocol_version(msg) + if version: + updates["protocol_version"] = version + updates["era"] = proto.era_of_version(version) + self._update_identity(session_id, state, updates) + + def _absorb_response_identity(self, session_id, state, msg, request_method) -> None: + updates: dict[str, Any] = {} + server = proto.server_info(msg, request_method) + if server: + updates["server_name"] = server["name"] + updates["server_version"] = server["version"] + if server["name"].lower() in HIDDEN_SERVER_NAMES: + updates["hidden"] = 1 + if request_method == "initialize": + version = proto.negotiated_version(msg) + if version: + updates["protocol_version"] = version + updates["era"] = proto.era_of_version(version) + self._update_identity(session_id, state, updates) + + def _update_identity(self, session_id, state, updates: dict[str, Any]) -> None: + changed = {k: v for k, v in updates.items() if state.identity.get(k) != v} + if not changed: + return + state.identity.update(changed) + assignments = ", ".join(f"{column} = ?" for column in changed) + self._conn.execute( + f"UPDATE sessions SET {assignments} WHERE id = ?", # column names are fixed + (*changed.values(), session_id), + ) diff --git a/mcphawk/tcp_reassembly.py b/mcphawk/tcp_reassembly.py deleted file mode 100644 index 66aa1d5..0000000 --- a/mcphawk/tcp_reassembly.py +++ /dev/null @@ -1,421 +0,0 @@ -"""TCP stream reassembly for capturing complete HTTP/SSE responses.""" - -import logging -from typing import Optional - -from scapy.all import IP, TCP, IPv6, Raw - -from .transport_detector import ( - MCPTransport, - TransportTracker, - detect_transport_from_http, - extract_endpoint_from_sse, -) - -logger = logging.getLogger(__name__) - - -class StreamKey: - """Key for identifying TCP streams.""" - - def __init__(self, src_ip: str, src_port: int, dst_ip: str, dst_port: int): - # Always order the tuple consistently (lower IP/port first) - if (src_ip, src_port) < (dst_ip, dst_port): - self.key = (src_ip, src_port, dst_ip, dst_port) - else: - self.key = (dst_ip, dst_port, src_ip, src_port) - - def __hash__(self): - return hash(self.key) - - def __eq__(self, other): - return self.key == other.key - - def __repr__(self): - return f"StreamKey{self.key}" - - -class HTTPStream: - """Tracks HTTP request/response pairs in a TCP stream.""" - - def __init__(self): - self.pending_request: Optional[bytes] = None - self.pending_response: Optional[bytes] = None - self.response_headers: Optional[dict[str, str]] = None - self.content_length: Optional[int] = None - self.is_chunked: bool = False - self.is_sse: bool = False - self.buffer: bytes = b"" - self.request_method: Optional[str] = None - self.request_path: Optional[str] = None - self.request_headers: dict[str, str] = {} - self.detected_transport: MCPTransport = MCPTransport.UNKNOWN - - def add_request(self, data: bytes): - """Add HTTP request data and parse headers.""" - self.pending_request = data - self.buffer = b"" - logger.debug(f"New HTTP request: {data[:100]}") - - # Parse request line and headers - try: - request_str = data.decode('utf-8', errors='ignore') - lines = request_str.split('\r\n') - if lines: - # Parse request line - parts = lines[0].split(' ') - if len(parts) >= 2: - self.request_method = parts[0] - self.request_path = parts[1] - - # Parse headers - self.request_headers = {} - for line in lines[1:]: - if ': ' in line: - key, value = line.split(': ', 1) - self.request_headers[key.lower()] = value - elif line == '': - break # End of headers - - # Try to detect transport type from request alone - if self.request_method and self.request_path: - self.detected_transport = detect_transport_from_http( - self.request_method, - self.request_path, - self.request_headers, - False # No response yet - ) - logger.debug(f"Transport detected from request: {self.detected_transport}") - except Exception as e: - logger.debug(f"Error parsing request: {e}") - - def add_response_data(self, data: bytes): - """Add HTTP response data, handling headers and body.""" - self.buffer += data - logger.debug(f"HTTPStream: Added {len(data)} bytes to buffer, total buffer size: {len(self.buffer)}") - - # If we don't have headers yet, try to parse them - if self.response_headers is None and b"\r\n\r\n" in self.buffer: - header_end = self.buffer.find(b"\r\n\r\n") + 4 - header_data = self.buffer[:header_end].decode('utf-8', errors='ignore') - self.buffer = self.buffer[header_end:] # Keep only body in buffer - - # Parse headers - lines = header_data.split('\r\n') - self.response_headers = {} - for line in lines[1:]: # Skip status line - if ': ' in line: - key, value = line.split(': ', 1) - self.response_headers[key.lower()] = value - - # Check for SSE - content_type = self.response_headers.get('content-type', '') - self.is_sse = 'text/event-stream' in content_type - - # Check for chunked encoding - transfer_encoding = self.response_headers.get('transfer-encoding', '') - self.is_chunked = 'chunked' in transfer_encoding - - # Get content length if not chunked - if not self.is_chunked and 'content-length' in self.response_headers: - self.content_length = int(self.response_headers['content-length']) - - logger.debug(f"Response headers parsed: SSE={self.is_sse}, chunked={self.is_chunked}") - - # Try to detect transport type - if self.request_method and self.request_path: - self.detected_transport = detect_transport_from_http( - self.request_method, - self.request_path, - self.request_headers, - self.is_sse - ) - - def extract_sse_messages(self) -> list[str]: - """Extract complete SSE messages from buffer.""" - messages = [] - logger.debug(f"extract_sse_messages called, buffer size: {len(self.buffer)}, is_chunked: {self.is_chunked}") - if self.buffer: - logger.debug(f"Buffer content preview: {self.buffer[:100]}") - - # If chunked encoding, we need to handle chunk sizes - data_to_process = self.buffer - if self.is_chunked and self.buffer: - # Try to extract chunks - logger.debug("Attempting to extract chunks from buffer") - chunk_data = self.extract_chunked_data() - if chunk_data: - # Process the extracted chunk data, but don't replace the buffer - # The buffer still contains any remaining chunk data - data_to_process = chunk_data - logger.debug(f"Extracted {len(chunk_data)} bytes from chunks to process") - else: - logger.debug("No complete chunks extracted yet") - return messages # Wait for more data - - # SSE messages are separated by double newlines (could be \r\n\r\n or \n\n) - logger.debug(f"Looking for SSE messages in {len(data_to_process)} bytes of data") - - # Look for either \r\n\r\n or \n\n - while b"\r\n\r\n" in data_to_process or b"\n\n" in data_to_process: - # Find the first occurrence of either separator - crlf_pos = data_to_process.find(b"\r\n\r\n") - lf_pos = data_to_process.find(b"\n\n") - - if crlf_pos >= 0 and (lf_pos < 0 or crlf_pos < lf_pos): - msg_end = crlf_pos + 4 # +4 for \r\n\r\n - else: - msg_end = lf_pos + 2 # +2 for \n\n - - msg_data = data_to_process[:msg_end].decode('utf-8', errors='ignore') - data_to_process = data_to_process[msg_end:] - logger.debug(f"Found SSE message block: {msg_data[:100]!r}") - - # Check for endpoint event (HTTP+SSE transport) - if "event: endpoint" in msg_data: - endpoint_url = extract_endpoint_from_sse(msg_data) - if endpoint_url: - logger.debug(f"Found endpoint event, URL: {endpoint_url}") - self.detected_transport = MCPTransport.HTTP_SSE - - # Extract data lines - for line in msg_data.split('\n'): - if line.startswith('data: '): - json_data = line[6:].strip() - if json_data and json_data.startswith('{'): - messages.append(json_data) - logger.debug(f"Extracted SSE message: {json_data[:100]}") - - return messages - - def extract_chunked_data(self) -> Optional[bytes]: - """Extract data from chunked transfer encoding.""" - # This is a simplified implementation - # Real chunked parsing is more complex - complete_data = b"" - original_buffer = self.buffer - - while True: - # Find chunk size - if b"\r\n" not in self.buffer: - logger.debug("extract_chunked_data: No CRLF in buffer, need more data") - break - - size_end = self.buffer.find(b"\r\n") - chunk_size_str = self.buffer[:size_end].decode('utf-8', errors='ignore').strip() - logger.debug(f"extract_chunked_data: Chunk size string: '{chunk_size_str}'") - - try: - chunk_size = int(chunk_size_str, 16) - logger.debug(f"extract_chunked_data: Parsed chunk size: {chunk_size}") - except ValueError: - logger.debug("extract_chunked_data: Failed to parse chunk size") - break - - if chunk_size == 0: - # Last chunk - consume it from buffer - self.buffer = self.buffer[size_end + 4:] # Skip "0\r\n\r\n" - logger.debug(f"extract_chunked_data: Found last chunk (size 0), returning {len(complete_data)} bytes") - return complete_data if complete_data else None - - # Check if we have the full chunk - chunk_start = size_end + 2 - chunk_end = chunk_start + chunk_size + 2 # +2 for trailing \r\n - - if len(self.buffer) < chunk_end: - # Need more data - restore original buffer and return what we have so far - logger.debug(f"extract_chunked_data: Need more data, have {len(self.buffer)}, need {chunk_end}") - self.buffer = original_buffer - break - - chunk_data = self.buffer[chunk_start:chunk_start + chunk_size] - logger.debug(f"extract_chunked_data: Extracted chunk of {len(chunk_data)} bytes") - complete_data += chunk_data - self.buffer = self.buffer[chunk_end:] - - if complete_data: - logger.debug(f"extract_chunked_data: Returning {len(complete_data)} bytes of unchunked data") - return complete_data - return None # Not complete yet - - -class TCPStreamReassembler: - """Reassembles TCP streams to capture complete HTTP messages.""" - - def __init__(self): - self.streams: dict[StreamKey, HTTPStream] = {} - self.transport_tracker = TransportTracker() - - def process_packet(self, pkt) -> list[dict]: - """Process a packet and return any complete messages.""" - messages = [] - - # Only process TCP packets with data - if not pkt.haslayer(TCP) or not pkt.haslayer(Raw): - return messages - - tcp = pkt[TCP] - - # Debug: Log that we're processing a packet - if pkt.haslayer(Raw): - payload = bytes(pkt[Raw]) - if len(payload) > 0 and (tcp.sport == 8765 or tcp.dport == 8765): - logger.debug(f"TCP reassembly: Processing {tcp.sport}->{tcp.dport} packet with {len(payload)} bytes") - logger.debug(f"TCP reassembly: Packet content: {payload[:100]}") - - # Get stream key - if pkt.haslayer(IP): - src_ip = pkt[IP].src - dst_ip = pkt[IP].dst - elif pkt.haslayer(IPv6): - src_ip = pkt[IPv6].src - dst_ip = pkt[IPv6].dst - else: - return messages - - src_port = pkt[TCP].sport - dst_port = pkt[TCP].dport - payload = bytes(pkt[Raw]) - - # Create stream key - stream_key = StreamKey(src_ip, src_port, dst_ip, dst_port) - - # Debug stream key for port 8765 - if src_port == 8765 or dst_port == 8765: - logger.debug(f"TCP reassembly: Stream key for port 8765: {stream_key}") - - # Get or create stream - if stream_key not in self.streams: - self.streams[stream_key] = HTTPStream() - logger.debug(f"TCP reassembly: Created new stream for {stream_key}") - stream = self.streams[stream_key] - - # Check if this is an HTTP request - if payload.startswith(b"POST ") or payload.startswith(b"GET "): - stream.add_request(payload) - logger.debug(f"TCP reassembly: HTTP request on {src_port}->{dst_port}") - logger.debug(f"TCP reassembly: Request method: {stream.request_method}, path: {stream.request_path}") - logger.debug(f"TCP reassembly: Request headers: {stream.request_headers}") - - # If we detected transport from request, update tracker - if stream.detected_transport != MCPTransport.UNKNOWN: - self.transport_tracker.update_transport( - src_ip, src_port, dst_ip, dst_port, - stream.detected_transport - ) - logger.debug(f"TCP reassembly: Updated transport tracker with {stream.detected_transport} from request") - - # For HTTP+SSE, log detection in auto-detect mode - if stream.detected_transport == MCPTransport.HTTP_SSE: - logger.info(f"[MCPHawk] Detected HTTP+SSE transport on {src_ip}:{src_port} -> {dst_ip}:{dst_port} (GET {stream.request_path} with Accept: text/event-stream)") - else: - logger.debug("TCP reassembly: Transport still unknown after request parsing") - - # Check if this is an HTTP response - elif payload.startswith(b"HTTP/1."): - stream.add_response_data(payload) - logger.debug(f"TCP reassembly: HTTP response data on {src_port}->{dst_port}, SSE={stream.is_sse}, buffer_len={len(stream.buffer)}") - logger.debug(f"TCP reassembly: Stream {stream_key} now has headers: {stream.response_headers}") - - # Try to extract messages if this is SSE - if stream.is_sse: - sse_messages = stream.extract_sse_messages() - logger.debug(f"TCP reassembly: Extracted {len(sse_messages)} SSE messages") - for msg in sse_messages: - # Update transport tracker if detected - if stream.detected_transport != MCPTransport.UNKNOWN: - self.transport_tracker.update_transport( - src_ip, src_port, dst_ip, dst_port, - stream.detected_transport - ) - - messages.append({ - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, - "message": msg, - "type": "sse_response", - "transport": stream.detected_transport.value - }) - - # Check for standalone SSE data (no HTTP headers) - elif payload.startswith(b"data: ") and b"jsonrpc" in payload: - # This might be a continuation of an SSE stream - logger.debug(f"TCP reassembly: Standalone SSE data on {src_port}->{dst_port}") - stream.buffer = payload - sse_messages = stream.extract_sse_messages() - for msg in sse_messages: - # Get transport from tracker - transport = self.transport_tracker.get_transport(src_ip, src_port, dst_ip, dst_port) - messages.append({ - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, - "message": msg, - "type": "sse_data", - "transport": transport.value - }) - - # For any packet, if we have an SSE stream, try to process it - elif stream.response_headers is not None and stream.is_sse and len(payload) > 0: - # This is SSE data following headers - logger.debug(f"TCP reassembly: SSE data on tracked stream {src_port}->{dst_port}, payload preview: {payload[:50]}") - stream.add_response_data(payload) - sse_messages = stream.extract_sse_messages() - if sse_messages: - logger.debug(f"TCP reassembly: Found {len(sse_messages)} messages in SSE stream") - for msg in sse_messages: - # Update transport tracker if detected - if stream.detected_transport != MCPTransport.UNKNOWN: - self.transport_tracker.update_transport( - src_ip, src_port, dst_ip, dst_port, - stream.detected_transport - ) - - messages.append({ - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, - "message": msg, - "type": "sse_continuation", - "transport": stream.detected_transport.value - }) - - # Catch-all: Handle any data on a stream where we've seen headers - elif len(payload) > 0: - logger.debug(f"TCP reassembly: Catch-all for {src_port}->{dst_port}") - # Check if this stream has headers and might be waiting for body data - if stream.response_headers is not None: - logger.debug(f"TCP reassembly: Data on stream with headers {src_port}->{dst_port}, is_sse={stream.is_sse}") - logger.debug(f"TCP reassembly: Data preview: {payload[:50]}") - - # Add data to the stream - stream.add_response_data(payload) - - # If it's an SSE stream, try to extract messages - if stream.is_sse: - logger.debug(f"TCP reassembly: Processing SSE stream, buffer now has {len(stream.buffer)} bytes") - sse_messages = stream.extract_sse_messages() - logger.debug(f"TCP reassembly: Extracted {len(sse_messages)} SSE messages from continuation") - for msg in sse_messages: - messages.append({ - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, - "message": msg, - "type": "sse_continuation" - }) - elif src_port == 8765 or dst_port == 8765: - logger.debug(f"TCP reassembly: Unhandled packet {src_port}->{dst_port}, no headers yet") - logger.debug(f"TCP reassembly: Payload starts with: {payload[:50]}") - - return messages - - def cleanup_old_streams(self, timeout: int = 300): - """Remove old streams to prevent memory leaks.""" - # TODO: Implement timeout-based cleanup - pass diff --git a/mcphawk/transport_detector.py b/mcphawk/transport_detector.py deleted file mode 100644 index 9d592e6..0000000 --- a/mcphawk/transport_detector.py +++ /dev/null @@ -1,197 +0,0 @@ -"""MCP transport detection logic.""" - -import logging -from enum import Enum -from typing import Optional - -logger = logging.getLogger(__name__) - - -class MCPTransport(Enum): - """MCP transport types.""" - STDIO = "stdio" - STREAMABLE_HTTP = "streamable_http" # Single endpoint, 2025-03-26+ - HTTP_SSE = "http_sse" # Separate endpoints, deprecated - UNKNOWN = "unknown" - - -def detect_transport_from_http( - method: str, - path: str, - headers: dict[str, str], - is_sse_response: bool = False, - response_contains_endpoint_event: bool = False -) -> MCPTransport: - """ - Detect MCP transport type from HTTP traffic. - - HTTP+SSE (legacy): - - GET request with Accept: text/event-stream - - Server sends "endpoint" event with POST URL - - Separate endpoints for SSE and POST - - POST requests don't have special Accept headers - - Streamable HTTP: - - POST requests with Accept: application/json, text/event-stream (BOTH required) - - Single endpoint for all requests - - No "endpoint" event - - Dynamic SSE upgrade when needed - - Key differences in Accept headers: - - HTTP+SSE: GET with Accept: text/event-stream (single type) - - Streamable HTTP: POST with Accept: application/json, text/event-stream (dual types) - - Args: - method: HTTP method (GET, POST) - path: HTTP path - headers: HTTP headers - is_sse_response: Whether response has SSE content-type - response_contains_endpoint_event: Whether SSE stream contains endpoint event - - Returns: - Detected MCP transport type - """ - # HTTP+SSE: GET with Accept: text/event-stream (single type) - if method == "GET" and headers.get("accept", "").lower() == "text/event-stream": - # This is HTTP+SSE establishing SSE connection - if response_contains_endpoint_event: - logger.debug("Detected HTTP+SSE transport (endpoint event found)") - return MCPTransport.HTTP_SSE - # Could still be HTTP+SSE without seeing the response yet - logger.debug("Possible HTTP+SSE transport (GET with SSE accept)") - return MCPTransport.HTTP_SSE - - # Streamable HTTP: POST with Accept: application/json, text/event-stream (BOTH types) - accept_header = headers.get("accept", "").lower() - if method == "POST" and "application/json" in accept_header and "text/event-stream" in accept_header: - logger.debug("Detected Streamable HTTP transport (POST with dual accept)") - return MCPTransport.STREAMABLE_HTTP - - # POST without proper dual Accept headers cannot be identified - # It could be either: - # - Streamable HTTP missing Accept headers (incorrect client) - # - HTTP+SSE posting to the endpoint URL (correct behavior) - - # If we see SSE response without prior transport detection, make best guess - if is_sse_response: - # In Streamable HTTP, SSE is a response to POST - # In HTTP+SSE, SSE is response to GET - logger.debug(f"SSE response detected, method was {method}") - if method == "POST": - return MCPTransport.STREAMABLE_HTTP - else: - return MCPTransport.HTTP_SSE - - logger.debug("Unable to definitively detect transport type") - return MCPTransport.UNKNOWN - - -def extract_endpoint_from_sse(sse_data: str) -> Optional[str]: - """ - Extract endpoint URL from SSE data (for HTTP+SSE transport). - - The endpoint event in HTTP+SSE looks like: - event: endpoint - data: {"url": "http://localhost:8080/messages"} - - Args: - sse_data: SSE message data - - Returns: - Endpoint URL if found, None otherwise - """ - import json - - lines = sse_data.strip().split('\n') - event_type = None - data_content = None - - for line in lines: - if line.startswith('event:'): - event_type = line[6:].strip() - elif line.startswith('data:'): - data_content = line[5:].strip() - - if event_type == "endpoint" and data_content: - try: - data = json.loads(data_content) - return data.get("url") - except json.JSONDecodeError: - logger.debug(f"Failed to parse endpoint data: {data_content}") - - return None - - -class TransportTracker: - """Track transport type per connection and server.""" - - def __init__(self): - self.transports: dict[tuple[str, int, str, int], MCPTransport] = {} - self.endpoint_urls: dict[tuple[str, int, str, int], str] = {} - # Track HTTP+SSE servers by IP:port - self.http_sse_servers: dict[tuple[str, int], MCPTransport] = {} - - def update_transport( - self, - src_ip: str, - src_port: int, - dst_ip: str, - dst_port: int, - transport: MCPTransport - ) -> None: - """Update transport type for a connection.""" - key = (src_ip, src_port, dst_ip, dst_port) - # Also store reverse direction - reverse_key = (dst_ip, dst_port, src_ip, src_port) - - if transport != MCPTransport.UNKNOWN: - self.transports[key] = transport - self.transports[reverse_key] = transport - logger.debug(f"Updated transport for {key}: {transport.value}") - - # For HTTP+SSE, also track the server endpoint - if transport == MCPTransport.HTTP_SSE: - server_key = (dst_ip, dst_port) - self.http_sse_servers[server_key] = transport - logger.debug(f"Marked server {dst_ip}:{dst_port} as HTTP+SSE") - - def get_transport( - self, - src_ip: str, - src_port: int, - dst_ip: str, - dst_port: int - ) -> MCPTransport: - """Get transport type for a connection.""" - key = (src_ip, src_port, dst_ip, dst_port) - - # First check if we have transport for this exact connection - if key in self.transports: - return self.transports[key] - - # For HTTP+SSE, check if the destination OR source is a known HTTP+SSE server - dst_server_key = (dst_ip, dst_port) - if dst_server_key in self.http_sse_servers: - logger.debug(f"Found HTTP+SSE server {dst_ip}:{dst_port} for new connection") - return self.http_sse_servers[dst_server_key] - - # Also check if source is an HTTP+SSE server (for responses) - src_server_key = (src_ip, src_port) - if src_server_key in self.http_sse_servers: - logger.debug(f"Response from HTTP+SSE server {src_ip}:{src_port}") - return self.http_sse_servers[src_server_key] - - return MCPTransport.UNKNOWN - - def store_endpoint_url( - self, - src_ip: str, - src_port: int, - dst_ip: str, - dst_port: int, - endpoint_url: str - ) -> None: - """Store endpoint URL for HTTP+SSE transport.""" - key = (src_ip, src_port, dst_ip, dst_port) - self.endpoint_urls[key] = endpoint_url - logger.debug(f"Stored endpoint URL for {key}: {endpoint_url}") diff --git a/mcphawk/utils.py b/mcphawk/utils.py deleted file mode 100644 index cb2ce9e..0000000 --- a/mcphawk/utils.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Utility functions for MCPHawk.""" - -import json -from typing import Any, Optional - - -def parse_message(message: str) -> Optional[dict[str, Any]]: - """Parse a JSON message string.""" - try: - if isinstance(message, str): - return json.loads(message) - return message - except (json.JSONDecodeError, TypeError): - return None - - -def get_message_type(message: str) -> str: - """ - Determine the type of a JSON-RPC message. - - Returns: 'request', 'response', 'notification', 'error', or 'unknown' - """ - parsed = parse_message(message) - if not parsed: - return "unknown" - - # Check if it's a valid JSON-RPC 2.0 message - if parsed.get("jsonrpc") != "2.0": - return "unknown" - - # Error response (has error and id) - if "error" in parsed and "id" in parsed: - return "error" - - # Response (has result and id) - if "result" in parsed and "id" in parsed: - return "response" - - # Request (has method and id) - if "method" in parsed and "id" in parsed: - return "request" - - # Notification (has method but no id) - if "method" in parsed and "id" not in parsed: - return "notification" - - return "unknown" - - -def get_method_name(message: str) -> Optional[str]: - """Extract method name from a JSON-RPC message.""" - parsed = parse_message(message) - return parsed.get("method") if parsed else None - - -def extract_server_info(message: str) -> Optional[dict[str, str]]: - """ - Extract serverInfo from an initialize response. - - Returns dict with 'name' and 'version' if found, None otherwise. - """ - parsed = parse_message(message) - if not parsed: - return None - - # Check if this is an initialize response - if parsed.get("jsonrpc") == "2.0" and "result" in parsed: - result = parsed.get("result", {}) - if isinstance(result, dict) and "serverInfo" in result: - server_info = result["serverInfo"] - if isinstance(server_info, dict) and "name" in server_info: - return { - "name": server_info.get("name", "unknown"), - "version": server_info.get("version", "unknown") - } - - return None - - -def extract_client_info(message: str) -> Optional[dict[str, str]]: - """ - Extract clientInfo from an initialize request. - - Returns dict with 'name' and 'version' if found, None otherwise. - """ - parsed = parse_message(message) - if not parsed: - return None - - # Check if this is an initialize request - if (parsed.get("jsonrpc") == "2.0" and - parsed.get("method") == "initialize" and - "params" in parsed): - params = parsed.get("params", {}) - if isinstance(params, dict) and "clientInfo" in params: - client_info = params["clientInfo"] - if isinstance(client_info, dict) and "name" in client_info: - return { - "name": client_info.get("name", "unknown"), - "version": client_info.get("version", "unknown") - } - - return None diff --git a/mcphawk/web/app.py b/mcphawk/web/app.py new file mode 100644 index 0000000..4f23c19 --- /dev/null +++ b/mcphawk/web/app.py @@ -0,0 +1,207 @@ +"""``mcphawk up``: web UI, JSON API, live updates, HTTP proxy and the MCPHawk +MCP endpoint, all on one local port.""" + +import asyncio +import contextlib +import logging +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import Query as Param +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from starlette.routing import Route + +from mcphawk import links +from mcphawk.analysis import cost, drift, lint, problems +from mcphawk.capture.http_proxy import Proxy +from mcphawk.install import installer +from mcphawk.query import Query +from mcphawk.store import Recorder + +logger = logging.getLogger(__name__) + +STATIC_DIR = Path(__file__).parent / "static" +LIVE_POLL_S = 0.5 +SEVERITIES = ("error", "warning", "info") + + +def create_app( + db: Path | str | None = None, + *, + upstreams: Callable[[], dict[str, str]] | None = None, + with_mcp: bool = True, + static_dir: Path | None = STATIC_DIR, + mask: bool = True, +) -> FastAPI: + q = Query(db) + recorder = Recorder(db, mask=mask) + proxy = Proxy(upstreams or installer.load_registry, recorder) + mcp_http = None + if with_mcp: + from mcphawk.mcp_server import build_server + + mcp_http = build_server(db).streamable_http_app(streamable_http_path="/mcp") + + @contextlib.asynccontextmanager + async def lifespan(app: FastAPI): + async with contextlib.AsyncExitStack() as stack: + if mcp_http is not None: + await stack.enter_async_context(mcp_http.router.lifespan_context(mcp_http)) + yield + await proxy.aclose() + q.close() + recorder.close() + + app = FastAPI(title="MCPHawk", lifespan=lifespan, docs_url="/api/docs", + openapi_url="/api/openapi.json") + app.state.query = q + app.state.recorder = recorder + app.state.proxy = proxy + + def need(value: Any, what: str) -> Any: + if value is None: + raise HTTPException(404, f"{what} not found") + return value + + # -- read API --------------------------------------------------------- + + @app.get("/api/stats") + def stats() -> dict[str, Any]: + return {**q.stats(), "proxies": proxy.upstreams(), "base_url": links.base_url()} + + @app.get("/api/runs") + def runs(limit: int = 50) -> list[dict[str, Any]]: + return q.list_runs(limit=limit) + + @app.get("/api/runs/{run_key:path}") + def run(run_key: str) -> dict[str, Any]: + return need(q.get_run(run_key), "run") + + @app.get("/api/sessions") + def sessions(run_key: str | None = None, server: str | None = None, + limit: int = 100) -> list[dict[str, Any]]: + return q.list_sessions(run_key=run_key, server=server, limit=limit) + + @app.get("/api/sessions/{session_id}") + def session(session_id: str) -> dict[str, Any]: + return need(q.get_session(session_id), "session") + + @app.get("/api/sessions/{session_id}/messages") + def session_messages(session_id: str, after_id: int = 0, + limit: int = 500) -> list[dict[str, Any]]: + return q.messages(session_id=session_id, after_id=after_id, limit=limit, + include_hidden=True) + + @app.get("/api/sessions/{session_id}/lint") + def session_lint(session_id: str) -> list[dict[str, Any]]: + need(q.get_session_header(session_id), "session") + return lint.lint_session(q, session_id) + + @app.get("/api/exchanges") + def exchanges(session_id: str | None = None, status: str | None = None, + method: str | None = None, target: str | None = None, + q_: str | None = Param(None, alias="q"), + limit: int = 500) -> list[dict[str, Any]]: + return q.exchanges(session_id=session_id, status=status, method=method, + target=target, text=q_, limit=limit) + + @app.get("/api/exchanges/{exchange_id}") + def exchange(exchange_id: int) -> dict[str, Any]: + return need(q.get_exchange(exchange_id), "exchange") + + @app.get("/api/messages/{message_id}") + def message(message_id: int) -> dict[str, Any]: + return need(q.get_message(message_id), "message") + + @app.get("/api/problems") + def problem_list(session_id: str | None = None, run_key: str | None = None, + server: str | None = None, min_severity: str = "warning", + limit: int = 200) -> dict[str, Any]: + if min_severity not in SEVERITIES: + raise HTTPException(422, "min_severity must be error, warning or info") + return problems.find_problems(q, session_id=session_id, run_key=run_key, + server=server, min_severity=min_severity, limit=limit) + + @app.get("/api/cost") + def context_cost(session_id: str | None = None, run_key: str | None = None, + server: str | None = None) -> dict[str, Any]: + return cost.context_cost(q, session_id=session_id, run_key=run_key, server=server) + + @app.get("/api/compare") + def compare(before: str, after: str) -> dict[str, Any]: + report = drift.compare_sessions(q, before, after) + if "error" in report: + raise HTTPException(404, report["error"]) + return report + + @app.get("/api/setup") + def setup() -> dict[str, Any]: + """Dry-run of ``mcphawk install``: what is and is not routed through us.""" + changes = installer.plan(include_http=True, registry=dict(installer.load_registry())) + return {"servers": [{ + "client": c.table.client, "scope": c.table.scope, "file": str(c.table.path), + "server": c.server, + "status": ("would wrap" if c.action == installer.WRAP + else "would proxy" if c.action == installer.PROXY else c.reason), + } for c in changes]} + + @app.delete("/api/data") + def clear(confirm: bool = False) -> dict[str, Any]: + if not confirm: + raise HTTPException(400, "pass confirm=true to delete all captured traffic") + q.clear() + return {"cleared": True} + + # -- live updates ----------------------------------------------------- + + @app.websocket("/api/live") + async def live(ws: WebSocket) -> None: + """Pushes new message summaries; works across processes by tailing the DB.""" + await ws.accept() + last = q.latest_message_id() + try: + while True: + fresh = await asyncio.to_thread(q.messages, after_id=last, limit=500) + if fresh: + last = fresh[-1]["id"] + await ws.send_json({"type": "messages", "items": fresh}) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ws.receive_text(), timeout=LIVE_POLL_S) + except (WebSocketDisconnect, RuntimeError): + return + + # -- proxy and MCP ---------------------------------------------------- + + for route in proxy.routes(): + app.router.routes.append(route) + if mcp_http is not None: + for route in mcp_http.routes: + if isinstance(route, Route): + app.router.routes.append(route) + + # -- single page app -------------------------------------------------- + + index = static_dir / "index.html" if static_dir else None + if index and index.exists(): + assets = static_dir / "assets" + if assets.exists(): + app.mount("/assets", StaticFiles(directory=assets), name="assets") + + @app.get("/{path:path}", include_in_schema=False) + def spa(path: str) -> FileResponse: + if path.startswith(("api/", "p/", "mcp")): + raise HTTPException(404) + candidate = (static_dir / path).resolve() + if path and candidate.is_file() and static_dir.resolve() in candidate.parents: + return FileResponse(candidate) + return FileResponse(index) + else: + @app.get("/", include_in_schema=False) + def no_ui() -> JSONResponse: + return JSONResponse({"message": "MCPHawk API is running; the web UI is not built.", + "api": "/api/docs"}) + + return app diff --git a/mcphawk/web/broadcaster.py b/mcphawk/web/broadcaster.py deleted file mode 100644 index 13462bc..0000000 --- a/mcphawk/web/broadcaster.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -from typing import Any - -from fastapi import WebSocket - -# Set up logger for this module -logger = logging.getLogger(__name__) - -active_clients: list[WebSocket] = [] - - -async def broadcast_new_log(log_entry: dict[str, Any]): - """ - Broadcast a new log entry to all connected WebSocket clients. - - Args: - log_entry: Dictionary representing the new log entry. - """ - if not active_clients: - return - - disconnected = [] - # Only show client count, not the full log entry - logger.debug(f"Broadcasting to {len(active_clients)} clients") - - for ws in active_clients: - try: - await ws.send_json(log_entry) - except Exception as e: - logger.debug(f"Failed to send to client: {type(e).__name__}") - disconnected.append(ws) - - # Clean up disconnected clients - if disconnected: - for ws in disconnected: - if ws in active_clients: - active_clients.remove(ws) - logger.debug(f"Removed {len(disconnected)} disconnected clients, {len(active_clients)} remaining") diff --git a/mcphawk/web/server.py b/mcphawk/web/server.py deleted file mode 100644 index 3b4e3b8..0000000 --- a/mcphawk/web/server.py +++ /dev/null @@ -1,165 +0,0 @@ -import asyncio -import logging -import os -import threading -from typing import Optional - -from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from fastapi.staticfiles import StaticFiles - -from mcphawk.logger import fetch_logs -from mcphawk.web.broadcaster import active_clients - -# Set up logger for this module -logger = logging.getLogger(__name__) - -# Global flag to track if web server was started with MCP -_with_mcp = False - -app = FastAPI() - -# Allow local UI dev or CDN-based dashboard -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/status") -def get_status(): - """ - Get server status including MCP server status. - """ - return JSONResponse(content={ - "with_mcp": _with_mcp - }) - - -@app.get("/logs") -def get_logs(limit: int = 50): - """ - Retrieve recent logs from the database. - - Args: - limit: Maximum number of logs to return (default: 50). - - Returns: - List of log entries as JSON-serializable dictionaries. - """ - logs = fetch_logs(limit) - logger.debug(f"/logs returned {len(logs)} entries") - return JSONResponse(content=[ - { - **log, - "timestamp": log["timestamp"].isoformat(), # ensure JSON-friendly - "transport_type": log.get("transport_type", "unknown") # ensure transport_type is included - } - for log in logs - ]) - - -@app.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket): - """ - WebSocket endpoint for live traffic updates. - Keeps the connection alive until the client disconnects. - """ - await websocket.accept() - active_clients.append(websocket) - logger.debug(f"WebSocket connected: {len(active_clients)} active clients") - - try: - while True: - # Keep connection alive with ping - try: - await asyncio.wait_for( - websocket.receive_text(), # Wait for any message - timeout=30.0 - ) - except asyncio.TimeoutError: - # Send ping to check if client is still alive - try: - await websocket.send_json({"type": "ping"}) - except Exception: - break - except (WebSocketDisconnect, ConnectionResetError, Exception) as e: - if not isinstance(e, WebSocketDisconnect): - logger.debug(f"WebSocket error: {type(e).__name__}: {e}") - finally: - if websocket in active_clients: - active_clients.remove(websocket) - logger.debug(f"WebSocket disconnected: {len(active_clients)} active clients") - - -def _start_sniffer_thread(filter_expr: str, auto_detect: bool = False, debug: bool = False, excluded_ports: Optional[list[int]] = None): - """ - Start the sniffer in a dedicated daemon thread. - - Args: - filter_expr: BPF filter expression for the sniffer. - auto_detect: Whether to auto-detect MCP traffic. - debug: Whether to enable debug logging. - excluded_ports: List of ports to exclude from capture. - """ - from mcphawk.sniffer import start_sniffer - - def safe_start(): - logger.debug(f"Sniffer thread starting with filter: {filter_expr}, auto_detect: {auto_detect}") - return start_sniffer(filter_expr=filter_expr, auto_detect=auto_detect, debug=debug, excluded_ports=excluded_ports) - - thread = threading.Thread(target=safe_start, daemon=True) - thread.start() - - -def run_web(sniffer: bool = True, host: str = "127.0.0.1", port: int = 8000, filter_expr: Optional[str] = None, auto_detect: bool = False, debug: bool = False, excluded_ports: Optional[list[int]] = None, with_mcp: bool = False): - """ - Run the web server and optionally the sniffer. - - Args: - sniffer: Whether to start the sniffer in a background thread. - host: Host to bind the server to. - port: Port to run the server on. - filter_expr: BPF filter expression for the sniffer. - auto_detect: Whether to auto-detect MCP traffic. - debug: Whether to enable debug logging. - """ - # Set global MCP flag - global _with_mcp - _with_mcp = with_mcp - - # Configure logging based on debug flag first - if debug: - logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(message)s') - logger.setLevel(logging.DEBUG) - log_level = "debug" - else: - logging.basicConfig(level=logging.INFO, format='%(message)s') - logger.setLevel(logging.INFO) - log_level = "warning" # Only show warnings and errors for uvicorn - - if sniffer: - if not filter_expr: - raise ValueError("filter_expr is required when sniffer is enabled") - _start_sniffer_thread(filter_expr, auto_detect, debug, excluded_ports) - - if sniffer: - logger.info(f"Starting sniffer and dashboard on http://{host}:{port}") - logger.info(f"Using filter: {filter_expr}") - if auto_detect: - logger.info("Auto-detect mode enabled") - else: - logger.info(f"Starting dashboard only (no sniffer) on http://{host}:{port}") - - import uvicorn - uvicorn.run(app, host=host, port=port, log_level=log_level) - - -# Serve static Vue dashboard (production mode) -static_dir = os.path.join(os.path.dirname(__file__), "static") -if os.path.exists(static_dir): - app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") diff --git a/mcphawk/wrapper.py b/mcphawk/wrapper.py deleted file mode 100644 index 2c66918..0000000 --- a/mcphawk/wrapper.py +++ /dev/null @@ -1,306 +0,0 @@ -"""MCP server wrapper for transparent stdio monitoring.""" - -import asyncio -import contextlib -import json -import logging -import os -import signal -import subprocess -import sys -import threading -import time -import uuid -from datetime import datetime, timezone -from typing import Optional - -from mcphawk.logger import log_message -from mcphawk.stdio_server_detector_fallback import ( - detect_server_from_command, - merge_server_info, -) -from mcphawk.web.broadcaster import broadcast_new_log - -logger = logging.getLogger(__name__) - - -class MCPWrapper: - """Transparently wrap an MCP server to capture stdio traffic.""" - - def __init__(self, command: list[str], debug: bool = False): - self.command = command - self.debug = debug - self.proc: Optional[subprocess.Popen] = None - self.running = False - self.server_info = None # Track server info from initialize response - self.client_info = None # Track client info from initialize request - self.server_info_fallback = detect_server_from_command(command) # Fallback detection - self.stdin_thread: Optional[threading.Thread] = None - self.stdout_thread: Optional[threading.Thread] = None - self.stderr_thread: Optional[threading.Thread] = None - - def start(self) -> int: - """Start the wrapper and return exit code.""" - try: - # Start the actual MCP server - logger.info(f"Starting MCP server: {' '.join(self.command)}") - - self.proc = subprocess.Popen( - self.command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0 # Unbuffered for real-time - ) - - self.running = True - - # Start forwarding threads - self.stdin_thread = threading.Thread( - target=self._forward_stdin, - daemon=True - ) - self.stdout_thread = threading.Thread( - target=self._forward_stdout, - daemon=True - ) - self.stderr_thread = threading.Thread( - target=self._forward_stderr, - daemon=True - ) - - self.stdin_thread.start() - self.stdout_thread.start() - self.stderr_thread.start() - - # Wait for process to complete - return_code = self.proc.wait() - self.running = False - - # Give threads time to finish - time.sleep(0.1) - - return return_code - - except KeyboardInterrupt: - logger.info("Wrapper interrupted") - self.stop() - return 130 # Standard exit code for SIGINT - except Exception as e: - logger.error(f"Wrapper error: {e}") - self.stop() - return 1 - - def stop(self): - """Stop the wrapper and subprocess.""" - self.running = False - if self.proc: - self.proc.terminate() - try: - self.proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self.proc.kill() - - def _forward_stdin(self): - """Forward stdin from parent to subprocess, capturing JSON-RPC.""" - try: - buffer = "" - while self.running and self.proc and self.proc.stdin: - # Read from our stdin - char = sys.stdin.read(1) - if not char: - break - - # Forward to subprocess - self.proc.stdin.write(char) - self.proc.stdin.flush() - - # Build buffer for JSON detection - buffer += char - - # Check for complete JSON messages - if char == '\n': - line = buffer.strip() - if line: - self._try_parse_json(line, "client->server") - buffer = "" - - except Exception as e: - if self.debug: - logger.debug(f"stdin forward error: {e}") - - def _forward_stdout(self): - """Forward stdout from subprocess to parent, capturing JSON-RPC.""" - try: - buffer = "" - while self.running and self.proc and self.proc.stdout: - # Read from subprocess - char = self.proc.stdout.read(1) - if not char: - break - - # Forward to our stdout - sys.stdout.write(char) - sys.stdout.flush() - - # Build buffer for JSON detection - buffer += char - - # Check for complete JSON messages - if char == '\n': - line = buffer.strip() - if line: - self._try_parse_json(line, "server->client") - buffer = "" - - except Exception as e: - if self.debug: - logger.debug(f"stdout forward error: {e}") - - def _forward_stderr(self): - """Forward stderr from subprocess to parent.""" - try: - while self.running and self.proc and self.proc.stderr: - # Read from subprocess - char = self.proc.stderr.read(1) - if not char: - break - - # Forward to our stderr - sys.stderr.write(char) - sys.stderr.flush() - - except Exception as e: - if self.debug: - logger.debug(f"stderr forward error: {e}") - - def _try_parse_json(self, line: str, direction: str): - """Try to parse a line as JSON-RPC and log if successful.""" - try: - # Skip empty lines - if not line.strip(): - return - - # Try to parse as JSON - msg = json.loads(line) - - # Check if it's JSON-RPC - if msg.get("jsonrpc") == "2.0": - # Extract server/client info if this is an initialize message - if direction == "client->server": - # Check for client info in initialize request - from .utils import extract_client_info - client_info = extract_client_info(json.dumps(msg)) - if client_info: - self.client_info = client_info - if self.debug: - logger.debug(f"Captured client info: {client_info}") - - elif direction == "server->client": - # Check for server info in initialize response - from .utils import extract_server_info - server_info = extract_server_info(json.dumps(msg)) - if server_info: - self.server_info = server_info - if self.debug: - logger.debug(f"Captured server info: {server_info}") - - self._log_jsonrpc_message(msg, direction) - - except json.JSONDecodeError: - # Not JSON, ignore - pass - except Exception as e: - if self.debug: - logger.debug(f"Error parsing JSON: {e}") - - def _log_jsonrpc_message(self, message: dict, direction: str): - """Log a JSON-RPC message to the database.""" - try: - # Create log entry - ts = datetime.now(tz=timezone.utc) - log_id = str(uuid.uuid4()) - - # Parse direction - if direction == "client->server": - src_ip = "mcp-client" - dst_ip = "mcp-server" - flow_direction = "outgoing" - else: - src_ip = "mcp-server" - dst_ip = "mcp-client" - flow_direction = "incoming" - - # Get process info - pid = self.proc.pid if self.proc else os.getpid() - - # Build metadata with server info - metadata = { - "wrapper": True, - "command": self.command, - "direction": direction - } - - # Merge server info (protocol takes precedence over fallback) - merged_server_info = merge_server_info(self.server_info_fallback, self.server_info) - if merged_server_info: - metadata["server_name"] = merged_server_info["name"] - metadata["server_version"] = merged_server_info["version"] - - # Add client info if we have it - if self.client_info: - metadata["client_name"] = self.client_info["name"] - metadata["client_version"] = self.client_info["version"] - - entry = { - "log_id": log_id, - "timestamp": ts, - "src_ip": src_ip, - "src_port": None, # No port for stdio - "dst_ip": dst_ip, - "dst_port": None, # No port for stdio - "direction": flow_direction, - "message": json.dumps(message), - "transport_type": "stdio", - "metadata": json.dumps(metadata), - "pid": pid # Add PID field - } - - # Log to database - log_message(entry) - - # Broadcast to web UI - broadcast_entry = dict(entry) - broadcast_entry["timestamp"] = ts.isoformat() - - # Try to broadcast - try: - loop = asyncio.get_running_loop() - _ = loop.create_task(broadcast_new_log(broadcast_entry)) # noqa: RUF006 - except RuntimeError: - # No event loop in this thread, try to create one - with contextlib.suppress(Exception): - asyncio.run(broadcast_new_log(broadcast_entry)) - - # Log info - method = message.get("method", "response") - logger.info(f"Captured {direction} JSON-RPC: {method}") - - except Exception as e: - logger.error(f"Error logging JSON-RPC message: {e}") - - -def run_wrapper(command: list[str], debug: bool = False) -> int: - """Run the MCP wrapper with the given command.""" - # Set up signal handling - def signal_handler(signum, frame): - logger.info("Received signal, shutting down wrapper") - sys.exit(130) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - # Create and run wrapper - wrapper = MCPWrapper(command, debug=debug) - return wrapper.start() diff --git a/pyproject.toml b/pyproject.toml index be1f63c..5324871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,8 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.run] omit = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index a0a5e1f..504e81a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,5 @@ -r requirements.txt -httpx==0.28.1 pytest>=8.0.0 -pytest-asyncio==1.1.0 +pytest-asyncio>=1.1.0 pytest-cov>=5.0.0 ruff>=0.8.0 -websockets>=12.0 -requests>=2.31.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a104daf..e0c9ad9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -click<8.2.0,>=8.1.3 -fastapi==0.116.1 -scapy==2.5.0 -psutil==6.0.0 -typer>=0.12.0 +fastapi>=0.116.1 +httpx>=0.28.1 +mcp>=2.2.0,<3 +psutil>=6.0.0 +scapy>=2.5.0 +typer>=0.16.0 uvicorn[standard]>=0.35.0 websockets>=12.0 -mcp>=1.0.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..589ece0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +import pytest + +from mcphawk.query import Query +from mcphawk.store import Recorder + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + """Never touch the real ~/.mcphawk from tests.""" + home = tmp_path / "mcphawk-home" + monkeypatch.setenv("MCPHAWK_HOME", str(home)) + monkeypatch.setenv("MCPHAWK_DB", str(home / "test.db")) + return home + + +@pytest.fixture +def db(isolated_home): + return isolated_home / "test.db" + + +@pytest.fixture +def recorder(db): + rec = Recorder(db) + yield rec + rec.close() + + +@pytest.fixture +def query(db, recorder): + q = Query(db) + yield q + q.close() diff --git a/tests/fixtures/echo_server.py b/tests/fixtures/echo_server.py new file mode 100644 index 0000000..7c201de --- /dev/null +++ b/tests/fixtures/echo_server.py @@ -0,0 +1,19 @@ +"""Tiny line-delimited JSON-RPC server used by the stdio shim tests.""" + +import json +import sys + +print("booting (stray stdout line)", flush=True) +print("stderr noise", file=sys.stderr, flush=True) +for line in sys.stdin: + msg = json.loads(line) + if "id" not in msg: + continue + if msg.get("method") == "initialize": + result = {"protocolVersion": "2025-06-18", "capabilities": {}, + "serverInfo": {"name": "echo", "version": "0.1"}} + elif msg.get("method") == "exit": + sys.exit(3) + else: + result = {"echo": msg.get("params")} + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}), flush=True) diff --git a/tests/fixtures/sdk_server.py b/tests/fixtures/sdk_server.py new file mode 100644 index 0000000..235400d --- /dev/null +++ b/tests/fixtures/sdk_server.py @@ -0,0 +1,23 @@ +"""A real MCP server built with the official SDK (stdio or Streamable HTTP).""" + +import sys + +from mcp.server.mcpserver import MCPServer + +server = MCPServer("sdk-weather", version="3.1.4", instructions="Weather lookups.") + + +@server.tool() +def get_weather(city: str) -> str: + """Current weather for a city.""" + return f"Sunny in {city}" + + +@server.tool() +def explode() -> str: + """Always fails.""" + raise ValueError("kaboom") + + +if __name__ == "__main__": + server.run("stdio" if len(sys.argv) < 2 else sys.argv[1]) diff --git a/tests/integration/capture/__init__.py b/tests/integration/capture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/capture/test_http_proxy.py b/tests/integration/capture/test_http_proxy.py new file mode 100644 index 0000000..5620909 --- /dev/null +++ b/tests/integration/capture/test_http_proxy.py @@ -0,0 +1,200 @@ +import json + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from mcphawk.capture.http_proxy import Proxy +from mcphawk.capture.http_sessions import HttpSessions +from mcphawk.query import Query +from tests.traffic import modern_meta, server_meta + +UPSTREAM = "http://upstream.test/mcp" +LEGACY = "http://upstream.test/sse" + + +async def mcp_endpoint(request: Request) -> Response: + msg = json.loads(await request.body()) + if "id" not in msg: + return Response(status_code=202) + if msg["method"] == "initialize": + return JSONResponse( + {"jsonrpc": "2.0", "id": msg["id"], "result": { + "protocolVersion": "2025-06-18", "serverInfo": {"name": "up", "version": "1"}}}, + headers={"Mcp-Session-Id": "sess-1"}) + if msg["method"] == "tools/call": + async def events(): + yield b": ping\n\n" + yield (b'data: {"jsonrpc":"2.0","method":"notifications/progress",' + b'"params":{"progress":1}}\n\n') + body = {"jsonrpc": "2.0", "id": msg["id"], "result": { + "resultType": "complete", "content": [{"type": "text", "text": "ok"}], + "_meta": server_meta("up")}} + yield f"data: {json.dumps(body)}\n\n".encode() + return StreamingResponse(events(), media_type="text/event-stream") + return JSONResponse({"jsonrpc": "2.0", "id": msg["id"], "result": { + "resultType": "complete", "tools": [], "ttlMs": 0, "cacheScope": "public", + "_meta": server_meta("up")}}, headers={"X-Upstream": "yes"}) + + +async def sse_endpoint(request: Request) -> Response: + async def events(): + yield b"event: endpoint\ndata: /messages?session_id=abc\n\n" + yield b'data: {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"old"}}}\n\n' + return StreamingResponse(events(), media_type="text/event-stream") + + +async def get_stream(request: Request) -> Response: + async def events(): + yield b'id: 9\ndata: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}\n\n' + return StreamingResponse(events(), media_type="text/event-stream") + + +async def messages_endpoint(request: Request) -> Response: + return Response(status_code=202) + + +upstream_app = Starlette(routes=[ + Route("/mcp", mcp_endpoint, methods=["POST"]), + Route("/mcp", get_stream, methods=["GET"]), + Route("/sse", sse_endpoint), + Route("/messages", messages_endpoint, methods=["POST"]), +]) + + +@pytest.fixture +async def proxy_client(recorder): + proxy = Proxy({"modern": UPSTREAM, "legacy": LEGACY, "down": "http://down.test/mcp"}, + recorder, transport=_Router()) + app = Starlette(routes=proxy.routes()) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), + base_url="http://proxy.test") as client: + yield client + await proxy.aclose() + + +class _Router(httpx.AsyncBaseTransport): + """Sends upstream.test to the fake server; everything else fails to connect.""" + + def __init__(self): + self._app = httpx.ASGITransport(app=upstream_app) + + async def handle_async_request(self, request): + if request.url.host != "upstream.test": + raise httpx.ConnectError("connection refused", request=request) + return await self._app.handle_async_request(request) + + +def rpc(id=None, method="tools/list", **params): + msg = {"jsonrpc": "2.0", "method": method, "params": {"_meta": modern_meta(), **params}} + if id is not None: + msg["id"] = id + return msg + + +async def test_modern_json_and_sse_responses(proxy_client, db): + listing = await proxy_client.post("/p/modern", json=rpc(1), + headers={"Mcp-Method": "tools/list"}) + assert listing.status_code == 200 + assert listing.headers["x-upstream"] == "yes" + assert listing.json()["result"]["tools"] == [] + + call = await proxy_client.post("/p/modern", json=rpc(2, "tools/call", name="t")) + assert call.headers["content-type"].startswith("text/event-stream") + assert call.text.count("data:") == 2 + assert ": ping" in call.text # passthrough keeps comments + + note = await proxy_client.post("/p/modern", json=rpc(None, "notifications/x")) + assert note.status_code == 202 + + q = Query(db) + [session] = q.list_sessions() + assert session["capture"] == "proxy" + assert session["name"] == "modern" + assert session["client_name"] == "claude-code" + assert session["server_name"] == "up" + detail = q.get_session(session["id"]) + assert [e["status"] for e in detail["exchanges"]] == ["ok", "ok"] + assert detail["notification_count"] == 2 + q.close() + + +async def test_legacy_handshake_binds_session_header(proxy_client, db): + init = {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "clientInfo": {"name": "cursor", "version": "1"}}} + first = await proxy_client.post("/p/modern", json=init) + assert first.headers["mcp-session-id"] == "sess-1" + follow = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + await proxy_client.post("/p/modern", json=follow, headers={"Mcp-Session-Id": "sess-1"}) + stream = await proxy_client.get("/p/modern", headers={"Mcp-Session-Id": "sess-1", + "Accept": "text/event-stream"}) + assert "list_changed" in stream.text + + q = Query(db) + [session] = q.list_sessions() + assert session["era"] == "legacy" + assert session["exchange_count"] == 2 + assert q.get_session(session["id"])["notification_count"] == 1 + q.close() + + +async def test_legacy_http_sse_rewrites_endpoint(proxy_client, db): + stream = await proxy_client.get("/p/legacy", headers={"Accept": "text/event-stream"}) + assert "data: /p/legacy/~/messages?session_id=abc" in stream.text + posted = await proxy_client.post("/p/legacy/~/messages?session_id=abc", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + assert posted.status_code == 202 + + q = Query(db) + [session] = q.list_sessions() + assert session["transport"] == "http_sse" + assert session["server_name"] is None # response 1 had no matching request + assert q.get_session(session["id"])["message_count"] == 2 + q.close() + + +async def test_unknown_upstream_and_unreachable(proxy_client, db): + missing = await proxy_client.post("/p/nope", json=rpc(1)) + assert missing.status_code == 404 + down = await proxy_client.post("/p/down", json=rpc(5, "tools/call", name="x")) + assert down.status_code == 502 + q = Query(db) + [exchange] = q.exchanges() + assert exchange["status"] == "error" + assert "upstream unreachable" in exchange["error_message"] + q.close() + + +async def test_sub_paths(recorder): + proxy = Proxy(lambda: {"a": "http://h.test/base/"}, recorder) + assert proxy._upstream_url("a", "", "") == "http://h.test/base/" + assert proxy._upstream_url("a", "x/y", "q=1") == "http://h.test/base/x/y?q=1" + assert proxy._upstream_url("a", "~/root", "") == "http://h.test/root" + assert proxy._upstream_url("a", "~", "") == "http://h.test/" + assert proxy._upstream_url("b", "", "") is None + await proxy.aclose() + + +def test_serialize_endpoint_variants(): + event = type("E", (), {"event": "endpoint", "data": "http://other.test/m", "id": "3"}) + assert Proxy._serialize(event, "n", UPSTREAM) == ( + b"event: endpoint\nid: 3\ndata: http://other.test/m\n\n") + relative = type("E", (), {"event": "endpoint", "data": "messages?s=1", "id": None}) + assert b"data: /p/n/~/messages?s=1" in Proxy._serialize(relative, "n", UPSTREAM) + + +def test_http_sessions_rollover_and_unknown_stream(recorder): + clock = [1000.0] + sessions = HttpSessions(recorder, "proxy", now=lambda: clock[0]) + a = sessions.for_request("u", {}, [rpc(1)]) + assert sessions.for_request("u", {}, [rpc(2)]) == a + clock[0] += 31 * 60 + assert sessions.for_request("u", {}, [rpc(3)]) != a + anon = sessions.for_request("u", None, [{"jsonrpc": "2.0", "id": 1, "method": "x"}]) + assert anon != a + assert sessions.for_sse_stream("u", {}) is None + assert sessions.for_sse_stream("u", {"Mcp-Session-Id": "nope"}) is None + sessions.bind_session_header(a, "u", None) diff --git a/tests/integration/capture/test_stdio_shim.py b/tests/integration/capture/test_stdio_shim.py new file mode 100644 index 0000000..e3a4714 --- /dev/null +++ b/tests/integration/capture/test_stdio_shim.py @@ -0,0 +1,186 @@ +import io +import json +import os +import sys +from pathlib import Path + +from mcphawk.capture import process, stdio +from mcphawk.capture.stdio import LineFramer, StdioShim +from mcphawk.query import Query + +SERVER = str(Path(__file__).resolve().parents[2] / "fixtures" / "echo_server.py") + + +def lines(*msgs): + return b"".join(json.dumps(m).encode() + b"\n" for m in msgs) + + +def run_shim(recorder, stdin_bytes, command=None, **kwargs): + stdout, stderr = io.BytesIO(), io.BytesIO() + shim = StdioShim(command or [sys.executable, SERVER, "--api-key", "hunter2"], + recorder=recorder, stdin=io.BytesIO(stdin_bytes), stdout=stdout, + stderr=stderr, **kwargs) + code = shim.run() + return shim, code, stdout.getvalue(), stderr.getvalue() + + +def test_shim_forwards_verbatim_and_records(recorder, db): + request = lines( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "clientInfo": {"name": "t", "version": "1"}}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "x"}}, + ) + shim, code, out, err = run_shim(recorder, request, name="echo") + assert code == 0 + assert out.startswith(b"booting (stray stdout line)\n") + assert out.count(b'"jsonrpc"') == 2 + assert b"stderr noise" in err + + q = Query(db) + session = q.get_session(shim.session_id) + assert session["name"] == "echo" + assert session["server_name"] == "echo" + assert session["client_name"] == "t" + assert session["transport"] == "stdio" + assert session["pid"] == os.getpid() + assert session["ended_at"] is not None + assert "hunter2" not in session["target"] + assert [e["status"] for e in session["exchanges"]] == ["ok", "ok"] + assert session["invalid_count"] == 1 # the stray stdout line + q.close() + + +def test_shim_reports_exit_code_and_missing_binary(recorder): + _, code, _, _ = run_shim(recorder, lines({"jsonrpc": "2.0", "id": 1, "method": "exit"})) + assert code == 3 + _, code, _, err = run_shim(recorder, b"", command=["/definitely/not/here"]) + assert code == 127 + assert b"cannot start" in err + + +def test_shim_requires_command(recorder): + try: + StdioShim([], recorder=recorder) + except ValueError as exc: + assert "no server command" in str(exc) + else: # pragma: no cover + raise AssertionError("expected ValueError") + + +def test_run_wrap_maps_signals(monkeypatch): + class FakeShim: + def __init__(self, *args, **kwargs): + pass + + def terminate(self, *args): # pragma: no cover - never signalled here + pass + + def run(self): + return -15 + + monkeypatch.setattr(stdio, "StdioShim", FakeShim) + monkeypatch.setattr(stdio.signal, "signal", lambda *a: None) + assert stdio.run_wrap(["x"]) == 143 + + +def test_line_framer_edge_cases(monkeypatch): + seen = [] + framer = LineFramer(seen.append) + framer.feed(b"a\nb") + framer.feed(b"c\n\n \n") + framer.flush() + framer.flush() + assert seen == [b"a", b"bc"] + monkeypatch.setattr(stdio, "MAX_LINE", 4) + framer.feed(b"123456") + assert seen[-1] == b"123456" + + def boom(_): + raise RuntimeError("disk full") + + LineFramer(boom).feed(b"x\n") # must not raise + + +def test_pump_survives_closed_sink(): + class ClosedSink: + def write(self, _): + raise BrokenPipeError + + def flush(self): # pragma: no cover + pass + + def close(self): + raise OSError + + seen = [] + stdio._pump(io.BytesIO(b"x\ny\n"), ClosedSink(), LineFramer(seen.append), True) + assert seen == [b"x", b"y"] + + +def test_pump_reads_without_read1(): + class Plain: + def __init__(self): + self.data = [b"z\n", b""] + + def read(self, _): + return self.data.pop(0) + + seen = [] + stdio._pump(Plain(), None, LineFramer(seen.append), False) + assert seen == [b"z"] + + +def test_find_client_walks_past_launchers(): + me = process.find_client(os.getpid()) + assert me is not None + assert me.run_key.startswith("pid:") + assert process.find_client(2**22 + 11) is None + + +def test_find_client_skips_launcher_chain(monkeypatch): + class Proc: + def __init__(self, pid, name, parent=None, cmd=()): + self.pid, self._name, self._parent, self._cmd = pid, name, parent, list(cmd) + + def name(self): + return self._name + + def cmdline(self): + return self._cmd + + def parent(self): + return self._parent + + def create_time(self): + return 5.0 + + client = Proc(10, "node") + chain = Proc(12, "uvx", Proc(11, "sh", client)) + monkeypatch.setattr(process.psutil, "Process", lambda pid: chain) + found = process.find_client(12) + assert (found.pid, found.name, found.run_key) == (10, "node", "pid:10:5") + + orphan = Proc(20, "uv", Proc(1, "launchd")) + monkeypatch.setattr(process.psutil, "Process", lambda pid: orphan) + assert process.find_client(20).pid == 20 + + wrapper = Proc(30, "Python", Proc(31, "claude"), cmd=["python", "mcphawk", "wrap"]) + monkeypatch.setattr(process.psutil, "Process", lambda pid: wrapper) + assert process.find_client(30).name == "claude" + + class NoCmd(Proc): + def cmdline(self): + raise process.psutil.AccessDenied() + + guarded = NoCmd(40, "Electron") + monkeypatch.setattr(process.psutil, "Process", lambda pid: guarded) + assert process.find_client(40).name == "Electron" + + deep = Proc(50, "sh") + node = deep + for i in range(10): + node._parent = Proc(51 + i, "sh") + node = node._parent + monkeypatch.setattr(process.psutil, "Process", lambda pid: deep) + assert process.find_client(50, max_depth=3).pid == 50 diff --git a/tests/integration/cli/test_cli.py b/tests/integration/cli/test_cli.py index d0d7f4a..4099864 100644 --- a/tests/integration/cli/test_cli.py +++ b/tests/integration/cli/test_cli.py @@ -1,469 +1,155 @@ -"""Tests for the MCPHawk CLI.""" - -import logging -from unittest.mock import patch +import json import pytest from typer.testing import CliRunner -from mcphawk.cli import app +from mcphawk import __version__, cli +from mcphawk.install import clients as cl +from tests.traffic import legacy_session runner = CliRunner() -@pytest.fixture(autouse=True) -def mock_init_db(): - """Mock init_db to avoid database issues in tests.""" - with patch('mcphawk.cli.init_db'): - yield - - -def test_cli_help(): - """Test that CLI help shows all commands.""" - result = runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "sniff" in result.stdout - assert "web" in result.stdout - assert "mcp" in result.stdout - assert "MCPHawk: Passive MCP traffic sniffer + dashboard" in result.stdout - - -def test_sniff_command_help(): - """Test sniff command help.""" - result = runner.invoke(app, ["sniff", "--help"]) - assert result.exit_code == 0 - assert "Start sniffing MCP traffic" in result.stdout - # Check for the actual text that appears (may include ANSI codes) - assert "-filter" in result.stdout or "--filter" in result.stdout - assert "-debug" in result.stdout or "--debug" in result.stdout - assert "Enable debug output" in result.stdout - - -def test_web_command_help(): - """Test web command help.""" - result = runner.invoke(app, ["web", "--help"]) - assert result.exit_code == 0 - assert "Start the MCPHawk dashboard" in result.stdout - # Check for the actual text that appears (may include ANSI codes) - assert "sniffer" in result.stdout # Will match -no-sniffer regardless of ANSI codes - assert "host" in result.stdout # Will match --host - assert "port" in result.stdout # Will match --port - - -def test_sniff_command_requires_flags(): - """Test sniff command requires port, filter, or auto-detect.""" - result = runner.invoke(app, ["sniff"]) +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.output.strip() == f"mcphawk {__version__}" + + +def test_default_command_runs_up(monkeypatch): + calls = [] + monkeypatch.setattr("uvicorn.run", lambda app, **kw: calls.append(kw)) + result = runner.invoke(cli.app, []) + assert result.exit_code == 0, result.output + assert calls[0]["port"] == 8484 + assert "http://127.0.0.1:8484" in result.output + + +def test_up_options(monkeypatch): + calls, sniffers = [], [] + monkeypatch.setattr("uvicorn.run", lambda app, **kw: calls.append(kw)) + monkeypatch.setattr(cli, "_start_sniffer", lambda *a: sniffers.append(a)) + monkeypatch.setattr(cli.installer, "load_registry", lambda: {"remote": "http://x"}) + result = runner.invoke(cli.app, ["up", "--port", "9001", "--host", "0.0.0.0", + "--no-mcp", "--sniff", "3000", "--sniff", "3001"]) + assert result.exit_code == 0, result.output + assert calls[0]["host"] == "0.0.0.0" + assert sniffers[0][0] == "tcp port 3000 or tcp port 3001" + assert "proxying remote" in result.output + assert " MCP " not in result.output + + +def test_wrap_passes_command(monkeypatch): + seen = {} + + def fake_run_wrap(command, name=None, mask=True): + seen.update(command=command, name=name, mask=mask) + return 4 + + monkeypatch.setattr("mcphawk.capture.stdio.run_wrap", fake_run_wrap) + result = runner.invoke(cli.app, ["wrap", "--name", "fs", "--no-mask", "--", + "npx", "-y", "server", "--flag"]) + assert result.exit_code == 4 + assert seen == {"command": ["npx", "-y", "server", "--flag"], "name": "fs", "mask": False} + legacy = runner.invoke(cli.app, ["wrap", "python", "server.py", "--port", "1"]) + assert seen["command"] == ["python", "server.py", "--port", "1"] + assert legacy.exit_code == 4 + empty = runner.invoke(cli.app, ["wrap"]) + assert empty.exit_code == 2 + + +def test_proxy_command(monkeypatch): + calls = [] + monkeypatch.setattr("uvicorn.run", lambda app, **kw: calls.append(kw)) + result = runner.invoke(cli.app, ["proxy", "--target", "http://x/mcp", "--name", "x"]) + assert "http://127.0.0.1:8485/p/x" in result.output + assert calls[0]["port"] == 8485 + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setattr(cl.platform, "system", lambda: "Darwin") + monkeypatch.setattr(cli.installer.Path, "home", lambda: home) + cursor = home / ".cursor" / "mcp.json" + cursor.parent.mkdir(parents=True) + cursor.write_text(json.dumps({"mcpServers": { + "fs": {"command": "npx", "args": ["fs"]}, + "web": {"url": "http://localhost:3000/mcp"}}})) + monkeypatch.setattr(cli.installer, "mcphawk_command", lambda: ["/bin/mcphawk"]) + return cursor + + +def test_install_dry_run_confirm_and_uninstall(fake_home): + dry = runner.invoke(cli.app, ["install", "--dry-run", "--include-http"]) + assert "2 change(s) planned" in dry.output + assert json.loads(fake_home.read_text())["mcpServers"]["fs"]["command"] == "npx" + + declined = runner.invoke(cli.app, ["install"], input="n\n") + assert declined.exit_code == 1 + + done = runner.invoke(cli.app, ["install", "--include-http", "--client", "cursor"], input="y\n") + assert done.exit_code == 0, done.output + assert "keep `mcphawk up` running" in done.output + config = json.loads(fake_home.read_text()) + assert config["mcpServers"]["fs"]["command"] == "/bin/mcphawk" + assert config["mcpServers"]["web"]["url"] == "http://127.0.0.1:8484/p/web" + + status = runner.invoke(cli.app, ["status"]) + assert "routed 2 server(s)" in status.output + + nothing = runner.invoke(cli.app, ["install", "-y"]) + assert "Nothing to change" in nothing.output + + undone = runner.invoke(cli.app, ["uninstall", "-y"]) + assert undone.exit_code == 0, undone.output + assert json.loads(fake_home.read_text())["mcpServers"]["web"]["url"] == ( + "http://localhost:3000/mcp") + + +def test_install_rejects_unknown_client_and_empty_home(fake_home, tmp_path, monkeypatch): + bad = runner.invoke(cli.app, ["install", "--client", "emacs"]) + assert bad.exit_code == 2 + monkeypatch.setattr(cli.installer.Path, "home", lambda: tmp_path / "empty") + empty = runner.invoke(cli.app, ["install", "--dry-run"]) + assert "No MCP client configurations found" in empty.output + + +def test_status_clear_and_sniff_validation(recorder): + legacy_session(recorder) + status = runner.invoke(cli.app, ["status"]) + assert "1 sessions, 5 calls, 2 errors" in status.output + assert runner.invoke(cli.app, ["clear"], input="n\n").exit_code == 1 + assert runner.invoke(cli.app, ["clear", "-y"]).output.strip() == "cleared" + assert "0 sessions" in runner.invoke(cli.app, ["status"]).output + assert runner.invoke(cli.app, ["sniff"]).exit_code == 2 + + +def test_sniff_permission_error(monkeypatch): + def denied(*a, **kw): + raise PermissionError + + monkeypatch.setattr("mcphawk.capture.sniff.run_sniffer", denied) + result = runner.invoke(cli.app, ["sniff", "-a"]) assert result.exit_code == 1 - assert "You must specify either --port, --filter, or --auto-detect" in result.stdout - assert "mcphawk sniff --port 3000" in result.stdout - assert "mcphawk sniff --filter 'tcp port 3000 or tcp port 3001'" in result.stdout - assert "mcphawk sniff --auto-detect" in result.stdout - - -def test_mcp_command_help(): - """Test mcp command help.""" - result = runner.invoke(app, ["mcp", "--help"]) - assert result.exit_code == 0 - assert "Run MCPHawk MCP server" in result.stdout - # Check for transport option (may be formatted as --transport or -transport) - assert "transport" in result.stdout - assert "stdio or tcp" in result.stdout - # Check for mcp port option (may be formatted as --mcp-port, -mcp-port, or broken across lines) - assert "mcp" in result.stdout and "port" in result.stdout - assert "Port for TCP transport" in result.stdout - - -def test_mcp_command_stdio_transport(): - """Test mcp command with stdio transport.""" - with patch('mcphawk.cli.MCPHawkServer') as mock_server_class, \ - patch('mcphawk.cli.asyncio.run') as mock_asyncio_run: - - mock_server_instance = mock_server_class.return_value - - result = runner.invoke(app, ["mcp", "--transport", "stdio"]) - - # Check output - assert "Starting MCP server (transport: stdio)" in result.stdout - # The debug output with mcpServers only shows up with debug flag - # So we don't check for it here - - # Verify server was created and run_stdio was called - mock_server_class.assert_called_once() - mock_asyncio_run.assert_called_once() - - # Verify that asyncio.run was called with the server's run_stdio method - assert mock_asyncio_run.called - # The coroutine passed should be from run_stdio - assert mock_server_instance.run_stdio.called - - -def test_mcp_command_http_transport(): - """Test mcp command with HTTP transport.""" - with patch('mcphawk.cli.MCPHawkServer') as mock_server_class, \ - patch('mcphawk.cli.asyncio.run') as mock_asyncio_run: - - mock_server_instance = mock_server_class.return_value - - result = runner.invoke(app, ["mcp", "--transport", "http", "--mcp-port", "8765"]) - - # Check output - assert "Starting MCP server (transport: http)" in result.stdout - assert "http://localhost:8765/mcp" in result.stdout - # curl example only shows in debug mode - - # Verify server was created and run_http was called - mock_server_class.assert_called_once() - mock_asyncio_run.assert_called_once() - - # Verify that asyncio.run was called with the server's run_http method - assert mock_asyncio_run.called - # The coroutine passed should be from run_http - assert mock_server_instance.run_http.called - - -def test_mcp_command_unknown_transport(): - """Test mcp command with unknown transport.""" - result = runner.invoke(app, ["mcp", "--transport", "websocket"]) - assert result.exit_code == 1 - assert "Unknown transport: websocket" in result.stdout - - -def test_sniff_with_mcp_http(): - """Test sniff command with MCP HTTP transport.""" - with patch('mcphawk.cli.start_sniffer') as mock_start_sniffer, \ - patch('mcphawk.cli.MCPHawkServer'), \ - patch('mcphawk.cli.threading.Thread') as mock_thread: - - mock_thread_instance = mock_thread.return_value - - result = runner.invoke(app, [ - "sniff", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "http", - "--mcp-port", "8765" - ]) - - # Check MCP server startup message - assert "Starting MCP HTTP server on http://localhost:8765/mcp" in result.stdout + assert "needs root" in result.output - # Verify thread was started for MCP server - mock_thread.assert_called_once() - mock_thread_instance.start.assert_called_once() - # Verify sniffer was called with excluded ports - mock_start_sniffer.assert_called_once() - call_args = mock_start_sniffer.call_args[1] - assert call_args['excluded_ports'] == [] +def test_mcp_command(monkeypatch): + ran = [] + async def fake_stdio(self): + ran.append("stdio") -def test_sniff_with_mcp_stdio(): - """Test sniff command with MCP stdio transport.""" - with patch('mcphawk.cli.start_sniffer') as mock_start_sniffer, \ - patch('mcphawk.cli.MCPHawkServer'), \ - patch('mcphawk.cli.threading.Thread'): + from mcp.server.mcpserver import MCPServer - result = runner.invoke(app, [ - "sniff", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "stdio" - ]) + monkeypatch.setattr(MCPServer, "run_stdio_async", fake_stdio) + assert runner.invoke(cli.app, ["mcp"]).exit_code == 0 + assert ran == ["stdio"] + assert runner.invoke(cli.app, ["mcp", "--transport", "carrier-pigeon"]).exit_code == 2 - # Check MCP server startup message - assert "Starting MCP server on stdio" in result.stdout - # Verify sniffer was called with empty excluded ports - mock_start_sniffer.assert_called_once() - call_args = mock_start_sniffer.call_args[1] - assert call_args['excluded_ports'] == [] - - -def test_web_with_mcp_http(): - """Test web command with MCP HTTP transport.""" - with patch('mcphawk.cli.run_web') as mock_run_web, \ - patch('mcphawk.cli.MCPHawkServer'), \ - patch('mcphawk.cli.threading.Thread'): - - result = runner.invoke(app, [ - "web", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "http", - "--mcp-port", "8766" - ]) - - # Check MCP server startup message - assert "Starting MCP HTTP server on http://localhost:8766/mcp" in result.stdout - - # Verify web was called with excluded ports - mock_run_web.assert_called_once() - call_args = mock_run_web.call_args[1] - assert call_args['excluded_ports'] == [] - - -def test_mcp_command_custom_port(): - """Test mcp command with custom HTTP port.""" - with patch('mcphawk.cli.MCPHawkServer') as mock_server_class, \ - patch('mcphawk.cli.asyncio.run') as mock_asyncio_run: - - mock_server_instance = mock_server_class.return_value - - result = runner.invoke(app, ["mcp", "--transport", "http", "--mcp-port", "9999"]) - - # Check output shows custom port - assert "Starting MCP server (transport: http)" in result.stdout - assert "http://localhost:9999/mcp" in result.stdout - - # Verify server was created - mock_server_class.assert_called_once() - - # Verify run_http was called with custom port - mock_asyncio_run.assert_called_once() - # Check it was called with port=9999 - assert mock_server_instance.run_http.call_args[1]['port'] == 9999 - - -def test_sniff_with_mcp_custom_port(): - """Test sniff command with MCP on custom port.""" - with patch('mcphawk.cli.start_sniffer') as mock_start_sniffer, \ - patch('mcphawk.cli.MCPHawkServer'), \ - patch('mcphawk.cli.threading.Thread') as mock_thread: - - mock_thread_instance = mock_thread.return_value - - result = runner.invoke(app, [ - "sniff", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "http", - "--mcp-port", "7777" - ]) - - # Check MCP server startup message with custom port - assert "Starting MCP HTTP server on http://localhost:7777/mcp" in result.stdout - - # Verify thread was started for MCP server - mock_thread.assert_called_once() - mock_thread_instance.start.assert_called_once() - - # Verify sniffer was called with custom port excluded - mock_start_sniffer.assert_called_once() - call_args = mock_start_sniffer.call_args[1] - assert call_args['excluded_ports'] == [] - - -def test_mcp_stdio_ignores_port(): - """Test that stdio transport ignores the mcp-port parameter.""" - with patch('mcphawk.cli.MCPHawkServer') as mock_server_class, \ - patch('mcphawk.cli.asyncio.run'): - - mock_server_instance = mock_server_class.return_value - - # Even with --mcp-port specified, stdio should ignore it - result = runner.invoke(app, ["mcp", "--transport", "stdio", "--mcp-port", "9999"]) - - # Check output doesn't mention the port - assert "Starting MCP server (transport: stdio)" in result.stdout - assert "9999" not in result.stdout - # mcpServers only shows in debug output - - # Verify run_stdio was called (not run_http) - assert mock_server_instance.run_stdio.called - assert not mock_server_instance.run_http.called - - -def test_web_with_mcp_default_vs_custom_port(): - """Test that default port 8765 is used when not specified.""" - with patch('mcphawk.cli.run_web') as mock_run_web, \ - patch('mcphawk.cli.MCPHawkServer') as mock_server_class, \ - patch('mcphawk.cli.threading.Thread') as mock_thread: - - # Test 1: Default port - result = runner.invoke(app, [ - "web", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "http" - ]) - - assert "Starting MCP HTTP server on http://localhost:8765/mcp" in result.stdout - call_args = mock_run_web.call_args[1] - assert call_args['excluded_ports'] == [] - - # Reset mocks - mock_run_web.reset_mock() - mock_server_class.reset_mock() - mock_thread.reset_mock() - - # Test 2: Custom port - result = runner.invoke(app, [ - "web", - "--port", "3000", - "--with-mcp", - "--mcp-transport", "http", - "--mcp-port", "5555" - ]) - - assert "Starting MCP HTTP server on http://localhost:5555/mcp" in result.stdout - call_args = mock_run_web.call_args[1] - assert call_args['excluded_ports'] == [] - - -@patch('mcphawk.cli.start_sniffer') -def test_sniff_command_with_port(mock_start_sniffer): - """Test sniff command with port option.""" - mock_start_sniffer.side_effect = KeyboardInterrupt() - - result = runner.invoke(app, ["sniff", "--port", "3000"]) - assert result.exit_code == 0 - assert "Starting sniffer with filter: tcp port 3000" in result.stdout - assert "Sniffer stopped." in result.stdout - mock_start_sniffer.assert_called_once_with(filter_expr="tcp port 3000", auto_detect=False, debug=False, excluded_ports=[]) - - -@patch('mcphawk.cli.start_sniffer') -def test_sniff_command_custom_filter(mock_start_sniffer): - """Test sniff command with custom filter.""" - mock_start_sniffer.side_effect = KeyboardInterrupt() - - result = runner.invoke(app, ["sniff", "--filter", "tcp port 8080"]) - assert result.exit_code == 0 - assert "Starting sniffer with filter: tcp port 8080" in result.stdout - mock_start_sniffer.assert_called_once_with(filter_expr="tcp port 8080", auto_detect=False, debug=False, excluded_ports=[]) - - -@patch('mcphawk.cli.start_sniffer') -def test_sniff_command_auto_detect(mock_start_sniffer): - """Test sniff command with auto-detect mode.""" - mock_start_sniffer.side_effect = KeyboardInterrupt() - - result = runner.invoke(app, ["sniff", "--auto-detect"]) - assert result.exit_code == 0 - assert "Auto-detect mode: monitoring all TCP traffic for MCP messages" in result.stdout - assert "Starting sniffer with filter: tcp" in result.stdout - mock_start_sniffer.assert_called_once_with(filter_expr="tcp", auto_detect=True, debug=False, excluded_ports=[]) - - -def test_web_command_requires_flags(): - """Test web command requires port, filter, auto-detect, or no-sniffer.""" - result = runner.invoke(app, ["web"]) - assert result.exit_code == 1 - assert "You must specify either --port, --filter, or --auto-detect (or use --no-sniffer)" in result.stdout - assert "mcphawk web --port 3000" in result.stdout - assert "mcphawk web --filter 'tcp port 3000 or tcp port 3001'" in result.stdout - assert "mcphawk web --auto-detect" in result.stdout - assert "mcphawk web --no-sniffer" in result.stdout - - -@patch('mcphawk.cli.run_web') -def test_web_command_with_port(mock_run_web): - """Test web command with port option.""" - result = runner.invoke(app, ["web", "--port", "3000"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=True, host="127.0.0.1", port=8000, filter_expr="tcp port 3000", auto_detect=False, debug=False, excluded_ports=[], with_mcp=False) - - -@patch('mcphawk.cli.run_web') -def test_web_command_no_sniffer(mock_run_web): - """Test web command with --no-sniffer.""" - result = runner.invoke(app, ["web", "--no-sniffer"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=False, host="127.0.0.1", port=8000, filter_expr=None, auto_detect=False, debug=False, excluded_ports=[], with_mcp=False) - - -@patch('mcphawk.cli.run_web') -def test_web_command_custom_host_web_port(mock_run_web): - """Test web command with custom host and web-port.""" - result = runner.invoke(app, ["web", "--port", "3000", "--host", "0.0.0.0", "--web-port", "9000"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=True, host="0.0.0.0", port=9000, filter_expr="tcp port 3000", auto_detect=False, debug=False, excluded_ports=[], with_mcp=False) - - -@patch('mcphawk.cli.run_web') -def test_web_command_with_filter(mock_run_web): - """Test web command with custom filter.""" - result = runner.invoke(app, ["web", "--filter", "tcp port 8080 or tcp port 8081"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=True, host="127.0.0.1", port=8000, filter_expr="tcp port 8080 or tcp port 8081", auto_detect=False, debug=False, excluded_ports=[], with_mcp=False) - - -@patch('mcphawk.cli.run_web') -def test_web_command_auto_detect(mock_run_web): - """Test web command with auto-detect mode.""" - result = runner.invoke(app, ["web", "--auto-detect"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=True, host="127.0.0.1", port=8000, filter_expr="tcp", auto_detect=True, debug=False, excluded_ports=[], with_mcp=False) - - -def test_scapy_warnings_suppressed(): - """Test that Scapy warnings are suppressed.""" - # Import should not produce warnings - - # Check that scapy.runtime logger is set to ERROR level - scapy_logger = logging.getLogger("scapy.runtime") - assert scapy_logger.level == logging.ERROR - - -def test_no_default_command(): - """Test that running mcphawk without a command shows error.""" - result = runner.invoke(app, []) - assert result.exit_code == 2 # Typer returns 2 for missing command - # Should show error message - assert "Missing command" in result.stdout - assert "Usage:" in result.stdout - - -@patch('mcphawk.cli.start_sniffer') -def test_sniff_command_with_debug_flag(mock_start_sniffer): - """Test sniff command with debug flag.""" - mock_start_sniffer.side_effect = KeyboardInterrupt() - - result = runner.invoke(app, ["sniff", "--port", "3000", "--debug"]) - assert result.exit_code == 0 - mock_start_sniffer.assert_called_once_with(filter_expr="tcp port 3000", auto_detect=False, debug=True, excluded_ports=[]) - - -@patch('mcphawk.cli.run_web') -def test_web_command_with_debug_flag(mock_run_web): - """Test web command with debug flag.""" - result = runner.invoke(app, ["web", "--port", "3000", "--debug"]) - assert result.exit_code == 0 - mock_run_web.assert_called_once_with(sniffer=True, host="127.0.0.1", port=8000, filter_expr="tcp port 3000", auto_detect=False, debug=True, excluded_ports=[], with_mcp=False) - - -@patch('mcphawk.cli.run_web') -@patch('mcphawk.cli.MCPHawkServer') -@patch('mcphawk.cli.threading.Thread') -def test_web_command_with_mcp(mock_thread, mock_mcp_server, mock_run_web): - """Test web command with MCP server integration.""" - result = runner.invoke(app, ["web", "--port", "3000", "--with-mcp"]) - assert result.exit_code == 0 - - # Check MCP server was created - mock_mcp_server.assert_called_once() - - # Check thread was started - mock_thread.assert_called_once() - mock_thread.return_value.start.assert_called_once() - - # Check run_web was called with excluded ports - # Default MCP transport is HTTP on port 8765 - # MCPHawk's own MCP traffic is always captured - mock_run_web.assert_called_once_with( - sniffer=True, - host="127.0.0.1", - port=8000, - filter_expr="tcp port 3000", - auto_detect=False, - debug=False, - excluded_ports=[], # No longer excluding MCP port - with_mcp=True, - ) - - -def test_mcp_command(): - """Test standalone MCP command.""" - result = runner.invoke(app, ["mcp", "--help"]) - assert result.exit_code == 0 - assert "Run MCPHawk MCP server standalone" in result.stdout +def test_sniff_filter_builder(): + assert cli._sniff_filter(None, "tcp port 1", True) == "tcp port 1" + assert cli._sniff_filter([1], None, False) == "tcp port 1" + assert cli._sniff_filter(None, None, True) == "tcp" + assert cli._sniff_filter(None, None, False) is None diff --git a/tests/integration/cli/test_wrapper.py b/tests/integration/cli/test_wrapper.py deleted file mode 100644 index f11623c..0000000 --- a/tests/integration/cli/test_wrapper.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Tests for the MCP server wrapper functionality.""" - -import json -import signal -import subprocess -import sys -from unittest.mock import MagicMock, patch - -import pytest - -from mcphawk.wrapper import MCPWrapper, run_wrapper - - -class TestMCPWrapper: - """Test the MCPWrapper class.""" - - def test_init(self): - """Test wrapper initialization.""" - wrapper = MCPWrapper(["echo", "test"], debug=True) - assert wrapper.command == ["echo", "test"] - assert wrapper.debug is True - assert wrapper.proc is None - assert wrapper.running is False - - @patch('subprocess.Popen') - def test_start_process(self, mock_popen): - """Test starting the wrapped process.""" - mock_proc = MagicMock() - mock_proc.wait.return_value = 0 - mock_popen.return_value = mock_proc - - wrapper = MCPWrapper(["echo", "test"]) - exit_code = wrapper.start() - - assert exit_code == 0 - mock_popen.assert_called_once_with( - ["echo", "test"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0 - ) - - @pytest.mark.skip(reason="Test causes hang in some environments") - @patch('mcphawk.wrapper.time.sleep') # Mock sleep to avoid delay - @patch('threading.Thread') - @patch('subprocess.Popen') - def test_keyboard_interrupt(self, mock_popen, mock_thread, mock_sleep): - """Test handling keyboard interrupt.""" - # Create a mock process - mock_proc = MagicMock() - mock_proc.wait.side_effect = KeyboardInterrupt() - mock_proc.poll.return_value = None # Process is still running - mock_proc.stdin = MagicMock() - mock_proc.stdout = MagicMock() - mock_proc.stderr = MagicMock() - mock_popen.return_value = mock_proc - - # Mock threads to prevent actual thread creation - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - wrapper = MCPWrapper(["echo", "test"]) - - # The start method should catch KeyboardInterrupt and return 130 - exit_code = wrapper.start() - - assert exit_code == 130 # Standard exit code for SIGINT - # stop() should have been called, which calls terminate - mock_proc.terminate.assert_called_once() - # wait should have been called in stop() - assert mock_proc.wait.call_count >= 1 - - def test_stop(self): - """Test stopping the wrapper.""" - wrapper = MCPWrapper(["echo", "test"]) - wrapper.proc = MagicMock() - wrapper.running = True - - wrapper.stop() - - assert wrapper.running is False - wrapper.proc.terminate.assert_called_once() - wrapper.proc.wait.assert_called_once_with(timeout=5) - - def test_stop_with_timeout(self): - """Test stopping with timeout and kill.""" - wrapper = MCPWrapper(["echo", "test"]) - wrapper.proc = MagicMock() - wrapper.proc.wait.side_effect = subprocess.TimeoutExpired("cmd", 5) - wrapper.running = True - - wrapper.stop() - - wrapper.proc.terminate.assert_called_once() - wrapper.proc.kill.assert_called_once() - - @patch('mcphawk.wrapper.log_message') - def test_parse_json_rpc_client_to_server(self, mock_log_message): - """Test parsing JSON-RPC from client to server.""" - wrapper = MCPWrapper(["test"]) - wrapper.proc = MagicMock() - wrapper.proc.pid = 12345 - - # Test request - msg = {"jsonrpc": "2.0", "method": "test", "id": 1} - wrapper._try_parse_json(json.dumps(msg), "client->server") - - # Check log_message was called - mock_log_message.assert_called_once() - log_entry = mock_log_message.call_args[0][0] - - assert log_entry["src_ip"] == "mcp-client" - assert log_entry["dst_ip"] == "mcp-server" - assert log_entry["direction"] == "outgoing" - assert log_entry["transport_type"] == "stdio" - assert log_entry["pid"] == 12345 - assert log_entry["src_port"] is None - assert log_entry["dst_port"] is None - - @patch('mcphawk.wrapper.log_message') - def test_parse_json_rpc_server_to_client(self, mock_log_message): - """Test parsing JSON-RPC from server to client.""" - wrapper = MCPWrapper(["test"]) - wrapper.proc = MagicMock() - wrapper.proc.pid = 12345 - - # Test response - msg = {"jsonrpc": "2.0", "result": "ok", "id": 1} - wrapper._try_parse_json(json.dumps(msg), "server->client") - - # Check log_message was called - mock_log_message.assert_called_once() - log_entry = mock_log_message.call_args[0][0] - - assert log_entry["src_ip"] == "mcp-server" - assert log_entry["dst_ip"] == "mcp-client" - assert log_entry["direction"] == "incoming" - assert log_entry["transport_type"] == "stdio" - assert log_entry["pid"] == 12345 - assert log_entry["src_port"] is None - assert log_entry["dst_port"] is None - - def test_parse_non_json(self): - """Test parsing non-JSON lines.""" - wrapper = MCPWrapper(["test"]) - wrapper._log_jsonrpc_message = MagicMock() - - # Should not call log method for non-JSON - wrapper._try_parse_json("Not JSON", "client->server") - wrapper._log_jsonrpc_message.assert_not_called() - - # Should not call for non-JSON-RPC - wrapper._try_parse_json('{"not": "jsonrpc"}', "client->server") - wrapper._log_jsonrpc_message.assert_not_called() - - @patch('mcphawk.wrapper.broadcast_new_log') - @patch('mcphawk.wrapper.log_message') - def test_log_jsonrpc_message_with_broadcast(self, mock_log_message, mock_broadcast): - """Test logging with broadcasting.""" - wrapper = MCPWrapper(["test"]) - wrapper.proc = MagicMock() - wrapper.proc.pid = 12345 - - msg = {"jsonrpc": "2.0", "method": "test", "id": 1} - wrapper._log_jsonrpc_message(msg, "client->server") - - # Check both logging and broadcasting were called - mock_log_message.assert_called_once() - # Broadcast might fail if no event loop, that's ok - - def test_metadata_includes_command(self): - """Test that metadata includes the wrapped command.""" - wrapper = MCPWrapper(["/path/to/mcp-server", "--arg1", "--arg2"]) - wrapper.proc = MagicMock() - wrapper.proc.pid = 12345 - - with patch('mcphawk.wrapper.log_message') as mock_log: - msg = {"jsonrpc": "2.0", "method": "test", "id": 1} - wrapper._log_jsonrpc_message(msg, "client->server") - - log_entry = mock_log.call_args[0][0] - metadata = json.loads(log_entry["metadata"]) - - assert metadata["wrapper"] is True - assert metadata["command"] == ["/path/to/mcp-server", "--arg1", "--arg2"] - assert metadata["direction"] == "client->server" - - -class TestRunWrapper: - """Test the run_wrapper function.""" - - @patch('mcphawk.wrapper.MCPWrapper') - def test_run_wrapper_success(self, mock_wrapper_class): - """Test successful wrapper run.""" - mock_wrapper = MagicMock() - mock_wrapper.start.return_value = 0 - mock_wrapper_class.return_value = mock_wrapper - - exit_code = run_wrapper(["echo", "test"], debug=True) - - assert exit_code == 0 - mock_wrapper_class.assert_called_once_with(["echo", "test"], debug=True) - mock_wrapper.start.assert_called_once() - - @patch('signal.signal') - def test_signal_handlers_setup(self, mock_signal): - """Test that signal handlers are set up.""" - with patch('mcphawk.wrapper.MCPWrapper') as mock_wrapper_class: - mock_wrapper = MagicMock() - mock_wrapper.start.return_value = 0 - mock_wrapper_class.return_value = mock_wrapper - - run_wrapper(["echo", "test"]) - - # Check SIGINT and SIGTERM handlers were set - assert mock_signal.call_count >= 2 - signal_calls = [call[0][0] for call in mock_signal.call_args_list] - assert signal.SIGINT in signal_calls - assert signal.SIGTERM in signal_calls - - -class TestIntegration: - """Integration tests with real processes.""" - - def test_echo_wrapper(self): - """Test wrapping echo command.""" - # Use a python command that outputs JSON and waits a bit - wrapper = MCPWrapper([ - "python", "-c", - "import sys, time; " - "print('{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"id\":1}'); " - "sys.stdout.flush(); " - "time.sleep(0.1)" # Give wrapper time to read - ]) - - # Mock the logging to capture what would be logged - with patch('mcphawk.wrapper.log_message') as mock_log: - # Run wrapper directly (no thread needed for this test) - exit_code = wrapper.start() - - assert exit_code == 0 - # Should have captured the JSON output - assert mock_log.called - # Verify the logged message - log_entry = mock_log.call_args[0][0] - assert log_entry["transport_type"] == "stdio" - assert "test" in log_entry["message"] - - def test_wrapper_forwards_stderr(self): - """Test that stderr is forwarded correctly.""" - # Use a command that writes to stderr - wrapper = MCPWrapper(["python", "-c", "import sys; sys.stderr.write('error message\\n')"]) - - # Capture our own stderr to check forwarding - import io - old_stderr = sys.stderr - sys.stderr = io.StringIO() - - try: - exit_code = wrapper.start() - stderr_output = sys.stderr.getvalue() - - assert exit_code == 0 - assert "error message" in stderr_output - finally: - sys.stderr = old_stderr - diff --git a/tests/integration/db/test_analysis.py b/tests/integration/db/test_analysis.py new file mode 100644 index 0000000..7c9f032 --- /dev/null +++ b/tests/integration/db/test_analysis.py @@ -0,0 +1,201 @@ +import json + +from mcphawk.analysis import cost, drift, lint, problems +from mcphawk.store import C2S, S2C +from tests.traffic import ( + SEARCH_TOOL, + WEATHER_TOOL, + Clock, + frame, + legacy_session, + modern_meta, + modern_session, + server_meta, +) + + +def test_context_cost_per_server(recorder, query): + sid = legacy_session(recorder) + report = cost.context_cost(query, session_id=sid) + server = report["servers"][0] + assert server["server"] == "weather" + assert server["tool_count"] == 2 + assert server["definitions"][0]["name"] == "search" # the verbose one + assert server["instructions_tokens"] > 0 + assert server["fixed_tokens_per_turn"] == report["fixed_tokens_per_turn"] + tools = {c["tool"]: c for c in server["calls"]} + assert tools["get_weather"]["calls"] == 2 + assert tools["get_weather"]["errors"] == 1 + assert tools["search"]["errors"] == 1 + assert server["unused_tools"] == [] + assert report["heaviest_results"][0]["exchange_id"] + + +def test_context_cost_findings(recorder, query): + sid = recorder.open_session(capture="wrap", transport="stdio", name="big") + huge = {**SEARCH_TOOL, "description": "x" * 4000} + recorder.record(sid, C2S, frame(id=1, method="tools/list")) + recorder.record(sid, S2C, frame(id=1, result={"tools": [huge, WEATHER_TOOL]})) + recorder.record(sid, C2S, frame(id=2, method="tools/call", params={"name": "search"})) + recorder.record(sid, S2C, frame(id=2, result={ + "content": [{"type": "text", "text": "y" * 50_000}]})) + findings = cost.context_cost(query)["findings"] + assert any("definition costs" in f for f in findings) + assert any("never called" in f for f in findings) + assert any("result was" in f for f in findings) + + +def test_context_cost_prefers_discover_instructions_and_newest_listing(recorder, query): + clock = Clock() + modern_session(recorder, clock) + newer = modern_session(recorder, clock) + recorder.record(newer, C2S, frame(id=10, method="tools/list", params={ + "cursor": "p2", "_meta": modern_meta()})) + recorder.record(newer, S2C, frame(id=10, result={ + "resultType": "complete", "tools": [SEARCH_TOOL], "ttlMs": 1, "cacheScope": "public", + "_meta": server_meta()})) + server = cost.context_cost(query)["servers"][0] + assert server["tool_count"] == 2 + assert server["instructions_tokens"] == 4 # "Weather tools." + + +def test_empty_cost_report(query): + report = cost.context_cost(query) + assert report["servers"] == [] + assert report["fixed_tokens_per_turn"] == 0 + + +def test_lint_clean_modern_session(recorder, query): + sid = modern_session(recorder, headers=True) + assert lint.lint_session(query, sid) == [] + + +def test_lint_modern_violations(recorder, query): + sid = recorder.open_session(capture="proxy", transport="streamable_http") + meta = {"io.modelcontextprotocol/protocolVersion": "2026-07-28"} + recorder.record(sid, C2S, frame(id=1, method="tools/list", params={"_meta": meta}), + headers={"Mcp-Method": "tools/call"}) + recorder.record(sid, S2C, frame(id=1, result={"tools": [WEATHER_TOOL]})) + recorder.record(sid, C2S, frame(id=2, method="tools/call", params={ + "name": "get_weather", "_meta": meta}), headers={"Mcp-Method": "tools/call"}) + recorder.record(sid, S2C, frame(id=2, result={"resultType": "complete", "content": []})) + recorder.record(sid, C2S, frame(id=3, method="resources/read", params={ + "uri": "file:///x", "_meta": meta}), + headers={"Mcp-Method": "resources/read", "Mcp-Name": "file:///y"}) + recorder.record(sid, S2C, frame(id=3, error={"code": -32002, "message": "not found"})) + recorder.record(sid, C2S, frame(id=4, method="ping", params={"_meta": meta})) + recorder.record(sid, C2S, frame(method="notifications/roots/list_changed")) + recorder.record(sid, S2C, frame(id="s", method="elicitation/create", params={})) + recorder.record(sid, S2C, frame(id="t", method="custom/thing", params={})) + + rules = {f["rule"] for f in lint.lint_session(query, sid)} + assert { + "missing-result-type", "missing-cache-hints", "missing-server-info", + "missing-client-info", "header-mismatch", "missing-mcp-headers", + "legacy-not-found-code", "removed-method", "server-initiated-request", + "deprecated-feature", + } <= rules + ordered = lint.lint_session(query, sid) + assert ordered[0]["severity"] == "error" + assert ordered[-1]["severity"] == "info" + + +def test_lint_legacy_and_generic_rules(recorder, query): + sid = recorder.open_session(capture="sniff", transport="http_sse") + recorder.record(sid, S2C, "debug: starting") + recorder.record(sid, S2C, '{"hello": "world"}') + recorder.record(sid, S2C, frame(id=5, result={})) + for i, tools in enumerate([[WEATHER_TOOL, SEARCH_TOOL], [SEARCH_TOOL, WEATHER_TOOL]]): + recorder.record(sid, C2S, frame(id=10 + i, method="tools/list")) + recorder.record(sid, S2C, frame(id=10 + i, result={"tools": tools})) + recorder.record(sid, S2C, frame(id=20, method="sampling/createMessage", params={})) + findings = lint.lint_session(query, sid) + rules = {f["rule"] for f in findings} + assert rules == {"non-json-output", "invalid-jsonrpc", "unmatched-response", + "nondeterministic-tools", "deprecated-transport", "deprecated-feature"} + non_json = next(f for f in findings if f["rule"] == "non-json-output") + assert non_json["detail"] == "Stray output on the stream" + assert lint.lint_session(query, "missing") == [] + + +def test_lint_folds_repeats_and_filters_severity(recorder, query): + sid = recorder.open_session(capture="wrap", transport="stdio") + for _ in range(3): + recorder.record(sid, S2C, "log line") + findings = lint.lint(query, [sid], min_severity="error") + assert len(findings) == 1 + assert findings[0]["count"] == 3 + assert findings[0]["detail"] == "Stray output on stdout" + + +def test_find_problems(recorder, query, db): + sid = legacy_session(recorder, clock=Clock(start=1000.0)) + recorder.record(sid, C2S, frame(id=50, method="tools/call", params={"name": "slow"}), + ts=2000.0) + recorder.record(sid, S2C, frame(id=50, result={"content": []}), ts=2012.0) + recorder.record(sid, C2S, frame(id=51, method="tools/call", params={"name": "gone"}), + ts=2013.0) + recorder.record(sid, C2S, frame(id=52, method="tools/call", params={"name": "c"}), + ts=2014.0) + recorder.record(sid, C2S, frame(method="notifications/cancelled", + params={"requestId": 52}), ts=2015.0) + for i in range(4): + recorder.record(sid, C2S, frame(id=60 + i, method="tools/call", params={ + "name": "search", "arguments": {"query": "same"}}), ts=2020.0 + i) + recorder.record(sid, S2C, frame(id=60 + i, result={"content": []}), ts=2020.5 + i) + recorder.record(sid, S2C, "oops") + recorder.end_session(sid, ts=2100.0) + + report = problems.find_problems(query, session_id=sid, min_severity="info") + kinds = [p["kind"] for p in report["problems"]] + assert {"error", "tool_error", "slow", "abandoned", "cancelled", "loop", + "lint:non-json-output"} <= set(kinds) + loop = next(p for p in report["problems"] if p["kind"] == "loop") + assert loop["count"] == 4 + assert report["summary"]["error"] >= 3 + assert report["problems"][0]["severity"] == "error" + + default = problems.find_problems(query, session_id=sid) + assert "cancelled" not in [p["kind"] for p in default["problems"]] + no_lint = problems.find_problems(query, include_lint=False, limit=1) + assert no_lint["total"] > 1 + assert len(no_lint["problems"]) == 1 + + +def test_tool_drift_and_compare_sessions(recorder, query): + clock = Clock() + before = legacy_session(recorder, clock) + after = recorder.open_session(capture="wrap", transport="stdio", name="weather", ts=clock()) + changed = {**WEATHER_TOOL, "description": "Get weather. Now with forecasts!"} + new_tool = {"name": "forecast", "inputSchema": {"type": "object"}} + recorder.record(after, C2S, frame(id=1, method="tools/list"), ts=clock()) + recorder.record(after, S2C, frame(id=1, result={"tools": [changed, new_tool]}), ts=clock()) + recorder.record(after, C2S, frame(id=2, method="tools/call", params={"name": "forecast"}), + ts=clock()) + recorder.record(after, S2C, frame(id=2, result={"content": []}), ts=clock()) + + report = drift.compare_sessions(query, before, after) + assert report["tools"]["added"] == ["forecast"] + assert report["tools"]["removed"] == ["search"] + assert report["tools"]["changed"][0]["name"] == "get_weather" + assert report["tools"]["changed"][0]["fields"] == ["description"] + assert report["tools"]["definition_tokens"]["delta"] < 0 + calls = {c["call"]: c for c in report["calls"]} + assert calls["tools/call forecast"]["before"] is None + assert calls["tools/call get_weather"]["before"]["calls"] == 2 + assert report["server_version_changed"] is True + + assert drift.latest_drift(query, "weather")["tools"]["added"] == ["forecast"] + assert "error" in drift.latest_drift(query, "nobody") + assert "error" in drift.compare_sessions(query, before, "missing") + empty = recorder.open_session(capture="wrap", transport="stdio") + assert drift.compare_sessions(query, before, empty)["tools"] is None + + +def test_tools_of_session_merges_pages(recorder, query): + sid = recorder.open_session(capture="wrap", transport="stdio") + for i, tool in enumerate([WEATHER_TOOL, SEARCH_TOOL]): + recorder.record(sid, C2S, frame(id=i, method="tools/list")) + recorder.record(sid, S2C, frame(id=i, result={"tools": [tool]})) + assert set(drift.tools_of_session(query, sid)) == {"get_weather", "search"} + assert json.dumps(drift.tool_drift({}, {})) diff --git a/tests/integration/db/test_logger.py b/tests/integration/db/test_logger.py deleted file mode 100644 index 9b2a266..0000000 --- a/tests/integration/db/test_logger.py +++ /dev/null @@ -1,445 +0,0 @@ -"""Tests for the logger module.""" - -import sqlite3 -import tempfile -from datetime import datetime, timezone -from pathlib import Path - -import pytest - -from mcphawk.logger import ( - fetch_logs, - get_db_connection, - init_db, - log_message, - set_db_path, -) - - -class TestDBConnection: - """Test the database connection context manager.""" - - @pytest.fixture - def temp_db(self): - """Create a temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - temp_path = Path(f.name) - - yield temp_path - - # Cleanup - temp_path.unlink(missing_ok=True) - - def test_context_manager_basic(self, temp_db): - """Test basic context manager functionality.""" - # Create a simple table for testing - with get_db_connection(temp_db) as conn: - cur = conn.cursor() - cur.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)") - conn.commit() - - # Verify connection is closed by trying to use it - with pytest.raises(sqlite3.ProgrammingError): - cur.execute("SELECT * FROM test") - - def test_context_manager_with_error(self, temp_db): - """Test context manager closes connection even on error.""" - # Create a simple table - with get_db_connection(temp_db) as conn: - cur = conn.cursor() - cur.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)") - conn.commit() - - # Force an error inside context - try: - with get_db_connection(temp_db) as conn: - cur = conn.cursor() - # This will raise an error (table already exists) - cur.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)") - except sqlite3.OperationalError: - pass - - # Connection should still be closed - with pytest.raises(sqlite3.ProgrammingError): - cur.execute("SELECT * FROM test") - - def test_context_manager_row_factory(self, temp_db): - """Test that row_factory is set correctly.""" - # Create and populate a table - with get_db_connection(temp_db) as conn: - cur = conn.cursor() - cur.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") - cur.execute("INSERT INTO test (name) VALUES (?)", ("test_name",)) - conn.commit() - - # Verify row_factory allows dict-like access - with get_db_connection(temp_db) as conn: - cur = conn.cursor() - cur.execute("SELECT * FROM test") - row = cur.fetchone() - assert row["name"] == "test_name" - assert row["id"] == 1 - - def test_context_manager_uses_default_path(self): - """Test that context manager uses default DB_PATH when None provided.""" - # When None is provided, it should use the default DB_PATH - with get_db_connection(None) as conn: - # Should connect successfully using default path - cur = conn.cursor() - cur.execute("SELECT 1") - result = cur.fetchone() - assert result[0] == 1 - - -class TestLogger: - """Test the basic logger functionality.""" - - @pytest.fixture - def temp_db(self): - """Create a temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - temp_path = f.name - - # Set the temporary path - set_db_path(temp_path) - init_db() - - yield temp_path - - # Cleanup - Path(temp_path).unlink(missing_ok=True) - - def test_init_db(self, temp_db): - """Test database initialization.""" - # Database should exist - assert Path(temp_db).exists() - - # Check schema - with get_db_connection(Path(temp_db)) as conn: - cursor = conn.cursor() - - # Get table info - cursor.execute("PRAGMA table_info(logs)") - columns = {col[1]: col[2] for col in cursor.fetchall()} - - # Check all expected columns exist - expected_columns = { - "log_id": "TEXT", - "timestamp": "DATETIME", - "src_ip": "TEXT", - "dst_ip": "TEXT", - "src_port": "INTEGER", - "dst_port": "INTEGER", - "direction": "TEXT", - "message": "TEXT", - "transport_type": "TEXT", - "metadata": "TEXT", - "pid": "INTEGER" - } - - for col, dtype in expected_columns.items(): - assert col in columns - assert columns[col] == dtype - - def test_log_message_basic(self, temp_db): - """Test basic message logging.""" - entry = { - "log_id": "test-001", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "192.168.1.1", - "dst_ip": "192.168.1.2", - "src_port": 3000, - "dst_port": 3001, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"test","id":1}', - "transport_type": "streamable_http", - "metadata": '{"test": true}' - } - - log_message(entry) - - # Fetch and verify - logs = fetch_logs(10) - assert len(logs) == 1 - assert logs[0]["log_id"] == "test-001" - assert logs[0]["src_ip"] == "192.168.1.1" - assert logs[0]["dst_ip"] == "192.168.1.2" - assert logs[0]["src_port"] == 3000 - assert logs[0]["dst_port"] == 3001 - assert logs[0]["transport_type"] == "streamable_http" - - def test_fetch_logs_limit(self, temp_db): - """Test fetching logs with limit.""" - # Log multiple entries - for i in range(5): - entry = { - "log_id": f"test-{i:03d}", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 3000 + i, - "dst_port": 4000 + i, - "direction": "outgoing", - "message": f'{{"jsonrpc":"2.0","method":"test{i}","id":{i}}}', - "transport_type": "streamable_http", - "metadata": '{}' - } - log_message(entry) - - # Fetch with limit - logs = fetch_logs(3) - assert len(logs) == 3 - - # Should be in reverse chronological order (newest first) - assert logs[0]["log_id"] == "test-004" - assert logs[1]["log_id"] == "test-003" - assert logs[2]["log_id"] == "test-002" - - -class TestPIDSupport: - """Test PID field in database schema and operations.""" - - @pytest.fixture - def temp_db(self): - """Create a temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - temp_path = f.name - - # Set the temporary path - set_db_path(temp_path) - init_db() - - yield temp_path - - # Cleanup - Path(temp_path).unlink(missing_ok=True) - - def test_schema_includes_pid(self, temp_db): - """Test that database schema includes PID column.""" - with get_db_connection(Path(temp_db)) as conn: - cursor = conn.cursor() - - # Get table info - cursor.execute("PRAGMA table_info(logs)") - columns = {col[1]: col[2] for col in cursor.fetchall()} - - assert "pid" in columns - assert columns["pid"] == "INTEGER" - - def test_log_message_with_pid(self, temp_db): - """Test logging message with PID.""" - entry = { - "log_id": "test-123", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "stdio", - "dst_ip": "stdio", - "src_port": None, - "dst_port": None, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"test","id":1}', - "transport_type": "stdio", - "pid": 12345, - "metadata": '{"test": true}' - } - - log_message(entry) - - # Fetch and verify - logs = fetch_logs(1) - assert len(logs) == 1 - assert logs[0]["pid"] == 12345 - assert logs[0]["src_port"] is None - assert logs[0]["dst_port"] is None - - def test_log_message_without_pid(self, temp_db): - """Test logging message without PID (network traffic).""" - entry = { - "log_id": "test-456", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 3000, - "dst_port": 3001, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"test","id":1}', - "transport_type": "streamable_http", - "metadata": '{"test": true}' - } - - log_message(entry) - - # Fetch and verify - logs = fetch_logs(1) - assert len(logs) == 1 - assert logs[0]["pid"] is None - assert logs[0]["src_port"] == 3000 - assert logs[0]["dst_port"] == 3001 - - def test_mixed_traffic_types(self, temp_db): - """Test handling both stdio (with PID) and network (with ports) traffic.""" - # Log stdio traffic - stdio_entry = { - "log_id": "stdio-1", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "stdio", - "dst_ip": "stdio", - "src_port": None, - "dst_port": None, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"stdio_test","id":1}', - "transport_type": "stdio", - "pid": 99999, - "metadata": '{"wrapper": true}' - } - log_message(stdio_entry) - - # Log network traffic - network_entry = { - "log_id": "network-1", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "192.168.1.1", - "dst_ip": "192.168.1.2", - "src_port": 8080, - "dst_port": 8081, - "direction": "incoming", - "message": '{"jsonrpc":"2.0","result":"ok","id":1}', - "transport_type": "streamable_http", - "metadata": '{"test": true}' - } - log_message(network_entry) - - # Fetch all logs - logs = fetch_logs(10) - assert len(logs) == 2 - - # Find each log by ID - stdio_log = next(log for log in logs if log["log_id"] == "stdio-1") - network_log = next(log for log in logs if log["log_id"] == "network-1") - - # Verify stdio log - assert stdio_log["transport_type"] == "stdio" - assert stdio_log["pid"] == 99999 - assert stdio_log["src_port"] is None - assert stdio_log["dst_port"] is None - - # Verify network log - assert network_log["transport_type"] == "streamable_http" - assert network_log["pid"] is None - assert network_log["src_port"] == 8080 - assert network_log["dst_port"] == 8081 - - def test_query_by_pid(self, temp_db): - """Test querying logs by PID.""" - # Log multiple entries with different PIDs - for i in range(3): - entry = { - "log_id": f"test-{i}", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "stdio", - "dst_ip": "stdio", - "direction": "outgoing", - "message": f'{{"jsonrpc":"2.0","method":"test{i}","id":{i}}}', - "transport_type": "stdio", - "pid": 12345 if i < 2 else 67890, - "metadata": '{}' - } - log_message(entry) - - # Direct SQL query to filter by PID - with get_db_connection(Path(temp_db)) as conn: - cursor = conn.cursor() - - cursor.execute("SELECT * FROM logs WHERE pid = ?", (12345,)) - rows = cursor.fetchall() - - assert len(rows) == 2 - for row in rows: - assert row["pid"] == 12345 - - def test_backward_compatibility(self, temp_db): - """Test that old logs without PID field still work.""" - # Directly insert an old-style log without PID - with get_db_connection(Path(temp_db)) as conn: - cursor = conn.cursor() - - # Insert without specifying PID (should be NULL) - cursor.execute(""" - INSERT INTO logs (log_id, timestamp, src_ip, dst_ip, src_port, dst_port, - direction, message, transport_type, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - "old-style-1", - datetime.now(tz=timezone.utc).isoformat(), - "127.0.0.1", - "127.0.0.1", - 3000, - 3001, - "outgoing", - '{"jsonrpc":"2.0","method":"test","id":1}', - "streamable_http", - '{}' - )) - conn.commit() - - # Fetch logs should work - logs = fetch_logs(1) - assert len(logs) == 1 - assert logs[0]["pid"] is None # Should be None for old logs - - -class TestTransportType: - """Test transport type field functionality.""" - - @pytest.fixture - def temp_db(self): - """Create a temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - temp_path = f.name - - # Set the temporary path - set_db_path(temp_path) - init_db() - - yield temp_path - - # Cleanup - Path(temp_path).unlink(missing_ok=True) - - def test_log_with_transport_types(self, temp_db): - """Test logging messages with different transport types.""" - transport_types = ["streamable_http", "http_sse", "stdio", "unknown"] - - for i, transport in enumerate(transport_types): - entry = { - "log_id": f"test-{transport}", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "stdio" if transport == "stdio" else "127.0.0.1", - "dst_ip": "stdio" if transport == "stdio" else "127.0.0.1", - "src_port": None if transport == "stdio" else 3000 + i, - "dst_port": None if transport == "stdio" else 4000 + i, - "direction": "outgoing", - "message": f'{{"jsonrpc":"2.0","method":"{transport}","id":{i}}}', - "transport_type": transport, - "pid": 12345 if transport == "stdio" else None, - "metadata": f'{{"transport": "{transport}"}}' - } - log_message(entry) - - # Fetch all logs - logs = fetch_logs(10) - assert len(logs) == len(transport_types) - - # Verify each transport type - for transport in transport_types: - log = next(lg for lg in logs if lg["log_id"] == f"test-{transport}") - assert log["transport_type"] == transport - - if transport == "stdio": - assert log["pid"] == 12345 - assert log["src_port"] is None - assert log["dst_port"] is None - else: - assert log["pid"] is None - assert log["src_port"] is not None - assert log["dst_port"] is not None - diff --git a/tests/integration/db/test_query.py b/tests/integration/db/test_query.py new file mode 100644 index 0000000..baad2de --- /dev/null +++ b/tests/integration/db/test_query.py @@ -0,0 +1,146 @@ +import os + +from mcphawk.query import HUNG_AFTER_S, IDLE_AFTER_S, Query, resolve_scope +from mcphawk.store import C2S, S2C +from tests.traffic import Clock, frame, legacy_session, modern_session + + +def test_list_sessions_counts_and_filters(recorder, query): + clock = Clock() + legacy = legacy_session(recorder, clock) + modern = modern_session(recorder, clock, name="other") + sessions = query.list_sessions() + assert [s["id"] for s in sessions] == [modern, legacy] + by_id = {s["id"]: s for s in sessions} + assert by_id[legacy]["exchange_count"] == 5 + assert by_id[legacy]["error_count"] == 2 + assert by_id[legacy]["display_name"] == "weather" + assert by_id[legacy]["tokens"] > 0 + assert [s["id"] for s in query.list_sessions(server="other")] == [modern] + assert [s["id"] for s in query.list_sessions(server="weather-server")] == [legacy] + + +def test_liveness_rules(recorder, db): + clock = Clock(start=1000.0) + alive = recorder.open_session(capture="wrap", transport="stdio", pid=os.getpid(), ts=clock()) + dead = recorder.open_session(capture="wrap", transport="stdio", pid=2**22 + 7, ts=clock()) + idle = recorder.open_session(capture="proxy", transport="streamable_http", ts=clock()) + ended = recorder.open_session(capture="proxy", transport="streamable_http", ts=clock()) + recorder.end_session(ended, ts=clock()) + q = Query(db, now=lambda: 1000.0 + IDLE_AFTER_S + 60) + live = {s["id"]: s["live"] for s in q.list_sessions()} + assert live == {alive: True, dead: False, idle: False, ended: False} + q.close() + + +def test_hung_and_abandoned_calls(recorder, db): + sid = recorder.open_session(capture="proxy", transport="streamable_http", ts=1000.0) + recorder.record(sid, C2S, frame(id=1, method="tools/call", params={"name": "slow"}), + ts=1000.0) + fresh = Query(db, now=lambda: 1001.0) + assert fresh.get_session(sid)["exchanges"][0]["status"] == "pending" + later = Query(db, now=lambda: 1000.0 + HUNG_AFTER_S + 1) + exchange = later.get_session(sid)["exchanges"][0] + assert exchange["status"] == "hung" + assert exchange["duration_ms"] > HUNG_AFTER_S * 1000 + assert [e["id"] for e in later.exchanges(status="hung")] == [exchange["id"]] + assert later.exchanges(status="abandoned") == [] + recorder.end_session(sid, ts=1002.0) + assert later.get_exchange(exchange["id"])["status"] == "abandoned" + for q in (fresh, later): + q.close() + + +def test_get_session_and_exchange_details(recorder, query): + sid = modern_session(recorder) + session = query.get_session(sid) + assert session["message_count"] == 8 + assert session["notification_count"] == 0 + assert session["invalid_count"] == 0 + retry = [e for e in session["exchanges"] if e["method"] == "tools/call"][-1] + detail = query.get_exchange(retry["id"]) + assert detail["request"]["body"]["params"]["requestState"] == "state-1" + assert detail["response"]["body"]["result"]["content"][0]["text"] == "Rain" + assert [c["status"] for c in detail["chain"]] == ["input_required", "ok"] + assert detail["session"]["id"] == sid + first = query.get_exchange(session["exchanges"][0]["id"]) + assert first["chain"] == [] + assert query.get_exchange(9999) is None + assert query.get_session("nope") is None + assert query.get_message(9999) is None + + +def test_pending_exchange_has_no_response(recorder, query): + sid = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(sid, C2S, frame(id=1, method="tools/list")) + detail = query.get_exchange(query.get_session(sid)["exchanges"][0]["id"]) + assert detail["response"] is None + + +def test_exchange_filters(recorder, query): + legacy_session(recorder) + assert len(query.exchanges(method="tools/call")) == 3 + assert len(query.exchanges(target="get_weather")) == 2 + assert [e["status"] for e in query.exchanges(status="tool_error")] == ["tool_error"] + assert len(query.exchanges(text="Berlin")) == 1 + assert query.exchanges(text="100%_nothing") == [] + assert query.exchanges(status="pending") == [] + ordered = query.exchanges() + assert ordered[0]["method"] == "initialize" + assert ordered[0]["server"] == "weather" + + +def test_runs_group_sessions(recorder, query): + clock = Clock() + a = legacy_session(recorder, clock, run_key="pid:42", client_app="claude") + b = modern_session(recorder, clock, run_key="pid:42", client_app="claude", name="search") + solo = legacy_session(recorder, clock, name="solo") + runs = {r["run_key"]: r for r in query.list_runs()} + assert set(runs) == {"pid:42", f"session:{solo}"} + assert runs["pid:42"]["session_count"] == 2 + assert runs["pid:42"]["servers"] == ["search", "weather"] + assert runs["pid:42"]["exchange_count"] == 9 + assert runs["pid:42"]["client_app"] == "claude" + + run = query.get_run("pid:42") + assert [s["id"] for s in run["sessions"]] == [a, b] + times = [e["started_at"] for e in run["timeline"]] + assert times == sorted(times) + assert run["client_app"] == "claude" + assert query.get_run(f"session:{solo}")["sessions"][0]["id"] == solo + assert query.get_run("pid:0") is None + + +def test_hidden_sessions_are_excluded(recorder, query): + sid = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(sid, C2S, frame(id=1, method="initialize", params={})) + recorder.record(sid, S2C, frame(id=1, result={"serverInfo": {"name": "mcphawk"}})) + assert query.list_sessions() == [] + assert query.list_runs() == [] + assert query.exchanges() == [] + assert query.messages() == [] + assert len(query.list_sessions(include_hidden=True)) == 1 + assert query.stats()["sessions"] == 0 + + +def test_messages_tail_and_stats(recorder, query): + sid = legacy_session(recorder) + assert query.latest_message_id() == 11 + tail = query.messages(after_id=9) + assert [m["id"] for m in tail] == [10, 11] + assert query.messages(session_id=sid, limit=2)[0]["method"] == "initialize" + assert len(query.messages_for(sid, kind="notification")) == 1 + stats = query.stats() + assert stats == {"sessions": 1, "exchanges": 5, "messages": 11, "errors": 2} + assert query.responses_for("tools/list", []) == [] + query.clear() + assert query.stats()["messages"] == 0 + assert query.latest_message_id() == 0 + + +def test_resolve_scope(recorder, query): + sid = legacy_session(recorder, run_key="pid:1") + assert [s["id"] for s in resolve_scope(query, session_id=sid)] == [sid] + assert resolve_scope(query, session_id="missing") == [] + assert [s["id"] for s in resolve_scope(query, run_key="pid:1")] == [sid] + assert [s["id"] for s in resolve_scope(query)] == [sid] diff --git a/tests/integration/db/test_recorder.py b/tests/integration/db/test_recorder.py new file mode 100644 index 0000000..d377217 --- /dev/null +++ b/tests/integration/db/test_recorder.py @@ -0,0 +1,157 @@ +import json + +from mcphawk.protocol.masking import MASK +from mcphawk.store import C2S, S2C, Recorder, connect +from tests.traffic import Clock, frame, legacy_session, modern_session + + +def rows(db, sql, params=()): + conn = connect(db) + try: + return [dict(r) for r in conn.execute(sql, params).fetchall()] + finally: + conn.close() + + +def test_schema_is_created_once(db): + connect(db).close() + conn = connect(db) + assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "wal" + conn.close() + + +def test_legacy_session_pairs_and_identifies(db, recorder): + sid = legacy_session(recorder) + session = rows(db, "SELECT * FROM sessions WHERE id = ?", (sid,))[0] + assert session["client_name"] == "claude-ai" + assert session["server_name"] == "weather-server" + assert session["protocol_version"] == "2025-06-18" + assert session["era"] == "legacy" + + exchanges = rows(db, "SELECT * FROM exchanges WHERE session_id = ? ORDER BY id", (sid,)) + assert [e["method"] for e in exchanges] == [ + "initialize", "tools/list", "tools/call", "tools/call", "tools/call"] + assert [e["status"] for e in exchanges] == ["ok", "ok", "ok", "error", "tool_error"] + assert exchanges[2]["target"] == "get_weather" + assert exchanges[2]["duration_ms"] == 250.0 + assert exchanges[3]["error_code"] == -32602 + assert exchanges[4]["error_message"] == "unknown city" + assert all(e["response_msg_id"] for e in exchanges) + + messages = rows(db, "SELECT * FROM messages WHERE session_id = ?", (sid,)) + assert len(messages) == 11 + assert all(m["exchange_id"] for m in messages if m["kind"] != "notification") + + +def test_modern_session_identity_and_mrtr_chain(db, recorder): + sid = modern_session(recorder, headers=True) + session = rows(db, "SELECT * FROM sessions WHERE id = ?", (sid,))[0] + assert session["era"] == "modern" + assert session["client_name"] == "claude-code" + assert session["server_name"] == "weather" + + calls = rows(db, "SELECT * FROM exchanges WHERE method = 'tools/call' ORDER BY id") + first, retry = calls + assert first["status"] == "input_required" + assert retry["status"] == "ok" + assert retry["parent_id"] == first["id"] + assert retry["chain_root_id"] == first["id"] + + headers = rows(db, "SELECT headers FROM messages WHERE method = 'tools/call'")[0] + parsed = json.loads(headers["headers"]) + assert parsed["Authorization"] == MASK + assert parsed["Mcp-Name"] == "get_weather" + + +def test_three_step_chain_keeps_root(db, recorder): + sid = recorder.open_session(capture="wrap", transport="stdio") + for i, state in enumerate(["a", "b"]): + params = {"name": "t"} + if i: + params["requestState"] = "a" + recorder.record(sid, C2S, frame(id=i, method="tools/call", params=params)) + recorder.record(sid, S2C, frame(id=i, result={ + "resultType": "input_required", "requestState": state})) + recorder.record(sid, C2S, frame(id=9, method="tools/call", params={ + "name": "t", "requestState": "b"})) + chain = rows(db, "SELECT id, parent_id, chain_root_id FROM exchanges ORDER BY id") + assert chain[1]["parent_id"] == chain[0]["id"] + assert chain[2]["parent_id"] == chain[1]["id"] + assert chain[2]["chain_root_id"] == chain[0]["id"] + + +def test_invalid_batch_unmatched_and_cancel(db, recorder): + sid = recorder.open_session(capture="wrap", transport="stdio") + assert recorder.record(sid, S2C, "Listening on stdio...") != [] + assert recorder.record(sid, S2C, " ") == [] + ids = recorder.record(sid, C2S, json.dumps([ + {"jsonrpc": "2.0", "id": 1, "method": "a"}, + {"jsonrpc": "2.0", "id": 2, "method": "b"}])) + assert len(ids) == 2 + recorder.record(sid, S2C, frame(id=99, result={})) + recorder.record(sid, C2S, frame(method="notifications/cancelled", params={ + "requestId": 2, "reason": "user aborted"})) + recorder.record(sid, C2S, frame(method="notifications/cancelled", params={"requestId": 7})) + recorder.record(sid, S2C, b'{"jsonrpc":"2.0","id":1,"result":{}}') + + messages = rows(db, "SELECT kind, note FROM messages ORDER BY id") + assert messages[0] == {"kind": "invalid", "note": "not JSON"} + assert messages[1]["note"] == "batch of 2" + assert messages[3]["note"] == "no matching request" + statuses = rows(db, "SELECT method, status, error_message FROM exchanges ORDER BY id") + assert statuses == [ + {"method": "a", "status": "ok", "error_message": None}, + {"method": "b", "status": "cancelled", "error_message": "user aborted"}, + ] + + +def test_server_initiated_request_pairs_with_client_response(db, recorder): + sid = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(sid, S2C, frame(id="s1", method="sampling/createMessage", params={})) + recorder.record(sid, C2S, frame(id="s1", result={"content": {}})) + exchange = rows(db, "SELECT * FROM exchanges")[0] + assert exchange["initiator"] == "server" + assert exchange["status"] == "ok" + + +def test_masking_can_be_disabled(db): + rec = Recorder(db, mask=False) + sid = rec.open_session(capture="wrap", transport="stdio", target="srv --token abc") + rec.record(sid, C2S, frame(id=1, method="x", params={"api_key": "abc"}), + headers={"Authorization": "Bearer abcdefghijklmnop"}) + msg = rows(db, "SELECT body, headers FROM messages")[0] + assert '"abc"' in msg["body"] + assert "Bearer" in msg["headers"] + rec.close() + + +def test_mcphawk_own_server_is_hidden(db, recorder): + sid = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(sid, C2S, frame(id=1, method="initialize", params={})) + recorder.record(sid, S2C, frame(id=1, result={"serverInfo": {"name": "mcphawk"}})) + assert rows(db, "SELECT hidden FROM sessions")[0]["hidden"] == 1 + + +def test_end_session_run_key_and_callback(db): + seen = [] + rec = Recorder(db, on_record=seen.append) + sid = rec.open_session(capture="wrap", transport="stdio") + rec.set_run_key(sid, "pid:1") + rec.set_run_key(sid, "pid:2") + rec.record(sid, C2S, frame(id=1, method="x")) + rec.end_session(sid, ts=5.0) + session = rows(db, "SELECT run_key, ended_at FROM sessions")[0] + assert session == {"run_key": "pid:1", "ended_at": 5.0} + assert seen == [{"session_id": sid, "message_id": 1}] + rec.close() + + +def test_recorders_in_parallel_share_the_database(db): + a, b = Recorder(db), Recorder(db) + clock = Clock() + legacy_session(a, clock) + legacy_session(b, clock) + assert rows(db, "SELECT COUNT(*) AS n FROM sessions")[0]["n"] == 2 + a.close() + b.close() diff --git a/tests/integration/db/test_traffic_type.py b/tests/integration/db/test_traffic_type.py deleted file mode 100644 index 564fab8..0000000 --- a/tests/integration/db/test_traffic_type.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Test transport_type field is properly set for TCP traffic.""" -import json -import uuid -from datetime import datetime, timezone - -import pytest - -from mcphawk.logger import ( - clear_logs, - fetch_logs, - get_db_connection, - init_db, - log_message, - set_db_path, -) - - -@pytest.fixture -def test_db(tmp_path): - """Create a test database.""" - db_path = tmp_path / "test_transport_type.db" - set_db_path(str(db_path)) - init_db() - yield db_path - clear_logs() - - -def test_tcp_transport_type(test_db): - """Test that TCP traffic is marked with transport_type='TCP'.""" - entry = { - "log_id": str(uuid.uuid4()), - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "src_port": 12345, - "dst_ip": "127.0.0.1", - "dst_port": 54321, - "direction": "outgoing", - "message": json.dumps({"jsonrpc": "2.0", "method": "test", "id": 1}), - "transport_type": "unknown", - } - - log_message(entry) - - logs = fetch_logs(limit=1) - assert len(logs) == 1 - assert logs[0]["transport_type"] == "unknown" - - - -def test_unknown_transport_type(test_db): - """Test that unknown traffic is marked with transport_type='N/A'.""" - entry = { - "log_id": str(uuid.uuid4()), - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "src_port": 9999, - "dst_ip": "127.0.0.1", - "dst_port": 54321, - "direction": "outgoing", - "message": json.dumps({"jsonrpc": "2.0", "method": "test", "id": 1}), - # Omit transport_type to test default - } - - log_message(entry) - - logs = fetch_logs(limit=1) - assert len(logs) == 1 - assert logs[0]["transport_type"] == "unknown" - - -def test_legacy_entries_without_transport_type(test_db): - """Test that we can handle legacy entries without transport_type column.""" - # This tests the backward compatibility in fetch_logs - - # Insert a row without transport_type using direct SQL - with get_db_connection(test_db) as conn: - cur = conn.cursor() - cur.execute( - """ - INSERT INTO logs (log_id, timestamp, src_ip, dst_ip, src_port, dst_port, direction, message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - str(uuid.uuid4()), - datetime.now(tz=timezone.utc).isoformat(), - "127.0.0.1", - "127.0.0.1", - 12345, - 54321, - "outgoing", - json.dumps({"jsonrpc": "2.0", "method": "test", "id": 1}), - ), - ) - conn.commit() - - logs = fetch_logs(limit=1) - assert len(logs) == 1 - # Should get 'N/A' for entries without transport_type - assert logs[0]["transport_type"] == "unknown" diff --git a/tests/integration/e2e/__init__.py b/tests/integration/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/e2e/test_real_sdk.py b/tests/integration/e2e/test_real_sdk.py new file mode 100644 index 0000000..db76f00 --- /dev/null +++ b/tests/integration/e2e/test_real_sdk.py @@ -0,0 +1,107 @@ +"""End to end: the official MCP SDK on both sides, MCPHawk in the middle.""" + +import os +import socket +import sys +import threading +import time +from pathlib import Path + +import pytest +import uvicorn +from mcp.client.client import Client +from mcp.client.stdio import StdioServerParameters + +from mcphawk.analysis import lint +from mcphawk.query import Query +from mcphawk.web.app import create_app + +FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sdk_server.py" + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class _Server(uvicorn.Server): + def install_signal_handlers(self): # pragma: no cover - not in main thread + pass + + +def _serve(app) -> tuple[_Server, int]: + port = _free_port() + server = _Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + deadline = time.time() + 10 + while not server.started: + if time.time() > deadline: # pragma: no cover + raise RuntimeError("server did not start") + time.sleep(0.02) + return server, port + + +async def _exercise(client: Client) -> None: + tools = await client.list_tools() + assert {t.name for t in tools.tools} == {"get_weather", "explode"} + result = await client.call_tool("get_weather", {"city": "Oslo"}) + assert result.content[0].text == "Sunny in Oslo" + failed = await client.call_tool("explode", {}) + assert failed.is_error + + +@pytest.mark.parametrize(("mode", "era"), [("legacy", "legacy"), ("auto", "modern")]) +async def test_stdio_through_wrap(db, mode, era): + params = StdioServerParameters( + command=sys.executable, + args=["-m", "mcphawk", "wrap", "--name", "weather", "--", sys.executable, str(FIXTURE)], + env={**os.environ, "MCPHAWK_DB": str(db)}, + ) + async with Client(params, mode=mode) as client: + await _exercise(client) + + q = Query(db) + [session] = q.list_sessions() + detail = q.get_session(session["id"]) + assert detail["name"] == "weather" + assert detail["server_name"] == "sdk-weather" + assert detail["server_version"] == "3.1.4" + assert detail["era"] == era + calls = [e for e in detail["exchanges"] if e["method"] == "tools/call"] + assert [(c["target"], c["status"]) for c in calls] == [ + ("get_weather", "ok"), ("explode", "tool_error")] + assert calls[1]["error_message"] == "Error executing tool explode" # what the wire says + if era == "modern": + errors = [f for f in lint.lint_session(q, session["id"]) if f["severity"] == "error"] + assert errors == [], errors + q.close() + + +async def test_streamable_http_through_proxy(db): + sys.path.insert(0, str(FIXTURE.parent)) + try: + from sdk_server import server as sdk_server + finally: + sys.path.pop(0) + upstream, upstream_port = _serve(sdk_server.streamable_http_app()) + hawk, hawk_port = _serve(create_app( + db, upstreams=lambda: {"weather": f"http://127.0.0.1:{upstream_port}/mcp"}, + with_mcp=False, static_dir=None)) + try: + async with Client(f"http://127.0.0.1:{hawk_port}/p/weather") as client: + await _exercise(client) + finally: + upstream.should_exit = hawk.should_exit = True + + q = Query(db) + [session] = q.list_sessions() + assert session["capture"] == "proxy" + assert session["transport"] == "streamable_http" + assert session["server_name"] == "sdk-weather" + assert session["era"] == "modern" + statuses = [e["status"] for e in q.get_session(session["id"])["exchanges"]] + assert statuses.count("tool_error") == 1 + errors = [f for f in lint.lint_session(q, session["id"]) if f["severity"] == "error"] + assert errors == [], errors + q.close() diff --git a/tests/integration/install/__init__.py b/tests/integration/install/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/install/test_installer.py b/tests/integration/install/test_installer.py new file mode 100644 index 0000000..f84a3de --- /dev/null +++ b/tests/integration/install/test_installer.py @@ -0,0 +1,201 @@ +import json +import os +import stat +import sys + +import pytest + +from mcphawk.install import clients as cl +from mcphawk.install import installer as ins + +HAWK = ["/opt/bin/mcphawk"] + + +@pytest.fixture +def home(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setattr(cl.platform, "system", lambda: "Darwin") + support = home / "Library" / "Application Support" + + def write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data if isinstance(data, str) else json.dumps(data)) + return path + + write(support / "Claude" / "claude_desktop_config.json", {"mcpServers": { + "fs": {"command": "npx", "args": ["-y", "@mcp/fs", "/tmp"], "env": {"X": "1"}}, + "off": {"command": "x", "disabled": True}, + "self": {"command": "mcphawk", "args": ["mcp"]}, + }, "otherSetting": True}) + os.chmod(support / "Claude" / "claude_desktop_config.json", 0o600) + write(home / ".claude.json", { + "mcpServers": { + "linear": {"type": "http", "url": "https://mcp.linear.app/mcp"}, + "local": {"type": "http", "url": "http://localhost:3000/mcp"}, + "keyed": {"type": "http", "url": "https://api.test/mcp", + "headers": {"Authorization": "Bearer x"}}, + }, + "projects": {"/work/app": {"mcpServers": { + "ctx": {"type": "stdio", "command": "uvx", "args": ["ctx-server"]}}}, + "/work/empty": {"allowedTools": []}}, + }) + write(home / ".cursor" / "mcp.json", {"mcpServers": { + "weird": {"something": "else"}, "notadict": 3}}) + write(support / "Code" / "User" / "mcp.json", + '{\n // my servers\n "servers": {\n "gh": {"type": "stdio", "command": "gh-mcp",},\n },\n}') + return home + + +def by_name(changes): + return {(c.table.client, c.server): c for c in changes} + + +def test_plan_install_defaults(home): + changes = by_name(ins.plan(home=home, command=HAWK, registry={})) + fs = changes[("claude-desktop", "fs")] + assert fs.action == ins.WRAP + assert fs.after["command"] == "/opt/bin/mcphawk" + assert fs.after["args"] == ["wrap", "--name", "fs", "--", "npx", "-y", "@mcp/fs", "/tmp"] + assert fs.after["env"] == {"X": "1"} + assert changes[("claude-desktop", "off")].reason == "disabled" + assert changes[("claude-desktop", "self")].reason == "this is MCPHawk itself" + assert changes[("claude-code", "linear")].reason.startswith("HTTP server") + assert changes[("claude-code", "ctx")].table.scope == "local:/work/app" + assert changes[("cursor", "weird")].reason == "unrecognised server entry" + assert changes[("vscode", "gh")].action == ins.WRAP + + +def test_plan_include_http(home): + registry = {"local": "http://elsewhere/mcp"} + changes = by_name(ins.plan(home=home, command=HAWK, include_http=True, registry=registry)) + assert "OAuth" in changes[("claude-code", "linear")].reason + local = changes[("claude-code", "local")] + assert local.action == ins.PROXY + assert local.after["url"] == "http://127.0.0.1:8484/p/local-2" + assert changes[("claude-code", "keyed")].action == ins.PROXY + assert registry["local-2"] == "http://localhost:3000/mcp" + forced = by_name(ins.plan(home=home, command=HAWK, force_http=True, registry={})) + assert forced[("claude-code", "linear")].action == ins.PROXY + + +def test_install_then_uninstall_roundtrip(home): + desktop = home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + original = json.loads(desktop.read_text()) + registry = {} + changes = ins.plan(home=home, command=HAWK, include_http=True, registry=registry) + notes = ins.apply(changes, registry) + assert any("comments are not preserved" in n for n in notes) + assert sum(n.startswith("updated") for n in notes) == 3 + assert stat.S_IMODE(desktop.stat().st_mode) == 0o600 + assert len(list((home.parent / "mcphawk-home" / "backups").iterdir())) == 3 + assert ins.load_registry() == registry + + installed = json.loads(desktop.read_text()) + assert installed["otherSetting"] is True + again = by_name(ins.plan(home=home, command=HAWK, include_http=True, registry=registry)) + assert again[("claude-desktop", "fs")].reason == "already wrapped" + assert again[("claude-code", "local")].reason == "already proxied" + + # the user edits the config while installed; uninstall must keep it + installed["mcpServers"]["fs"]["env"]["Y"] = "2" + desktop.write_text(json.dumps(installed)) + + undo = ins.plan(uninstall=True, home=home, registry=ins.load_registry()) + ins.apply(undo) + restored = json.loads(desktop.read_text()) + assert restored["mcpServers"]["fs"]["command"] == "npx" + assert restored["mcpServers"]["fs"]["args"] == original["mcpServers"]["fs"]["args"] + assert restored["mcpServers"]["fs"]["env"] == {"X": "1", "Y": "2"} + code = json.loads((home / ".claude.json").read_text()) + assert code["mcpServers"]["local"]["url"] == "http://localhost:3000/mcp" + assert code["projects"]["/work/app"]["mcpServers"]["ctx"]["command"] == "uvx" + reasons = {c.reason for c in ins.plan(uninstall=True, home=home, registry={})} + assert reasons == {"not routed through MCPHawk"} + + +def test_uninstall_with_missing_registry_entry(home): + registry = {} + ins.apply(ins.plan(home=home, command=HAWK, include_http=True, registry=registry), registry) + changes = by_name(ins.plan(uninstall=True, home=home, registry={})) + assert "missing from registry" in changes[("claude-code", "local")].reason + + +def test_project_scope_files(home, tmp_path): + project = tmp_path / "proj" + (project / ".vscode").mkdir(parents=True) + (project / ".vscode" / "mcp.json").write_text('{"servers": {"p": {"command": "p"}}}') + (project / ".mcp.json").write_text("not json") + changes = ins.plan(home=home, project=project, command=HAWK, registry={}, + clients=(cl.VSCODE, cl.CLAUDE_CODE, cl.CURSOR)) + scopes = {(c.table.client, c.table.scope) for c in changes} + assert ("vscode", "project") in scopes + assert ("claude-code", "project") not in scopes # unparsable file is skipped + + +def test_apply_skips_file_that_became_unparsable(home): + changes = ins.plan(home=home, command=HAWK, registry={}, clients=(cl.CURSOR, cl.CLAUDE_DESKTOP)) + desktop = next(c for c in changes if c.after).table.path + desktop.write_text("{broken") + notes = ins.apply(changes) + assert notes == [f"skipped {desktop}: could not parse it"] + + +def test_backup_names_do_not_collide(home): + path = home / ".cursor" / "mcp.json" + first, second = ins.backup(path), ins.backup(path) + assert first != second + assert second.read_text() == path.read_text() + + +def test_wrapped_original_variants(): + assert ins.wrapped_original({"command": "uvx", "args": [ + "mcphawk", "wrap", "--", "srv", "-v"]}) == ("srv", ["-v"]) + assert ins.wrapped_original({"command": "python", "args": [ + "-m", "mcphawk", "wrap", "--"]}) is None + assert ins.wrapped_original({"command": "mcphawk", "args": ["mcp"]}) is None + assert ins.wrapped_original({"command": None}) is None + + +def test_mcphawk_command_resolution(monkeypatch, tmp_path): + script = tmp_path / "mcphawk" + script.write_text("") + monkeypatch.setattr(sys, "argv", [str(script)]) + assert ins.mcphawk_command() == [str(script.resolve())] + monkeypatch.setattr(sys, "argv", ["pytest"]) + monkeypatch.setattr(ins.shutil, "which", lambda name: f"/usr/bin/{name}") + assert ins.mcphawk_command() == ["/usr/bin/mcphawk"] + monkeypatch.setattr(ins.shutil, "which", lambda name: None) + assert ins.mcphawk_command() == [sys.executable, "-m", "mcphawk"] + monkeypatch.setattr(sys, "executable", "/Users/x/.cache/uv/archive-v0/abc/bin/python") + assert ins.mcphawk_command() == ["uvx", "mcphawk"] + + +def test_registry_ignores_garbage(): + ins.registry_path().write_text("[1, 2") + assert ins.load_registry() == {} + ins.registry_path().write_text('{"a": "http://x", "b": 3}') + assert ins.load_registry() == {"a": "http://x"} + + +def test_client_paths_per_platform(monkeypatch, tmp_path): + monkeypatch.setattr(cl.platform, "system", lambda: "Linux") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + assert cl.config_files(cl.CLAUDE_DESKTOP, tmp_path, None)[0][1] == ( + tmp_path / "cfg" / "Claude" / "claude_desktop_config.json") + monkeypatch.setattr(cl.platform, "system", lambda: "Windows") + monkeypatch.setenv("APPDATA", str(tmp_path / "roaming")) + assert cl.config_files(cl.VSCODE, tmp_path, None)[0][1] == ( + tmp_path / "roaming" / "Code" / "User" / "mcp.json") + with pytest.raises(ValueError): + cl.config_files("emacs", tmp_path, None) + + +def test_jsonc_parsing(): + text = '{"a": "http://x // not a comment", /* c */ "b": [1, 2,], // tail\n}' + assert cl.loads_jsonc(text) == {"a": "http://x // not a comment", "b": [1, 2]} + assert cl.has_comments(text) + assert not cl.has_comments('{"a": "//"}') + assert cl.loads_jsonc(" ") == {} + table = cl.ServerTable("cursor", "user", tmp := None, ("mcpServers",)) + assert table.label() == "cursor (user)" + assert tmp is None diff --git a/tests/integration/mcp/test_mcp_http_simple.py b/tests/integration/mcp/test_mcp_http_simple.py deleted file mode 100644 index 639dc4b..0000000 --- a/tests/integration/mcp/test_mcp_http_simple.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Simple HTTP tests for MCP server with mocks.""" - -import json -from unittest.mock import MagicMock, patch - - -class TestMCPHTTPSimple: - """Test MCP server HTTP transport with mocks.""" - - def test_sse_response_format(self): - """Test that we correctly parse SSE format responses.""" - # Mock SSE response - sse_response = 'data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}\n\n' - - # Parse SSE response - lines = sse_response.strip().split('\n') - assert lines[0].startswith('data: ') - data = json.loads(lines[0][6:]) - assert data["id"] == 1 - assert data["result"]["protocolVersion"] == "2024-11-05" - - @patch('requests.post') - def test_http_basic_flow_mocked(self, mock_post): - """Test basic HTTP flow with mocked responses.""" - # Mock responses for each call - mock_responses = [ - # Initialize response - MagicMock( - status_code=200, - text='data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"mcphawk-mcp","version":"1.0.0"}}}\n\n', - headers={"mcp-session-id": "test-session-123"} - ), - # List tools response - MagicMock( - status_code=200, - text='data: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"query_traffic"},{"name":"get_log"},{"name":"search_traffic"},{"name":"get_stats"},{"name":"list_methods"}]}}\n\n' - ), - # Get stats response - MagicMock( - status_code=200, - text='data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\\"total\\":10,\\"requests\\":3,\\"responses\\":3,\\"notifications\\":2,\\"errors\\":2}"}]}}\n\n' - ) - ] - mock_post.side_effect = mock_responses - - # Import here to avoid issues - import requests - - # 1. Initialize - response = requests.post( - "http://localhost:8765/mcp", - json={"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}, - headers={"Accept": "application/json, text/event-stream"} - ) - assert response.status_code == 200 - session_id = response.headers.get("mcp-session-id") - assert session_id == "test-session-123" - - # 2. List tools - response = requests.post( - "http://localhost:8765/mcp", - json={"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 2}, - headers={"Accept": "application/json, text/event-stream", "mcp-session-id": session_id} - ) - assert response.status_code == 200 - - # Parse and check tools - lines = response.text.strip().split('\n') - data = json.loads(lines[0][6:]) - assert len(data["result"]["tools"]) == 5 - - # 3. Call get_stats - response = requests.post( - "http://localhost:8765/mcp", - json={"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "get_stats"}, "id": 3}, - headers={"Accept": "application/json, text/event-stream", "mcp-session-id": session_id} - ) - assert response.status_code == 200 - - # Parse stats - lines = response.text.strip().split('\n') - data = json.loads(lines[0][6:]) - stats = json.loads(data["result"]["content"][0]["text"]) - assert stats["total"] == 10 - assert stats["requests"] == 3 - - @patch('requests.post') - def test_http_error_handling_mocked(self, mock_post): - """Test HTTP error handling with mocked responses.""" - # Mock error responses - mock_responses = [ - # Session not initialized error - MagicMock( - status_code=200, - text='data: {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Session not initialized"}}\n\n' - ), - # Unknown method error - MagicMock( - status_code=200, - text='data: {"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Unknown method: unknown/method"}}\n\n' - ) - ] - mock_post.side_effect = mock_responses - - import requests - - # Try to call tool without initialization - response = requests.post( - "http://localhost:8765/mcp", - json={"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1}, - headers={"Accept": "application/json, text/event-stream", "mcp-session-id": "uninitialized"} - ) - assert response.status_code == 200 - - # Parse SSE response - lines = response.text.strip().split('\n') - data = json.loads(lines[0][6:]) - assert "error" in data - assert data["error"]["message"] == "Session not initialized" - - # Unknown method - response = requests.post( - "http://localhost:8765/mcp", - json={"jsonrpc": "2.0", "method": "unknown/method", "params": {}, "id": 2}, - headers={"Accept": "application/json, text/event-stream"} - ) - assert response.status_code == 200 - - # Parse SSE response - lines = response.text.strip().split('\n') - data = json.loads(lines[0][6:]) - assert "error" in data - assert "Unknown method" in data["error"]["message"] diff --git a/tests/integration/mcp/test_mcp_server.py b/tests/integration/mcp/test_mcp_server.py index 9e3d86d..2e487f4 100644 --- a/tests/integration/mcp/test_mcp_server.py +++ b/tests/integration/mcp/test_mcp_server.py @@ -1,270 +1,101 @@ -"""Tests for MCP server functionality.""" - -import asyncio -import contextlib import json -import uuid -from datetime import datetime, timezone -from unittest.mock import AsyncMock, patch import pytest +from mcp.client.client import Client -from mcphawk import logger -from mcphawk.mcp_server.server import MCPHawkServer +from mcphawk.mcp_server import DEFAULT_MAX_CHARS, _dump, build_server +from tests.traffic import Clock, legacy_session, modern_session -@pytest.fixture -def test_db(tmp_path): - """Create a temporary test database.""" - db_path = tmp_path / "test_mcp.db" - logger.set_db_path(str(db_path)) - logger.init_db() - yield db_path +def text(result): + return result.content[0].text @pytest.fixture -def sample_logs(test_db): - """Create sample log entries.""" - test_messages = [ - { - "jsonrpc": "2.0", - "method": "tools/list", - "id": "req-1" - }, - { - "jsonrpc": "2.0", - "result": {"tools": ["query", "search"]}, - "id": "req-1" - }, - { - "jsonrpc": "2.0", - "method": "progress/update", - "params": {"progress": 50} - }, - { - "jsonrpc": "2.0", - "error": {"code": -32601, "message": "Method not found"}, - "id": "req-2" - } - ] - - log_ids = [] - for i, msg in enumerate(test_messages): - log_id = str(uuid.uuid4()) - log_ids.append(log_id) - entry = { - "log_id": log_id, - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 3000 + i, - "dst_port": 8000, - "direction": "unknown", - "message": json.dumps(msg), - "transport_type": "unknown" - } - logger.log_message(entry) - - return log_ids - - -class TestMCPServer: - """Test MCP server functionality.""" - - def test_server_initialization(self, test_db): - """Test server initializes correctly.""" - server = MCPHawkServer(str(test_db)) - assert server.mcp.name == "mcphawk-mcp" - - # Check that FastMCP instance was created - assert hasattr(server, 'mcp') - assert hasattr(server.mcp, 'tool') - - def test_list_tools(self, test_db): - """Test that tools are registered correctly.""" - server = MCPHawkServer(str(test_db)) - - # The FastMCP instance exposes tools through _tool_manager._tools - tool_names = list(server.mcp._tool_manager._tools.keys()) - - assert len(tool_names) == 5 - assert "query_traffic" in tool_names - assert "get_log" in tool_names - assert "search_traffic" in tool_names - assert "get_stats" in tool_names - assert "list_methods" in tool_names - - @pytest.mark.asyncio - async def test_call_tools(self, sample_logs): - """Test calling tools directly.""" - server = MCPHawkServer() - - # Test query_traffic - query_tool = server.mcp._tool_manager._tools["query_traffic"] - result = await query_tool.fn(limit=2, offset=0) - data = json.loads(result) - assert len(data) == 2 - assert all("log_id" in log for log in data) - - # Test get_log - get_log_tool = server.mcp._tool_manager._tools["get_log"] - result = await get_log_tool.fn(log_id=sample_logs[0]) - data = json.loads(result) - assert data["log_id"] == sample_logs[0] - assert "tools/list" in data["message"] - - # Test get_log with invalid ID - result = await get_log_tool.fn(log_id="invalid") - assert "No log found" in result - - # Test search_traffic - search_tool = server.mcp._tool_manager._tools["search_traffic"] - result = await search_tool.fn(search_term="tools/list") - data = json.loads(result) - assert len(data) == 1 - assert "tools/list" in data[0]["message"] - - # Test get_stats - stats_tool = server.mcp._tool_manager._tools["get_stats"] - result = await stats_tool.fn() - stats = json.loads(result) - assert stats["total_logs"] == 4 - assert stats["requests"] == 1 - assert stats["responses"] == 1 - assert stats["notifications"] == 1 - assert stats["errors"] == 1 - - # Test list_methods - methods_tool = server.mcp._tool_manager._tools["list_methods"] - result = await methods_tool.fn() - methods_data = json.loads(result) - # The result format is {"methods": [...], "count": 2} - assert methods_data["count"] == 2 - assert "tools/list" in methods_data["methods"] - assert "progress/update" in methods_data["methods"] - - @pytest.mark.asyncio - async def test_search_with_filters(self, sample_logs): - """Test search functionality with various filters.""" - server = MCPHawkServer() - - search_tool = server.mcp._tool_manager._tools["search_traffic"] - - # Test message type filter - result = await search_tool.fn( - search_term="jsonrpc", - message_type="notification" - ) - data = json.loads(result) - assert len(data) == 1 - assert "progress/update" in data[0]["message"] - - # Test traffic type filter - result = await search_tool.fn( - search_term="jsonrpc", - transport_type="unknown" - ) - data = json.loads(result) - # All 4 test messages match the search criteria - assert len(data) == 4 - assert all(log["transport_type"] == "unknown" for log in data) - - @pytest.mark.asyncio - async def test_error_handling(self, test_db): - """Test error handling in tool functions.""" - server = MCPHawkServer(str(test_db)) - - # The SDK handles parameter validation automatically - # So we'll test the actual error cases in the tool implementations - - get_log_tool = server.mcp._tool_manager._tools["get_log"] - - # Test with non-existent log ID - result = await get_log_tool.fn(log_id="non-existent-id") - assert "No log found with ID" in result - - @pytest.mark.asyncio - async def test_run_stdio(self): - """Test that run_stdio properly handles stdio transport.""" - server = MCPHawkServer() - - # Mock the run_stdio_async method to avoid actual stdio operations - with patch.object(server.mcp, 'run_stdio_async', new_callable=AsyncMock) as mock_run: - await server.run_stdio() - - # Verify run was called - mock_run.assert_called_once() - - @pytest.mark.asyncio - async def test_run_http(self, test_db): - """Test that run_http properly handles HTTP transport.""" - server = MCPHawkServer(str(test_db)) - - # Create test server config that immediately shuts down - with patch('uvicorn.Server') as mock_server_class: - mock_server_instance = AsyncMock() - mock_server_instance.serve = AsyncMock() - mock_server_class.return_value = mock_server_instance - - # Run the server in a task that we'll cancel - task = asyncio.create_task(server.run_http(port=8765)) - - # Give it a moment to set up - await asyncio.sleep(0.1) - - # Cancel the task - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - # Verify uvicorn was configured correctly - mock_server_class.assert_called_once() - config = mock_server_class.call_args[0][0] - assert config.host == "127.0.0.1" - assert config.port == 8765 - - @pytest.mark.asyncio - async def test_fastmcp_integration(self, sample_logs): - """Test that FastMCP handles requests correctly.""" - server = MCPHawkServer() - - # Test that we can access tool metadata - query_tool = server.mcp._tool_manager._tools["query_traffic"] - assert query_tool.description - # Tool has parameters but not exposed as input_schema - assert hasattr(query_tool, 'fn') - - # Test tool execution - result = await query_tool.fn(limit=1, offset=0) - data = json.loads(result) - assert len(data) == 1 - assert "log_id" in data[0] - - @pytest.mark.asyncio - async def test_notification_handling_concept(self): - """Test the concept of notification handling (SDK handles the actual protocol).""" - # The FastMCP SDK handles JSON-RPC protocol details including notifications - # This test verifies our understanding of the concept - - # In JSON-RPC 2.0: - # - Requests have an 'id' field and expect a response - # - Notifications have no 'id' field and should not receive a response - - notification = { - "jsonrpc": "2.0", - "method": "progress/update", - "params": {"progress": 50} - # Note: no 'id' field - } - - request = { - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 1 - } - - # Verify our test data structure - assert "id" not in notification - assert "id" in request - +def hawk(db, recorder, monkeypatch): + monkeypatch.setenv("MCPHAWK_URL", "http://127.0.0.1:9999") + clock = Clock() + ids = { + "legacy": legacy_session(recorder, clock, run_key="pid:7"), + "modern": modern_session(recorder, clock, run_key="pid:7", name="weather"), + } + return build_server(db), ids + + +async def test_tools_are_few_and_documented(hawk): + server, _ = hawk + async with Client(server) as client: + tools = (await client.list_tools()).tools + assert sorted(t.name for t in tools) == [ + "compare_sessions", "context_cost", "find_problems", "get_exchange", + "get_session", "list_sessions"] + assert all(t.description for t in tools) + + +async def test_list_and_get_session(hawk): + server, ids = hawk + async with Client(server) as client: + rows = json.loads(text(await client.call_tool("list_sessions", {}))) + assert {r["id"] for r in rows} == set(ids.values()) + assert rows[0]["url"].startswith("http://127.0.0.1:9999/s/") + filtered = json.loads(text(await client.call_tool("list_sessions", {"run_key": "pid:7"}))) + assert len(filtered) == 2 + + session = json.loads(text(await client.call_tool( + "get_session", {"session_id": ids["legacy"], "limit": 2}))) + assert session["shown"] == "last 2 of 5 exchanges" + assert session["errors"] == 2 + assert session["exchanges"][-1]["error"] == "unknown city" + assert "No session" in text(await client.call_tool("get_session", {"session_id": "x"})) + + +async def test_get_exchange_with_chain_and_truncation(hawk, query): + server, ids = hawk + async with Client(server) as client: + retry = [e for e in query.get_session(ids["modern"])["exchanges"] + if e["method"] == "tools/call"][-1] + report = json.loads(text(await client.call_tool("get_exchange", {"exchange_id": retry["id"]}))) + assert report["response"]["result"]["content"][0]["text"] == "Rain" + assert [r["status"] for r in report["round_trips"]] == ["input_required", "ok"] + assert report["url"] == f"http://127.0.0.1:9999/x/{retry['id']}" + small = text(await client.call_tool("get_exchange", {"exchange_id": retry["id"], + "max_chars": 10})) + assert "truncated" in small + assert "No exchange" in text(await client.call_tool("get_exchange", {"exchange_id": 999})) + + +async def test_find_problems_and_cost(hawk): + server, ids = hawk + async with Client(server) as client: + report = json.loads(text(await client.call_tool("find_problems", {}))) + assert report["summary"]["error"] >= 2 + assert all("at" not in p for p in report["problems"]) + assert any(p.get("url", "").startswith("http://127.0.0.1:9999/x/") for p in report["problems"]) + bad = text(await client.call_tool("find_problems", {"min_severity": "bad"})) + assert "min_severity must be" in bad + + cost = json.loads(text(await client.call_tool("context_cost", {"run_key": "pid:7"}))) + assert cost["fixed_tokens_per_turn"] > 0 + assert all("session_ids" not in s for s in cost["servers"]) + + +async def test_compare_sessions(hawk): + server, ids = hawk + async with Client(server) as client: + report = json.loads(text(await client.call_tool("compare_sessions", { + "before_session_id": ids["legacy"], "after_session_id": ids["modern"]}))) + assert report["tools"]["removed"] == ["search"] + assert report["url"].startswith("http://127.0.0.1:9999/compare?") + latest = json.loads(text(await client.call_tool("compare_sessions", + {"server_name": "weather"}))) + assert latest["before"]["id"] == ids["legacy"] + missing = json.loads(text(await client.call_tool("compare_sessions", + {"server_name": "nobody"}))) + assert "error" in missing + assert "Give" in text(await client.call_tool("compare_sessions", {})) + + +def test_dump_truncates(): + assert _dump({"a": 1}, 100) == json.dumps({"a": 1}, indent=1) + long = _dump({"a": "x" * (DEFAULT_MAX_CHARS * 2)}, DEFAULT_MAX_CHARS) + assert long.endswith("more chars)") diff --git a/tests/integration/mcp/test_mcp_stdio_integration.py b/tests/integration/mcp/test_mcp_stdio_integration.py deleted file mode 100644 index 170199f..0000000 --- a/tests/integration/mcp/test_mcp_stdio_integration.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Integration tests for MCP stdio transport.""" - -import contextlib -import json -import subprocess -import sys -from datetime import datetime, timezone - -import pytest - -from mcphawk import logger - - -class TestMCPStdioIntegration: - """Test MCP server stdio transport with real subprocess communication.""" - - @pytest.fixture - def test_db(self, tmp_path, monkeypatch): - """Create a temporary test database and patch the logger to use it.""" - # Create temp database - db_path = tmp_path / "test_stdio.db" - - # Monkeypatch the logger module to use our test database - monkeypatch.setattr(logger, "DB_PATH", str(db_path)) - logger.init_db() - - # Add some test data - test_messages = [ - { - "jsonrpc": "2.0", - "method": "tools/list", - "id": "test-1" - }, - { - "jsonrpc": "2.0", - "result": {"tools": ["test"]}, - "id": "test-1" - } - ] - - for i, msg in enumerate(test_messages): - entry = { - "log_id": f"test-{i}", - "timestamp": datetime.now(tz=timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 3000, - "dst_port": 8000, - "direction": "unknown", - "message": json.dumps(msg), - "transport_type": "unknown" - } - logger.log_message(entry) - - return db_path - - def test_stdio_initialize_handshake(self, test_db, monkeypatch): - """Test proper MCP initialization handshake over stdio.""" - # For this test, we'll use the simpler approach without database dependency - # The key is to test the protocol handshake works correctly - - # Prepare all requests - requests = [ - { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - }, - "id": 1 - }, - { - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {} - }, - { - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 2 - } - ] - - input_data = "\n".join(json.dumps(req) for req in requests) + "\n" - - # Start the MCP server - proc = subprocess.Popen( - [sys.executable, "-m", "mcphawk.cli", "mcp", "--transport", "stdio"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout, stderr = proc.communicate(input=input_data, timeout=5) - - # Parse responses - responses = [] - for line in stdout.strip().split('\n'): - if line: - with contextlib.suppress(json.JSONDecodeError): - responses.append(json.loads(line)) - - # Should have at least 2 responses (no response for notification) - assert len(responses) >= 2, f"Expected at least 2 responses, got {len(responses)}" - - # Check initialize response - assert responses[0]["id"] == 1 - assert "result" in responses[0] - assert responses[0]["result"]["protocolVersion"] == "2024-11-05" - assert responses[0]["result"]["serverInfo"]["name"] == "mcphawk-mcp" - - # Check tools/list response - assert responses[1]["id"] == 2 - assert "result" in responses[1] - assert "tools" in responses[1]["result"] - assert len(responses[1]["result"]["tools"]) == 5 # We have 5 tools - - def test_stdio_basic_communication(self): - """Test basic stdio communication without select.""" - # Use communicate() for simpler testing - requests = [ - { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - }, - "id": 1 - }, - { - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {} - }, - { - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 2 - } - ] - - input_data = "\n".join(json.dumps(req) for req in requests) + "\n" - - proc = subprocess.Popen( - [sys.executable, "-m", "mcphawk.cli", "mcp", "--transport", "stdio"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0 - ) - - stdout, stderr = proc.communicate(input=input_data, timeout=5) - - # Parse responses - responses = [] - for line in stdout.strip().split('\n'): - if line: - with contextlib.suppress(json.JSONDecodeError): - responses.append(json.loads(line)) - - # Debug output - if not responses: - print(f"STDOUT: {stdout}") - print(f"STDERR: {stderr}") - - # Should have 2 responses (no response for notification) - assert len(responses) >= 2, f"Expected at least 2 responses, got {len(responses)}" - - # Check initialize response - assert responses[0]["id"] == 1 - assert responses[0]["result"]["protocolVersion"] == "2024-11-05" - - # Check tools/list response - assert responses[1]["id"] == 2 - assert "tools" in responses[1]["result"] - - def test_stdio_logging_to_stderr(self): - """Test that all logging goes to stderr, not stdout.""" - # Use communicate for simpler test - input_data = json.dumps({ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - }, - "id": 1 - }) + "\n" - - proc = subprocess.Popen( - [sys.executable, "-m", "mcphawk.cli", "mcp", "--transport", "stdio", "--debug"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout, stderr = proc.communicate(input=input_data, timeout=5) - - # Response should be valid JSON (no log messages mixed in stdout) - response_line = stdout.strip() - response = json.loads(response_line) - assert response["id"] == 1 - assert "result" in response - - # Check stderr has log messages - assert "Starting MCP server" in stderr - - @pytest.mark.parametrize("tool_name,args,expected_in_result", [ - ("get_stats", {}, "total"), - ("list_methods", {}, []), # Empty list if no data - ]) - def test_stdio_tool_calls_basic(self, tool_name, args, expected_in_result): - """Test basic tool calls that don't require specific data.""" - # Use communicate for simpler test - requests = [ - { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - }, - "id": 1 - }, - { - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {} - }, - { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": args - }, - "id": 2 - } - ] - - input_data = "\n".join(json.dumps(req) for req in requests) + "\n" - - proc = subprocess.Popen( - [sys.executable, "-m", "mcphawk.cli", "mcp", "--transport", "stdio"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout, stderr = proc.communicate(input=input_data, timeout=5) - - # Parse responses - responses = [] - for line in stdout.strip().split('\n'): - if line: - with contextlib.suppress(json.JSONDecodeError): - responses.append(json.loads(line)) - - # Should have 2 responses (init and tool call) - assert len(responses) >= 2 - - # Check tool response - tool_response = responses[-1] # Last response should be tool call - assert tool_response["id"] == 2 - assert "result" in tool_response - assert "content" in tool_response["result"] - - # Check content - content = tool_response["result"]["content"][0]["text"] - if isinstance(expected_in_result, str): - assert expected_in_result in content - else: - # For list_methods, just check it's valid JSON - json.loads(content) diff --git a/tests/integration/network/test_ipv4_ipv6_capture.py b/tests/integration/network/test_ipv4_ipv6_capture.py deleted file mode 100644 index f1e4712..0000000 --- a/tests/integration/network/test_ipv4_ipv6_capture.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Test IPv4 and IPv6 traffic capture for unknown.""" -import json -import socket -import time - -import pytest -from scapy.layers.inet import IP, TCP -from scapy.layers.inet6 import IPv6 -from scapy.packet import Raw - -from mcphawk.logger import clear_logs, fetch_logs, init_db, set_db_path -from mcphawk.sniffer import packet_callback - - -@pytest.fixture -def test_db(tmp_path): - """Create a test database.""" - db_path = tmp_path / "test_ipv4_ipv6.db" - set_db_path(str(db_path)) - init_db() - yield db_path - clear_logs() - - -class TestIPv4Capture: - """Test IPv4 traffic capture.""" - - def test_ipv4_tcp_direct(self, test_db): - """Test IPv4 unknown traffic capture.""" - json_rpc = json.dumps({"jsonrpc": "2.0", "method": "ipv4_tcp_test", "id": 1}) - - # Create IPv4 packet - pkt = IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=12345, dport=54321) / Raw(load=json_rpc.encode()) - packet_callback(pkt) - - time.sleep(0.1) - - logs = fetch_logs(limit=1) - assert len(logs) == 1 - assert logs[0]["transport_type"] == "unknown" - assert logs[0]["src_ip"] == "127.0.0.1" - assert logs[0]["dst_ip"] == "127.0.0.1" - assert "ipv4_tcp_test" in logs[0]["message"] - - - -class TestIPv6Capture: - """Test IPv6 traffic capture.""" - - def test_ipv6_tcp_direct(self, test_db): - """Test IPv6 unknown traffic capture.""" - json_rpc = json.dumps({"jsonrpc": "2.0", "method": "ipv6_tcp_test", "id": 3}) - - # Create IPv6 packet - pkt = IPv6(src="::1", dst="::1") / TCP(sport=12345, dport=54321) / Raw(load=json_rpc.encode()) - packet_callback(pkt) - - time.sleep(0.1) - - logs = fetch_logs(limit=1) - # This test will show if IPv6 is captured - if len(logs) == 0: - pytest.skip("IPv6 traffic not captured - known limitation") - else: - assert logs[0]["transport_type"] == "unknown" - assert logs[0]["src_ip"] == "::1" - assert logs[0]["dst_ip"] == "::1" - assert "ipv6_tcp_test" in logs[0]["message"] - - - -class TestRealSocketCapture: - """Test with real sockets to verify actual network behavior.""" - - @pytest.mark.skipif(not socket.has_ipv6, reason="IPv6 not available") - def test_real_ipv6_connection(self, test_db): - """Test if real IPv6 socket connections are captured.""" - # This test requires a running server on ::1 - # We'll try to connect and see if it's captured - try: - sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - sock.settimeout(0.5) - - # Try to connect to a port that's likely not listening - # This will fail, but we can check if the SYN packet was captured - with pytest.raises((ConnectionRefusedError, socket.timeout)): - sock.connect(("::1", 59999)) - - sock.close() - time.sleep(0.1) - - # Check if any traffic was captured - logs = fetch_logs(limit=10) - ipv6_logs = [log for log in logs if "::1" in (log.get("src_ip", ""), log.get("dst_ip", ""))] - - if len(ipv6_logs) == 0: - pytest.skip("IPv6 traffic not captured by sniffer - needs IPv6 support") - - except Exception as e: - pytest.skip(f"IPv6 test failed: {e}") - - -def test_mixed_ipv4_ipv6_traffic(test_db): - """Test that both IPv4 and IPv6 traffic can be captured in the same session.""" - # Send IPv4 traffic - ipv4_msg = json.dumps({"jsonrpc": "2.0", "method": "mixed_ipv4", "id": 10}) - ipv4_pkt = IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=12345, dport=54321) / Raw(load=ipv4_msg.encode()) - packet_callback(ipv4_pkt) - - # Send IPv6 traffic - ipv6_msg = json.dumps({"jsonrpc": "2.0", "method": "mixed_ipv6", "id": 11}) - ipv6_pkt = IPv6(src="::1", dst="::1") / TCP(sport=12345, dport=54321) / Raw(load=ipv6_msg.encode()) - packet_callback(ipv6_pkt) - - time.sleep(0.1) - - logs = fetch_logs(limit=10) - - # Check IPv4 capture - ipv4_logs = [log for log in logs if "mixed_ipv4" in log.get("message", "")] - assert len(ipv4_logs) >= 1 - assert ipv4_logs[0]["src_ip"] == "127.0.0.1" - - # Check IPv6 capture (may not work) - ipv6_logs = [log for log in logs if "mixed_ipv6" in log.get("message", "")] - if len(ipv6_logs) == 0: - pytest.skip("IPv6 not captured - known limitation") - else: - assert ipv6_logs[0]["src_ip"] == "::1" diff --git a/tests/integration/network/test_sniff.py b/tests/integration/network/test_sniff.py new file mode 100644 index 0000000..d4a6891 --- /dev/null +++ b/tests/integration/network/test_sniff.py @@ -0,0 +1,194 @@ +import json + +from scapy.all import IP, TCP, IPv6, Raw + +from mcphawk.capture.sniff import FIN, SYN, Sniffer, _Direction, default_interface +from mcphawk.query import Query +from tests.traffic import modern_meta, server_meta + +CLIENT = ("127.0.0.1", 51000) +SERVER = ("127.0.0.1", 3000) + + +class Wire: + """Plays both sides of one TCP connection into a sniffer.""" + + def __init__(self, sniffer, client=CLIENT, server=SERVER): + self.sniffer, self.client, self.server = sniffer, client, server + self.seq = {client: 1000, server: 5000} + sniffer.process(client, server, 999, SYN, b"") + sniffer.process(server, client, 4999, SYN, b"") + + def send(self, src, data, chunk=None): + dst = self.server if src == self.client else self.client + chunk = chunk or len(data) or 1 + for i in range(0, len(data), chunk): + part = data[i:i + chunk] + self.sniffer.process(src, dst, self.seq[src], 0, part) + self.seq[src] += len(part) + + def close(self): + self.sniffer.process(self.client, self.server, self.seq[self.client], FIN, b"") + + +def post(body, headers=""): + data = json.dumps(body).encode() + return (f"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\n" + f"{headers}Content-Length: {len(data)}\r\n\r\n").encode() + data + + +def json_response(body, headers=""): + data = json.dumps(body).encode() + return (f"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{headers}" + f"Content-Length: {len(data)}\r\n\r\n").encode() + data + + +def chunked_sse(*bodies): + out = b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n" + for body in bodies: + event = f"data: {json.dumps(body)}\r\n\r\n".encode() + out += f"{len(event):x}\r\n".encode() + event + b"\r\n" + return out + b"0\r\n\r\n" + + +def test_modern_json_and_chunked_sse(recorder, db): + sniffer = Sniffer(recorder) + wire = Wire(sniffer) + listing = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": modern_meta()}} + wire.send(CLIENT, post(listing, "Mcp-Method: tools/list\r\n"), chunk=17) + wire.send(SERVER, json_response({"jsonrpc": "2.0", "id": 1, "result": { + "resultType": "complete", "tools": [], "_meta": server_meta("snf")}}), chunk=23) + call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "t", "_meta": modern_meta()}} + wire.send(CLIENT, post(call)) + wire.send(SERVER, chunked_sse( + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "result": {"resultType": "complete", "content": []}}), + chunk=11) + wire.close() + + q = Query(db) + [session] = q.list_sessions() + assert session["capture"] == "sniff" + assert session["target"] == "http://127.0.0.1:3000/mcp" + assert session["server_name"] == "snf" + detail = q.get_session(session["id"]) + assert [e["status"] for e in detail["exchanges"]] == ["ok", "ok"] + assert detail["notification_count"] == 1 + request = q.get_message(detail["exchanges"][0]["request_msg_id"]) + assert request["headers"]["Mcp-Method"] == "tools/list" + q.close() + + +def test_out_of_order_and_retransmitted_segments(recorder, db): + sniffer = Sniffer(recorder) + wire = Wire(sniffer) + data = post({"jsonrpc": "2.0", "id": 7, "method": "ping"}) + start = wire.seq[CLIENT] + a, b, c = data[:30], data[30:60], data[60:] + sniffer.process(CLIENT, SERVER, start + 60, 0, c) + sniffer.process(CLIENT, SERVER, start, 0, a) + sniffer.process(CLIENT, SERVER, start, 0, a) # full retransmit, dropped + sniffer.process(CLIENT, SERVER, start + 20, 0, a[20:] + b) # partial overlap + q = Query(db) + [exchange] = q.exchanges() + assert exchange["method"] == "ping" + q.close() + + +def test_legacy_http_sse_and_session_header(recorder, db): + sniffer = Sniffer(recorder) + stream = Wire(sniffer, client=("127.0.0.1", 51001)) + stream.send(stream.client, b"GET /sse HTTP/1.1\r\nAccept: text/event-stream\r\n\r\n") + stream.send(SERVER, b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" + b"event: endpoint\ndata: /messages?session_id=s1\n\n") + posts = Wire(sniffer, client=("127.0.0.1", 51002)) + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode() + posts.send(posts.client, b"POST /messages?session_id=s1 HTTP/1.1\r\nContent-Length: " + + str(len(body)).encode() + b"\r\n\r\n" + body) + posts.send(SERVER, b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") + stream.send(SERVER, b'data: {"jsonrpc":"2.0","id":1,"result":{"tools":[]}}\n\n' + b"data: not json\n\n") + stream.close() + + init = Wire(sniffer, client=("127.0.0.1", 51003)) + init.send(init.client, post({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "clientInfo": {"name": "c", "version": "1"}}})) + init.send(SERVER, json_response({"jsonrpc": "2.0", "id": 0, "result": { + "serverInfo": {"name": "legacy", "version": "1"}}}, "Mcp-Session-Id: abc\r\n")) + init.send(init.client, post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + "Mcp-Session-Id: abc\r\n")) + init.send(SERVER, json_response({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}})) + get = Wire(sniffer, client=("127.0.0.1", 51004)) + get.send(get.client, b"GET /mcp HTTP/1.1\r\nMcp-Session-Id: abc\r\n\r\n") + get.send(SERVER, b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" + b'data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}\n\n') + + q = Query(db) + sessions = {s["transport"]: s for s in q.list_sessions()} + assert sessions["http_sse"]["exchange_count"] == 1 + assert q.get_session(sessions["http_sse"]["id"])["exchanges"][0]["status"] == "ok" + modern = q.get_session(sessions["streamable_http"]["id"]) + assert modern["server_name"] == "legacy" + assert len(modern["exchanges"]) == 2 + assert modern["notification_count"] == 1 + q.close() + + +def test_raw_json_over_tcp_and_non_mcp_traffic(recorder, db): + sniffer = Sniffer(recorder, excluded_ports={9999}) + raw = Wire(sniffer, client=("::1", 40000), server=("::1", 7000)) + raw.send(raw.client, b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n') + raw.send(raw.server, b'{"jsonrpc":"2.0","id":1,"result":{}}\n{"not":"rpc"}\n') + raw.close() + other = Wire(sniffer, client=("127.0.0.1", 40001), server=("127.0.0.1", 22)) + other.send(other.client, b"SSH-2.0-OpenSSH\r\n") + web = Wire(sniffer, client=("127.0.0.1", 40002)) + web.send(web.client, b"GET / HTTP/1.1\r\n\r\n") + web.send(SERVER, b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi") + web.send(web.client, b"POST /x HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}") + orphan = Wire(sniffer, client=("127.0.0.1", 40003)) + orphan.send(SERVER, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + sniffer.process(("127.0.0.1", 1), ("127.0.0.1", 9999), 1, 0, b"{}") + sniffer.process(("127.0.0.1", 2), ("127.0.0.1", 3), 1, 0, b"") # bare ACK + + q = Query(db) + [session] = q.list_sessions() + assert session["target"] == "tcp://::1:7000" + assert session["transport"] == "unknown" + assert session["exchange_count"] == 1 + q.close() + + +def test_parse_errors_drop_the_connection(recorder, monkeypatch): + sniffer = Sniffer(recorder) + wire = Wire(sniffer) + + def explode(*_): + raise RuntimeError("bad") + + monkeypatch.setattr("mcphawk.capture.sniff._Connection.packet", explode) + wire.send(CLIENT, b"x") + assert sniffer._connections == {} + + +def test_handle_packet_with_scapy_layers(recorder, db): + sniffer = Sniffer(recorder) + body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' + sniffer.handle_packet(IP(src="10.0.0.1", dst="10.0.0.2") / TCP(sport=5, dport=6, seq=1) / Raw(body)) + sniffer.handle_packet(IPv6(src="::1", dst="::2") / TCP(sport=5, dport=6, seq=1)) + sniffer.handle_packet(IP() / Raw(b"no tcp")) + q = Query(db) + assert len(q.exchanges()) == 1 + q.close() + + +def test_direction_wraps_sequence_numbers(): + d = _Direction() + assert d.accept(2**32 - 2, b"abcd", syn=False) == [b"abcd"] + assert d.next_seq == 2 + assert d.accept(2, b"ef", syn=False) == [b"ef"] + + +def test_default_interface(): + assert default_interface() in ("lo0", None) diff --git a/tests/integration/network/test_sniffer.py b/tests/integration/network/test_sniffer.py deleted file mode 100644 index fd8e512..0000000 --- a/tests/integration/network/test_sniffer.py +++ /dev/null @@ -1,347 +0,0 @@ -import os -import socket -import threading -import time -from unittest.mock import MagicMock, patch - -import pytest -from scapy.all import Ether -from scapy.layers.inet import IP, TCP -from scapy.layers.inet6 import IPv6 -from scapy.packet import Raw - -from mcphawk.logger import get_db_connection, init_db, set_db_path -from mcphawk.sniffer import packet_callback, start_sniffer - -# --- TEST DB PATH --- -TEST_DB_DIR = "tests/test_logs" -TEST_DB = os.path.join(TEST_DB_DIR, "test_mcp_sniffer_logs.db") - - -@pytest.fixture(scope="module") -def clean_db(): - """Prepare a clean SQLite DB for tests.""" - # Save original DB path - import mcphawk.logger as logger_module - original_path = logger_module.DB_PATH - - os.makedirs(TEST_DB_DIR, exist_ok=True) - if os.path.exists(TEST_DB): - os.remove(TEST_DB) - - set_db_path(TEST_DB) - init_db() - yield - - # Restore original DB path - logger_module.DB_PATH = original_path - - -@pytest.fixture(scope="module") -def dummy_server(): - """Spin up a dummy MCP-like TCP echo server in a background thread.""" - host = "127.0.0.1" - - # Use port 0 to let OS assign an available port - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as temp_sock: - temp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - temp_sock.bind((host, 0)) - _, port = temp_sock.getsockname() - - def server(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind((host, port)) - s.listen() - conn, _ = s.accept() - with conn: - while True: - data = conn.recv(1024) - if not data: - break - conn.sendall(data) - - thread = threading.Thread(target=server, daemon=True) - thread.start() - time.sleep(0.5) - yield host, port - - -def test_packet_callback(clean_db, dummy_server): - """Simulate sending an MCP-like JSON-RPC packet and verify it's logged.""" - host, port = dummy_server - - jsonrpc_msg = ( - b'{"jsonrpc":"2.0","method":"callTool","params":{"tool":"weather",' - b'"location":"Berlin"}}' - ) - pkt = ( - Ether() - / IP(src="127.0.0.1", dst=host) - / TCP(sport=55555, dport=port) - / Raw(load=jsonrpc_msg) - ) - packet_callback(pkt) - - from pathlib import Path - - with get_db_connection(Path(TEST_DB)) as conn: - cur = conn.cursor() - cur.execute("SELECT message FROM logs ORDER BY log_id DESC LIMIT 1;") - logged_msg = cur.fetchone()[0] - - assert "weather" in logged_msg - assert "Berlin" in logged_msg - - - -class TestAutoDetect: - """Test auto-detect mode functionality.""" - - def setup_method(self): - """Reset global state before each test.""" - import mcphawk.sniffer - mcphawk.sniffer._auto_detect_mode = False - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - @patch('mcphawk.sniffer.logger') - def test_auto_detect_prints_port_info(self, mock_logger, mock_broadcast, mock_log): - """Test that auto-detect mode prints port information when MCP traffic is found.""" - import mcphawk.sniffer - mcphawk.sniffer._auto_detect_mode = True - - # Create a mock packet with MCP JSON-RPC - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=b'{"jsonrpc":"2.0","method":"test","id":1}'), - IP: MagicMock(src="10.0.0.1", dst="10.0.0.2"), - TCP: MagicMock(sport=54321, dport=3000) - }[layer] - - packet_callback(mock_pkt) - - # Check that port detection message was logged - mock_logger.info.assert_any_call("Detected MCP traffic on port 54321 -> 3000") - - # Verify log_message was called - assert mock_log.called - logged_entry = mock_log.call_args[0][0] - assert logged_entry["src_port"] == 54321 - assert logged_entry["dst_port"] == 3000 - assert "test" in logged_entry["message"] - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - @patch('mcphawk.sniffer.logger') - def test_non_auto_detect_no_port_print(self, mock_logger, mock_broadcast, mock_log): - """Test that port info is not printed when not in auto-detect mode.""" - import mcphawk.sniffer - mcphawk.sniffer._auto_detect_mode = False - - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=b'{"jsonrpc":"2.0","method":"test","id":1}'), - IP: MagicMock(src="10.0.0.1", dst="10.0.0.2"), - TCP: MagicMock(sport=3000, dport=54321) - }[layer] - - packet_callback(mock_pkt) - - # Should not log port detection message - for call in mock_logger.info.call_args_list: - assert "Detected MCP traffic on port" not in str(call) - - @patch('mcphawk.sniffer.sniff') - def test_start_sniffer_auto_detect_flag(self, mock_sniff): - """Test that start_sniffer sets auto_detect mode and uses correct filter.""" - start_sniffer(filter_expr="tcp", auto_detect=True) - - # Check that auto_detect mode was enabled - import mcphawk.sniffer - assert mcphawk.sniffer._auto_detect_mode == True - - # Check sniff was called with tcp filter - mock_sniff.assert_called_once() - call_kwargs = mock_sniff.call_args[1] - assert call_kwargs["filter"] == "tcp" - - -class TestHTTPParsing: - """Test HTTP request/response parsing for MCP over HTTP.""" - - def setup_method(self): - """Reset global state before each test.""" - # Reset any global state if needed - pass - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - def test_http_post_request_parsing(self, mock_broadcast, mock_log): - """Test parsing of HTTP POST request with JSON-RPC body.""" - http_request = ( - b'POST /mcp HTTP/1.1\r\n' - b'Host: localhost:8765\r\n' - b'Content-Type: application/json\r\n' - b'Content-Length: 89\r\n' - b'\r\n' - b'{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05"},"id":1}' - ) - - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=http_request), - IP: MagicMock(src="127.0.0.1", dst="127.0.0.1"), - TCP: MagicMock(sport=54321, dport=8765) - }[layer] - - packet_callback(mock_pkt) - - # Verify the JSON-RPC body was extracted and logged - assert mock_log.called - logged_entry = mock_log.call_args[0][0] - assert logged_entry["message"] == '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05"},"id":1}' - assert logged_entry["transport_type"] == "unknown" - assert logged_entry["src_port"] == 54321 - assert logged_entry["dst_port"] == 8765 - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - def test_http_response_parsing(self, mock_broadcast, mock_log): - """Test parsing of HTTP response with JSON-RPC body.""" - http_response = ( - b'HTTP/1.1 200 OK\r\n' - b'Content-Type: application/json\r\n' - b'Content-Length: 50\r\n' - b'\r\n' - b'{"jsonrpc":"2.0","result":{"status":"ok"},"id":1}' - ) - - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=http_response), - IP: MagicMock(src="127.0.0.1", dst="127.0.0.1"), - TCP: MagicMock(sport=8765, dport=54321) - }[layer] - - packet_callback(mock_pkt) - - # Verify the JSON-RPC body was extracted and logged - assert mock_log.called - logged_entry = mock_log.call_args[0][0] - assert logged_entry["message"] == '{"jsonrpc":"2.0","result":{"status":"ok"},"id":1}' - assert logged_entry["transport_type"] == "unknown" - assert logged_entry["src_port"] == 8765 - assert logged_entry["dst_port"] == 54321 - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - def test_http_without_jsonrpc_ignored(self, mock_broadcast, mock_log): - """Test that HTTP requests without JSON-RPC content are ignored.""" - http_request = ( - b'POST /api/test HTTP/1.1\r\n' - b'Host: localhost:8765\r\n' - b'Content-Type: application/json\r\n' - b'\r\n' - b'{"data":"not json-rpc"}' - ) - - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=http_request), - IP: MagicMock(src="127.0.0.1", dst="127.0.0.1"), - TCP: MagicMock(sport=54321, dport=8765) - }[layer] - - packet_callback(mock_pkt) - - # Should not log non-JSON-RPC content - assert not mock_log.called - - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - def test_mcphawk_mcp_traffic_server_info(self, mock_broadcast, mock_log): - """Test that MCPHawk's own MCP traffic uses server info tracking.""" - # Simulate an initialize response with serverInfo - http_response = ( - b'HTTP/1.1 200 OK\r\n' - b'Content-Type: application/json\r\n' - b'\r\n' - b'{"jsonrpc":"2.0","result":{"protocolVersion":"2024-11-05",' - b'"serverInfo":{"name":"mcphawk","version":"0.1.0"}},"id":1}' - ) - - mock_pkt = MagicMock() - # Note: haslayer should return True for IP but False for IPv6 - mock_pkt.haslayer.side_effect = lambda layer: { - Raw: True, - IP: True, - TCP: True, - IPv6: False - }.get(layer, False) - - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=http_response), - IP: MagicMock(src="127.0.0.1", dst="127.0.0.1"), - TCP: MagicMock(sport=8765, dport=54321) - }[layer] - - packet_callback(mock_pkt) - - # Verify the server info was logged - assert mock_log.called - # The test verifies that server info tracking works through the normal server registry mechanism - - def test_state_isolation_between_tests(self): - """Test that state is properly isolated between tests.""" - # MCPHawk ports no longer tracked separately - pass - # State isolation is maintained through other global variables - - @patch('mcphawk.sniffer.log_message') - @patch('mcphawk.sniffer._broadcast_in_any_loop') - def test_http_sse_response_parsing(self, mock_broadcast, mock_log): - """Test parsing of Server-Sent Events (SSE) responses with JSON-RPC.""" - import json - - json_content = json.dumps({ - "jsonrpc": "2.0", - "result": { - "tools": [ - {"name": "get_stats", "description": "Get traffic stats"} - ] - }, - "id": 2 - }) - - sse_response = ( - f"HTTP/1.1 200 OK\r\n" - f"Content-Type: text/event-stream\r\n" - f"Cache-Control: no-cache\r\n" - f"\r\n" - f"data: {json_content}\n\n" - ).encode() - - mock_pkt = MagicMock() - mock_pkt.haslayer.side_effect = lambda layer: layer in [Raw, IP, TCP] - mock_pkt.__getitem__.side_effect = lambda layer: { - Raw: MagicMock(load=sse_response), - IP: MagicMock(src="127.0.0.1", dst="127.0.0.1"), - TCP: MagicMock(sport=8765, dport=54321) - }[layer] - - packet_callback(mock_pkt) - - # Verify the JSON-RPC was extracted from SSE format and logged - assert mock_log.called - logged_entry = mock_log.call_args[0][0] - assert logged_entry["message"] == json_content - assert logged_entry["transport_type"] == "unknown" - assert logged_entry["src_port"] == 8765 - assert logged_entry["dst_port"] == 54321 diff --git a/tests/integration/network/test_sniffer_traffic_type.py b/tests/integration/network/test_sniffer_traffic_type.py deleted file mode 100644 index ddbfc2d..0000000 --- a/tests/integration/network/test_sniffer_traffic_type.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Test that sniffer properly sets transport_type for captured packets.""" -import json -import time - -import pytest -from scapy.layers.inet import IP, TCP -from scapy.packet import Raw - -from mcphawk.logger import clear_logs, fetch_logs, init_db, set_db_path -from mcphawk.sniffer import packet_callback - - -@pytest.fixture -def test_db(tmp_path): - """Create a test database.""" - db_path = tmp_path / "test_sniffer_transport_type.db" - set_db_path(str(db_path)) - init_db() - yield db_path - clear_logs() - - -def test_sniffer_tcp_transport_type(test_db): - """Test that sniffer marks TCP JSON-RPC traffic with transport_type='TCP'.""" - # Create a mock TCP packet with JSON-RPC content - json_rpc = json.dumps({"jsonrpc": "2.0", "method": "test", "id": 1}) - - # Create packet layers - ip = IP(src="127.0.0.1", dst="127.0.0.1") - tcp = TCP(sport=12345, dport=54321) - raw = Raw(load=json_rpc.encode()) - - # Construct the packet - pkt = ip / tcp / raw - - # Process the packet - packet_callback(pkt) - - # Give it a moment to process - time.sleep(0.1) - - # Check the logged entry - logs = fetch_logs(limit=1) - assert len(logs) == 1 - assert logs[0]["transport_type"] == "unknown" - assert logs[0]["src_port"] == 12345 - assert logs[0]["dst_port"] == 54321 - - - - -def test_sniffer_non_jsonrpc_ignored(test_db): - """Test that non-JSON-RPC traffic is ignored.""" - # Create a packet with non-JSON-RPC content - ip = IP(src="127.0.0.1", dst="127.0.0.1") - tcp = TCP(sport=12345, dport=54321) - raw = Raw(load=b"Hello, this is not JSON-RPC") - - # Construct the packet - pkt = ip / tcp / raw - - # Process the packet - packet_callback(pkt) - - # Give it a moment to process - time.sleep(0.1) - - # Should have no logs - logs = fetch_logs(limit=1) - assert len(logs) == 0 diff --git a/tests/integration/web/test_app.py b/tests/integration/web/test_app.py new file mode 100644 index 0000000..4e20774 --- /dev/null +++ b/tests/integration/web/test_app.py @@ -0,0 +1,123 @@ +import json + +import pytest +from fastapi.testclient import TestClient + +from mcphawk.web.app import create_app +from tests.traffic import Clock, legacy_session, modern_session + + +@pytest.fixture +def client(db, recorder): + clock = Clock() + legacy_session(recorder, clock, run_key="pid:1", client_app="claude") + modern_session(recorder, clock, run_key="pid:1", client_app="claude", name="search") + app = create_app(db, upstreams=lambda: {"demo": "http://127.0.0.1:1/mcp"}, with_mcp=False, + static_dir=None) + with TestClient(app) as c: + yield c + + +def test_read_api(client): + stats = client.get("/api/stats").json() + assert stats["sessions"] == 2 + assert stats["proxies"] == {"demo": "http://127.0.0.1:1/mcp"} + + [run] = client.get("/api/runs").json() + assert run["run_key"] == "pid:1" + detail = client.get("/api/runs/pid:1").json() + assert len(detail["sessions"]) == 2 + assert client.get("/api/runs/nope").status_code == 404 + + sessions = client.get("/api/sessions", params={"server": "search"}).json() + sid = sessions[0]["id"] + assert client.get(f"/api/sessions/{sid}").json()["era"] == "modern" + assert client.get("/api/sessions/zzz").status_code == 404 + assert len(client.get(f"/api/sessions/{sid}/messages").json()) == 8 + assert client.get(f"/api/sessions/{sid}/lint").json() != [] + assert client.get("/api/sessions/zzz/lint").status_code == 404 + + calls = client.get("/api/exchanges", params={"method": "tools/call", "q": "Berlin"}).json() + assert len(calls) == 1 + exchange = client.get(f"/api/exchanges/{calls[0]['id']}").json() + assert exchange["request"]["body"]["params"]["arguments"] == {"city": "Berlin"} + assert client.get("/api/exchanges/999").status_code == 404 + assert client.get(f"/api/messages/{exchange['request_msg_id']}").status_code == 200 + assert client.get("/api/messages/999").status_code == 404 + + +def test_analysis_api(client): + problems = client.get("/api/problems").json() + assert problems["summary"]["error"] >= 2 + assert client.get("/api/problems", params={"min_severity": "loud"}).status_code == 422 + cost = client.get("/api/cost", params={"run_key": "pid:1"}).json() + assert {s["server"] for s in cost["servers"]} == {"weather", "search"} + sessions = [s["id"] for s in client.get("/api/sessions").json()] + compare = client.get("/api/compare", params={"before": sessions[1], "after": sessions[0]}) + assert compare.json()["tools"]["removed"] == ["search"] + missing = client.get("/api/compare", params={"before": "x", "after": sessions[0]}) + assert missing.status_code == 404 + + +def test_setup_and_clear(client, monkeypatch): + from mcphawk.install import installer + + monkeypatch.setattr(installer, "plan", lambda **kw: []) + assert client.get("/api/setup").json() == {"servers": []} + assert client.delete("/api/data").status_code == 400 + assert client.delete("/api/data", params={"confirm": True}).json() == {"cleared": True} + assert client.get("/api/stats").json()["sessions"] == 0 + + +def test_live_websocket_pushes_new_messages(client, recorder): + from mcphawk.store import C2S + from mcphawk.web import app as web_app + + web_app.LIVE_POLL_S = 0.05 + with client.websocket_connect("/api/live") as ws: + sid = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(sid, C2S, json.dumps({"jsonrpc": "2.0", "id": 1, "method": "ping"})) + update = ws.receive_json() + assert update["type"] == "messages" + assert update["items"][0]["method"] == "ping" + + +def test_proxy_route_is_mounted(client): + missing = client.post("/p/unknown", json={"jsonrpc": "2.0", "id": 1, "method": "x"}) + assert missing.status_code == 404 + down = client.post("/p/demo", json={"jsonrpc": "2.0", "id": 1, "method": "x"}) + assert down.status_code == 502 + + +def test_no_ui_fallback(client): + assert "not built" in client.get("/").json()["message"] + + +def test_spa_serving(db, tmp_path): + static = tmp_path / "static" + (static / "assets").mkdir(parents=True) + (static / "index.html").write_text("app") + (static / "assets" / "app.js").write_text("js") + (static / "favicon.svg").write_text("") + app = create_app(db, upstreams=dict, with_mcp=False, static_dir=static) + with TestClient(app) as c: + assert c.get("/").text == "app" + assert c.get("/s/abc123").text == "app" # client-side route + assert c.get("/assets/app.js").text == "js" + assert c.get("/favicon.svg").text == "" + assert c.get("/../../etc/passwd").text == "app" + assert c.get("/api/unknown").status_code == 404 + + +def test_mcp_endpoint_is_mounted(db): + app = create_app(db, upstreams=dict, static_dir=None) + with TestClient(app, base_url="http://127.0.0.1:8484") as c: + response = c.post("/mcp", json={ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": {}}}}, + headers={"Accept": "application/json, text/event-stream", + "Mcp-Method": "tools/list", "MCP-Protocol-Version": "2026-07-28"}) + assert response.status_code == 200 + assert "find_problems" in response.text diff --git a/tests/integration/web/test_broadcaster.py b/tests/integration/web/test_broadcaster.py deleted file mode 100644 index 9a42a98..0000000 --- a/tests/integration/web/test_broadcaster.py +++ /dev/null @@ -1,74 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest - -from mcphawk.web.broadcaster import active_clients, broadcast_new_log - - -@pytest.mark.asyncio -async def test_broadcast_new_log_with_clients(): - # Create mock websocket clients - mock_client1 = AsyncMock() - mock_client2 = AsyncMock() - - # Add to active clients - active_clients.append(mock_client1) - active_clients.append(mock_client2) - - try: - # Broadcast a message - test_log = { - "timestamp": "2024-01-01T12:00:00Z", - "src_ip": "127.0.0.1", - "message": "test" - } - - await broadcast_new_log(test_log) - - # Verify both clients received the message - mock_client1.send_json.assert_called_once_with(test_log) - mock_client2.send_json.assert_called_once_with(test_log) - - finally: - # Clean up - active_clients.clear() - - -@pytest.mark.asyncio -async def test_broadcast_new_log_with_disconnected_client(): - # Create mock websocket clients - mock_client1 = AsyncMock() - mock_client2 = AsyncMock() - - # Make client1 throw an exception (disconnected) - mock_client1.send_json.side_effect = Exception("Disconnected") - - # Add to active clients - active_clients.append(mock_client1) - active_clients.append(mock_client2) - - try: - # Broadcast a message - test_log = {"test": "data"} - - await broadcast_new_log(test_log) - - # Client1 should be removed from active_clients - assert mock_client1 not in active_clients - assert mock_client2 in active_clients - - # Client2 should still receive the message - mock_client2.send_json.assert_called_once_with(test_log) - - finally: - # Clean up - active_clients.clear() - - -@pytest.mark.asyncio -async def test_broadcast_new_log_no_clients(): - # Ensure list is empty - active_clients.clear() - - # Should not raise any errors - await broadcast_new_log({"test": "data"}) diff --git a/tests/integration/web/test_web.py b/tests/integration/web/test_web.py deleted file mode 100644 index 5756280..0000000 --- a/tests/integration/web/test_web.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -Test suite for MCPHawk Web API and WebSocket (Phase 1.6 Final). - -Covers: -- REST API for fetching logs -- WebSocket broadcasting of new log entries -""" - -import os -import tempfile -import uuid -from datetime import datetime, timezone - -import pytest -from fastapi.testclient import TestClient - -from mcphawk.logger import init_db, log_message, set_db_path -from mcphawk.web.broadcaster import broadcast_new_log -from mcphawk.web.server import app - -client = TestClient(app) - -# Use a temporary test database -TEST_DB_PATH = os.path.join(tempfile.gettempdir(), "test_web_mcphawk.db") - - -@pytest.fixture(scope="module") -def setup_test_db(): - """Set up test database for all tests in this module.""" - # Save original DB path - import mcphawk.logger as logger_module - original_path = logger_module.DB_PATH - - set_db_path(TEST_DB_PATH) - init_db() - yield - # Cleanup - if os.path.exists(TEST_DB_PATH): - os.remove(TEST_DB_PATH) - - # Restore original DB path - logger_module.DB_PATH = original_path - - -@pytest.fixture(autouse=True) -def clean_db(setup_test_db): - """ - Ensure the database is cleared before each test. - """ - from pathlib import Path - - from mcphawk.logger import get_db_connection - - with get_db_connection(Path(TEST_DB_PATH)) as conn: - conn.execute("DELETE FROM logs;") - conn.commit() - yield - - -def create_test_log(message, **kwargs): - """Helper to create a log entry with required log_id.""" - entry = { - "log_id": str(uuid.uuid4()), - "timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "message": message, - } - entry.update(kwargs) - return entry - - -def test_get_logs_limit(setup_test_db): - """ - Ensure /logs returns valid JSON and respects the limit parameter. - """ - # Insert two logs for testing - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "message": '{"jsonrpc":"2.0","method":"ping"}', - } - ) - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "message": '{"jsonrpc":"2.0","method":"pong"}', - } - ) - - response = client.get("/logs?limit=1") - assert response.status_code == 200 - - data = response.json() - assert isinstance(data, list) - assert len(data) == 1 - assert "message" in data[0] - assert "jsonrpc" in data[0]["message"] - - -def test_get_logs_multiple(setup_test_db): - """ - Ensure /logs can return multiple rows when more logs are inserted. - """ - # Insert multiple logs - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "message": '{"jsonrpc":"2.0","method":"ping"}', - } - ) - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "message": '{"jsonrpc":"2.0","method":"pong"}', - } - ) - - response = client.get("/logs?limit=2") - assert response.status_code == 200 - - data = response.json() - assert isinstance(data, list) - assert len(data) == 2 - - -@pytest.mark.asyncio -async def test_websocket_broadcast(setup_test_db): - """ - Ensure that new log entries are broadcast to connected WebSocket clients. - """ - # Connect to WebSocket - with client.websocket_connect("/ws") as websocket: - new_log = { - "timestamp": datetime.now(timezone.utc).isoformat(), - "src": "127.0.0.1", - "dst": "127.0.0.1", - "message": '{"jsonrpc":"2.0","method":"testMethod"}', - "method": "testMethod", - } - - # Broadcast the new log - await broadcast_new_log(new_log) - - # Receive the message (the test WebSocket doesn't send pings) - data = websocket.receive_json() - assert "message" in data - assert "jsonrpc" in data["message"] - assert data["method"] == "testMethod" - - -def test_empty_database_returns_empty_list(setup_test_db): - """Test that /logs returns empty list when database is empty.""" - response = client.get("/logs?limit=10") - assert response.status_code == 200 - assert response.json() == [] - - -def test_logs_persist_across_requests(setup_test_db): - """Test that logs persist between different API requests.""" - # Add a log - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "192.168.1.1", - "dst_ip": "192.168.1.2", - "src_port": 12345, - "dst_port": 8080, - "direction": "incoming", - "message": '{"jsonrpc":"2.0","method":"persistTest","id":1}' - }) - - # First request - response1 = client.get("/logs?limit=10") - assert response1.status_code == 200 - data1 = response1.json() - assert len(data1) == 1 - assert "persistTest" in data1[0]["message"] - - # Second request should return the same data - response2 = client.get("/logs?limit=10") - assert response2.status_code == 200 - data2 = response2.json() - assert len(data2) == 1 - assert data1[0]["message"] == data2[0]["message"] - - -def test_logs_order_newest_first(setup_test_db): - """Test that logs are returned in reverse chronological order.""" - import time - - # Add logs with small delays to ensure different timestamps - for i in range(3): - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 12345, - "dst_port": 8080, - "direction": "incoming", - "message": f'{{"jsonrpc":"2.0","method":"order{i}","id":{i}}}' - }) - time.sleep(0.01) # Small delay to ensure different timestamps - - response = client.get("/logs?limit=10") - assert response.status_code == 200 - data = response.json() - - # Should be in reverse order (newest first) - assert len(data) == 3 - assert "order2" in data[0]["message"] - assert "order1" in data[1]["message"] - assert "order0" in data[2]["message"] - - -def test_logs_default_limit(setup_test_db): - """Test that default limit of 50 is applied.""" - # Add 60 logs - for i in range(60): - log_message({ - "log_id": str(uuid.uuid4()),"timestamp": datetime.now(timezone.utc), - "src_ip": "127.0.0.1", - "dst_ip": "127.0.0.1", - "src_port": 12345, - "dst_port": 8080, - "direction": "incoming", - "message": f'{{"jsonrpc":"2.0","method":"bulk","id":{i}}}' - }) - - # Request without limit should return 50 - response = client.get("/logs") - assert response.status_code == 200 - data = response.json() - assert len(data) == 50 - - -def test_log_fields_preserved_in_api(setup_test_db): - """Test that all log fields are preserved through the API.""" - test_entry = { - "log_id": str(uuid.uuid4()), - "timestamp": datetime.now(timezone.utc), - "src_ip": "10.0.0.1", - "dst_ip": "10.0.0.2", - "src_port": 54321, - "dst_port": 9999, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"fieldTest","id":"abc"}' - } - - log_message(test_entry) - - response = client.get("/logs?limit=1") - assert response.status_code == 200 - data = response.json() - - assert len(data) == 1 - log = data[0] - - assert log["src_ip"] == "10.0.0.1" - assert log["dst_ip"] == "10.0.0.2" - assert log["src_port"] == 54321 - assert log["dst_port"] == 9999 - assert log["direction"] == "outgoing" - assert log["message"] == '{"jsonrpc":"2.0","method":"fieldTest","id":"abc"}' - assert "timestamp" in log # Should be ISO format string diff --git a/tests/integration/web/test_web_server.py b/tests/integration/web/test_web_server.py deleted file mode 100644 index 2af82b3..0000000 --- a/tests/integration/web/test_web_server.py +++ /dev/null @@ -1,203 +0,0 @@ -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -import pytest -from fastapi.testclient import TestClient - -from mcphawk.web.server import app - - -@pytest.fixture -def client(): - return TestClient(app) - - -@pytest.fixture -def mock_fetch_logs(): - with patch("mcphawk.web.server.fetch_logs") as mock: - mock.return_value = [ - { - "timestamp": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "src_ip": "127.0.0.1", - "src_port": 3000, - "dst_ip": "127.0.0.1", - "dst_port": 12345, - "direction": "outgoing", - "message": '{"jsonrpc":"2.0","method":"test","id":1}', - }, - { - "timestamp": datetime(2024, 1, 1, 12, 0, 1, tzinfo=timezone.utc), - "src_ip": "127.0.0.1", - "src_port": 12345, - "dst_ip": "127.0.0.1", - "dst_port": 3000, - "direction": "incoming", - "message": '{"jsonrpc":"2.0","result":"ok","id":1}', - }, - ] - yield mock - - - -def test_logs_endpoint(client, mock_fetch_logs): - response = client.get("/logs") - assert response.status_code == 200 - data = response.json() - assert len(data) == 2 - assert data[0]["src_ip"] == "127.0.0.1" - assert data[0]["message"] == '{"jsonrpc":"2.0","method":"test","id":1}' - assert data[1]["message"] == '{"jsonrpc":"2.0","result":"ok","id":1}' - - -def test_logs_endpoint_with_limit(client, mock_fetch_logs): - response = client.get("/logs?limit=1") - assert response.status_code == 200 - mock_fetch_logs.assert_called_once_with(1) - - -def test_websocket_connection(): - with TestClient(app) as client, client.websocket_connect("/ws"): - # WebSocket should connect successfully - # The connection is automatically closed when exiting the context - pass - - -def test_websocket_ping_pong(): - import asyncio - with TestClient(app) as client, patch("asyncio.wait_for") as mock_wait_for: - # Mock asyncio.wait_for to simulate timeout - mock_wait_for.side_effect = asyncio.TimeoutError() - - with client.websocket_connect("/ws") as websocket: - # This should trigger the timeout handling - try: - # The server should send a ping - data = websocket.receive_json() - assert data == {"type": "ping"} - except Exception: - # Connection might close, that's ok - pass - - -def test_websocket_disconnect(): - with TestClient(app) as client, client.websocket_connect("/ws") as websocket: - # Force disconnect by closing - websocket.close() - # Should handle gracefully - - -def test_websocket_broadcast(): - with TestClient(app) as client, client.websocket_connect("/ws") as websocket: - # Test that websocket connects and can receive messages - # Just verify the websocket connection works - assert websocket is not None - # Could send a test message if needed - # websocket.send_json({"type": "ping"}) - - - - -def test_start_sniffer_thread(): - from mcphawk.web.server import _start_sniffer_thread - - with patch("mcphawk.sniffer.start_sniffer"), patch("threading.Thread") as mock_thread: - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - _start_sniffer_thread("tcp port 3000", auto_detect=False, debug=True) - - # Verify thread was created with correct arguments - mock_thread.assert_called_once() - args, kwargs = mock_thread.call_args - assert kwargs["daemon"] is True - assert callable(kwargs["target"]) - - # Verify thread was started - mock_thread_instance.start.assert_called_once() - - -def test_run_web_with_sniffer(): - from mcphawk.web.server import run_web - - with patch("uvicorn.run") as mock_uvicorn, \ - patch("mcphawk.web.server._start_sniffer_thread") as mock_start_sniffer, \ - patch("mcphawk.logger.init_db"): - run_web( - sniffer=True, - host="0.0.0.0", - port=9000, - filter_expr="tcp port 3000", - auto_detect=False, - debug=True - ) - - # Verify sniffer was started - mock_start_sniffer.assert_called_once_with("tcp port 3000", False, True, None) - - # Verify uvicorn was started with correct params - mock_uvicorn.assert_called_once() - args, kwargs = mock_uvicorn.call_args - assert args[0] == app # First arg is the app - assert kwargs["host"] == "0.0.0.0" - assert kwargs["port"] == 9000 - - -def test_run_web_without_sniffer(): - from mcphawk.web.server import run_web - - with patch("uvicorn.run") as mock_uvicorn, \ - patch("mcphawk.web.server._start_sniffer_thread") as mock_start_sniffer, \ - patch("mcphawk.logger.init_db"): - run_web( - sniffer=False, - host="127.0.0.1", - port=8000, - debug=False - ) - - # Verify sniffer was NOT started - mock_start_sniffer.assert_not_called() - - # Verify uvicorn was started with correct params - mock_uvicorn.assert_called_once() - args, kwargs = mock_uvicorn.call_args - assert args[0] == app # First arg is the app - assert kwargs["host"] == "127.0.0.1" - assert kwargs["port"] == 8000 - - -def test_run_web_sniffer_without_filter(): - from mcphawk.web.server import run_web - - # Test that ValueError is raised when sniffer=True but no filter_expr - with pytest.raises(ValueError, match="filter_expr is required"): - run_web(sniffer=True, filter_expr=None) - - -def test_status_endpoint(client): - """Test /status endpoint returns MCP server status.""" - response = client.get("/status") - assert response.status_code == 200 - data = response.json() - assert "with_mcp" in data - assert isinstance(data["with_mcp"], bool) - - -def test_status_endpoint_with_mcp_enabled(): - """Test /status endpoint when MCP is enabled.""" - import mcphawk.web.server - from mcphawk.web.server import app - - # Set MCP flag - original_value = mcphawk.web.server._with_mcp - mcphawk.web.server._with_mcp = True - - try: - with TestClient(app) as client: - response = client.get("/status") - assert response.status_code == 200 - data = response.json() - assert data["with_mcp"] is True - finally: - # Restore original value - mcphawk.web.server._with_mcp = original_value diff --git a/tests/traffic.py b/tests/traffic.py new file mode 100644 index 0000000..d828fb8 --- /dev/null +++ b/tests/traffic.py @@ -0,0 +1,123 @@ +"""Builders for realistic MCP traffic used across the test suite.""" + +import json +from typing import Any + +from mcphawk.store import C2S, S2C, Recorder + +MODERN = "2026-07-28" +LEGACY = "2025-06-18" + +WEATHER_TOOL = { + "name": "get_weather", + "description": "Get the current weather for a city.", + "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}}, +} +SEARCH_TOOL = { + "name": "search", + "description": "Search the knowledge base. " * 40, + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + }, +} + + +def frame(**msg: Any) -> str: + return json.dumps({"jsonrpc": "2.0", **msg}) + + +def modern_meta(client: str = "claude-code") -> dict[str, Any]: + return { + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientInfo": {"name": client, "version": "2.1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + + +def server_meta(name: str = "weather") -> dict[str, Any]: + return {"io.modelcontextprotocol/serverInfo": {"name": name, "version": "1.2.0"}} + + +class Clock: + def __init__(self, start: float = 1_000_000.0): + self.t = start + + def __call__(self, step: float = 0.01) -> float: + self.t += step + return self.t + + +def legacy_session(rec: Recorder, clock: Clock | None = None, **open_kwargs: Any) -> str: + """initialize handshake, tools/list, a good call, an error and a tool error.""" + clock = clock or Clock() + sid = rec.open_session(capture="wrap", transport="stdio", ts=clock(), **{ + "name": "weather", "target": "python weather.py", "pid": None, **open_kwargs}) + rec.record(sid, C2S, frame(id=0, method="initialize", params={ + "protocolVersion": LEGACY, "capabilities": {}, + "clientInfo": {"name": "claude-ai", "version": "0.1.0"}}), ts=clock()) + rec.record(sid, S2C, frame(id=0, result={ + "protocolVersion": LEGACY, "capabilities": {"tools": {}}, + "serverInfo": {"name": "weather-server", "version": "1.0.0"}, + "instructions": "Use get_weather for forecasts."}), ts=clock()) + rec.record(sid, C2S, frame(method="notifications/initialized"), ts=clock()) + rec.record(sid, C2S, frame(id=1, method="tools/list"), ts=clock()) + rec.record(sid, S2C, frame(id=1, result={"tools": [WEATHER_TOOL, SEARCH_TOOL]}), ts=clock()) + rec.record(sid, C2S, frame(id=2, method="tools/call", params={ + "name": "get_weather", "arguments": {"city": "Berlin"}}), ts=clock()) + rec.record(sid, S2C, frame(id=2, result={ + "content": [{"type": "text", "text": "Sunny, 21C"}]}), ts=clock(0.25)) + rec.record(sid, C2S, frame(id=3, method="tools/call", params={ + "name": "search", "arguments": {"query": "x"}}), ts=clock()) + rec.record(sid, S2C, frame(id=3, error={"code": -32602, "message": "bad query"}), + ts=clock()) + rec.record(sid, C2S, frame(id=4, method="tools/call", params={ + "name": "get_weather", "arguments": {"city": "Atlantis"}}), ts=clock()) + rec.record(sid, S2C, frame(id=4, result={ + "isError": True, "content": [{"type": "text", "text": "unknown city"}]}), + ts=clock()) + return sid + + +def modern_session(rec: Recorder, clock: Clock | None = None, headers: bool = False, + **open_kwargs: Any) -> str: + """Stateless 2026-07-28 traffic including a multi round-trip chain.""" + clock = clock or Clock() + sid = rec.open_session(capture="proxy", transport="streamable_http", ts=clock(), **{ + "name": "weather", "target": "http://localhost:9000/mcp", **open_kwargs}) + + def hdrs(method: str, name: str | None = None) -> dict[str, str] | None: + if not headers: + return None + out = {"Mcp-Method": method, "Authorization": "Bearer abcdefghijklmnop"} + if name: + out["Mcp-Name"] = name + return out + + rec.record(sid, C2S, frame(id=1, method="server/discover", params={ + "_meta": modern_meta()}), headers=hdrs("server/discover"), ts=clock()) + rec.record(sid, S2C, frame(id=1, result={ + "resultType": "complete", "supportedVersions": [MODERN], "capabilities": {}, + "serverInfo": {"name": "weather", "version": "1.2.0"}, + "instructions": "Weather tools.", "ttlMs": 60000, "cacheScope": "public", + "_meta": server_meta()}), ts=clock()) + rec.record(sid, C2S, frame(id=2, method="tools/list", params={"_meta": modern_meta()}), + headers=hdrs("tools/list"), ts=clock()) + rec.record(sid, S2C, frame(id=2, result={ + "resultType": "complete", "tools": [WEATHER_TOOL], "ttlMs": 60000, + "cacheScope": "public", "_meta": server_meta()}), ts=clock()) + rec.record(sid, C2S, frame(id=3, method="tools/call", params={ + "name": "get_weather", "arguments": {"city": "Paris"}, "_meta": modern_meta()}), + headers=hdrs("tools/call", "get_weather"), ts=clock()) + rec.record(sid, S2C, frame(id=3, result={ + "resultType": "input_required", "requestState": "state-1", + "inputRequests": {"u": {"method": "elicitation/create", "params": {}}}, + "_meta": server_meta()}), ts=clock()) + rec.record(sid, C2S, frame(id=4, method="tools/call", params={ + "name": "get_weather", "arguments": {"city": "Paris"}, "requestState": "state-1", + "inputResponses": {"u": {"action": "accept"}}, "_meta": modern_meta()}), + headers=hdrs("tools/call", "get_weather"), ts=clock()) + rec.record(sid, S2C, frame(id=4, result={ + "resultType": "complete", "content": [{"type": "text", "text": "Rain"}], + "_meta": server_meta()}), ts=clock(0.5)) + return sid diff --git a/tests/unit/test_jsonrpc.py b/tests/unit/test_jsonrpc.py new file mode 100644 index 0000000..8c1cc57 --- /dev/null +++ b/tests/unit/test_jsonrpc.py @@ -0,0 +1,30 @@ +from mcphawk.protocol import jsonrpc + + +def test_parse_frame_single_and_batch(): + assert jsonrpc.parse_frame('{"jsonrpc":"2.0","id":1,"method":"x"}') == [ + {"jsonrpc": "2.0", "id": 1, "method": "x"}] + batch = jsonrpc.parse_frame('[{"jsonrpc":"2.0","method":"a"}, 3]') + assert batch == [{"jsonrpc": "2.0", "method": "a"}, {}] + + +def test_parse_frame_rejects_non_json_and_scalars(): + assert jsonrpc.parse_frame("Server started on port 3000") is None + assert jsonrpc.parse_frame("42") is None + assert jsonrpc.parse_frame(None) is None + + +def test_classify(): + assert jsonrpc.classify({"jsonrpc": "2.0", "id": 1, "method": "m"}) == jsonrpc.REQUEST + assert jsonrpc.classify({"jsonrpc": "2.0", "method": "m"}) == jsonrpc.NOTIFICATION + assert jsonrpc.classify({"jsonrpc": "2.0", "id": None, "method": "m"}) == jsonrpc.NOTIFICATION + assert jsonrpc.classify({"jsonrpc": "2.0", "id": 1, "result": {}}) == jsonrpc.RESPONSE + assert jsonrpc.classify({"jsonrpc": "2.0", "id": 1, "error": {"code": 1}}) == jsonrpc.ERROR + assert jsonrpc.classify({"jsonrpc": "1.0", "id": 1, "method": "m"}) == jsonrpc.INVALID + assert jsonrpc.classify({"jsonrpc": "2.0", "result": {}}) == jsonrpc.INVALID + assert jsonrpc.classify({}) == jsonrpc.INVALID + + +def test_id_key_keeps_types_distinct(): + assert jsonrpc.id_key(1) != jsonrpc.id_key("1") + assert jsonrpc.id_key(None) is None diff --git a/tests/unit/test_masking_and_tokens.py b/tests/unit/test_masking_and_tokens.py new file mode 100644 index 0000000..e1e81f6 --- /dev/null +++ b/tests/unit/test_masking_and_tokens.py @@ -0,0 +1,61 @@ +from mcphawk.protocol import masking, tokens +from mcphawk.protocol.masking import MASK + + +def test_mask_value_masks_secret_keys_but_not_lookalikes(): + data = { + "api_key": "abc", "token": "t", "clientSecret": "s", "password": "p", + "max_tokens": 100, "tokens_used": "12", "nested": [{"Authorization": "x"}], + "empty_token": "", + } + masked = masking.mask_value(data) + assert masked["api_key"] == masked["token"] == masked["password"] == MASK + assert masked["nested"][0]["Authorization"] == MASK + assert masked["max_tokens"] == 100 + assert masked["tokens_used"] == "12" + assert masked["empty_token"] == "" + + +def test_mask_text_patterns(): + text = ("key sk-ant-abcdefghijklmnopqrstuv and ghp_" + "a" * 36 + + " AKIAABCDEFGHIJKLMNOP Bearer abc.def.ghijklmnop") + masked = masking.mask_text(text) + assert "sk-ant" not in masked + assert "ghp_" not in masked + assert "AKIA" not in masked + assert "abc.def" not in masked + jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnop" + assert masking.mask_text(jwt) == MASK + assert masking.mask_text("plain text") == "plain text" + + +def test_mask_headers(): + masked = masking.mask_headers({"Authorization": "Bearer x", "Mcp-Method": "tools/call"}) + assert masked == {"Authorization": MASK, "Mcp-Method": "tools/call"} + + +def test_mask_argv(): + argv = ["server", "--api-key", "secret", "--token=abc", "--port", "80", + "sk-abcdefghijklmnopqrstu"] + assert masking.mask_argv(argv) == [ + "server", "--api-key", MASK, "--token=" + MASK, "--port", "80", MASK] + + +def test_token_estimates(): + assert tokens.estimate_text("") == 0 + assert tokens.estimate_text("abc") == 1 + assert tokens.estimate_text("a" * 400) == 100 + assert tokens.estimate_json(None) == 0 + assert tokens.estimate_json({"a": 1}) > 0 + blocks = [ + {"type": "text", "text": "a" * 40}, + {"type": "image", "data": "x" * 100000}, + {"type": "resource", "resource": {"text": "b" * 8}}, + {"type": "resource", "resource": {"blob": "zz"}}, + {"type": "resource_link", "uri": "file:///x"}, + "odd", + ] + total = tokens.estimate_content(blocks) + assert total == 10 + tokens.IMAGE_TOKENS + 2 + tokens.IMAGE_TOKENS + \ + tokens.estimate_json(blocks[4]) + tokens.estimate_json("odd") + assert tokens.estimate_content({"x": 1}) == tokens.estimate_json({"x": 1}) diff --git a/tests/unit/test_mcp_protocol.py b/tests/unit/test_mcp_protocol.py new file mode 100644 index 0000000..187823b --- /dev/null +++ b/tests/unit/test_mcp_protocol.py @@ -0,0 +1,84 @@ +from mcphawk.protocol import mcp as proto +from tests.traffic import MODERN, modern_meta, server_meta + + +def req(method, params=None, id=1): + msg = {"jsonrpc": "2.0", "id": id, "method": method} + if params is not None: + msg["params"] = params + return msg + + +def res(result, id=1): + return {"jsonrpc": "2.0", "id": id, "result": result} + + +def test_era_of_version(): + assert proto.era_of_version("2024-11-05") == proto.LEGACY + assert proto.era_of_version("2025-11-25") == proto.LEGACY + assert proto.era_of_version(MODERN) == proto.MODERN + assert proto.era_of_version("2027-01-01") == proto.MODERN + assert proto.era_of_version(None) is None + + +def test_target_of(): + assert proto.target_of(req("tools/call", {"name": "t"})) == "t" + assert proto.target_of(req("resources/read", {"uri": "file:///a"})) == "file:///a" + assert proto.target_of(req("prompts/get", {"name": "p"})) == "p" + assert proto.target_of(req("tools/list")) is None + assert proto.target_of(req("tools/call", {})) is None + + +def test_legacy_identity_and_version(): + init = req("initialize", {"protocolVersion": "2025-06-18", + "clientInfo": {"name": "cursor", "version": 1}}) + assert proto.client_info(init) == {"name": "cursor", "version": "1"} + assert proto.protocol_version(init) == "2025-06-18" + response = res({"protocolVersion": "2025-03-26", "serverInfo": {"name": "srv"}}) + assert proto.server_info(response, "initialize") == {"name": "srv", "version": ""} + assert proto.negotiated_version(response) == "2025-03-26" + assert proto.client_info(req("tools/list", {"clientInfo": {"name": "x"}})) is None + + +def test_modern_identity_and_version(): + call = req("tools/call", {"name": "t", "_meta": modern_meta("claude-code")}) + assert proto.client_info(call) == {"name": "claude-code", "version": "2.1.0"} + assert proto.protocol_version(call) == MODERN + assert proto.server_info(res({"_meta": server_meta("wx")})) == { + "name": "wx", "version": "1.2.0"} + discover = res({"serverInfo": {"name": "d", "version": "2"}}) + assert proto.server_info(discover, "server/discover")["name"] == "d" + assert proto.server_info(res({"content": []}), "tools/call") is None + assert proto.server_info(res({"serverInfo": {"version": "1"}})) is None + + +def test_result_type_and_mrtr_fields(): + assert proto.result_type(res({})) == proto.RESULT_COMPLETE + interim = res({"resultType": "input_required", "requestState": "s", + "inputRequests": {"a": {"method": "elicitation/create"}}}) + assert proto.result_type(interim) == proto.RESULT_INPUT_REQUIRED + assert proto.request_state(interim) == "s" + assert proto.input_requests(interim) == {"a": {"method": "elicitation/create"}} + assert proto.request_state(req("tools/call", {"requestState": "s"})) == "s" + assert proto.request_state(req("tools/call", {})) is None + + +def test_errors_and_tool_errors(): + error = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "nope"}} + assert proto.error_of(error) == (-32601, "nope") + assert proto.error_of({"error": {"code": "x"}}) == (None, None) + failed = res({"isError": True, "content": [{"type": "image"}, {"type": "text", "text": "boom"}]}) + assert proto.is_tool_error(failed) + assert proto.tool_error_text(failed) == "boom" + assert proto.tool_error_text(res({"isError": True})) is None + + +def test_list_helpers(): + listing = res({"tools": [{"name": "a"}, "junk"], "instructions": "hi"}) + assert proto.tools_from_list(listing) == [{"name": "a"}] + assert proto.tools_from_list(res({"tools": "nope"})) == [] + assert proto.instructions_of(listing) == "hi" + assert proto.instructions_of(res({})) is None + traced = req("tools/call", {"_meta": {"traceparent": "00-abc-def-01"}}) + assert proto.trace_parent(traced) == "00-abc-def-01" + assert proto.trace_parent(req("tools/list")) is None diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py new file mode 100644 index 0000000..f6e06ed --- /dev/null +++ b/tests/unit/test_paths.py @@ -0,0 +1,18 @@ +from mcphawk import paths + + +def test_defaults_under_home(monkeypatch, tmp_path): + monkeypatch.delenv("MCPHAWK_HOME", raising=False) + monkeypatch.delenv("MCPHAWK_DB", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.data_dir() == tmp_path / ".mcphawk" + assert paths.db_path() == tmp_path / ".mcphawk" / "mcphawk.db" + assert (tmp_path / ".mcphawk").is_dir() + + +def test_env_overrides(monkeypatch, tmp_path): + monkeypatch.setenv("MCPHAWK_HOME", str(tmp_path / "h")) + monkeypatch.setenv("MCPHAWK_DB", str(tmp_path / "d" / "x.db")) + assert paths.data_dir() == tmp_path / "h" + assert paths.db_path() == tmp_path / "d" / "x.db" + assert (tmp_path / "d").is_dir() diff --git a/tests/unit/test_sse_and_http1.py b/tests/unit/test_sse_and_http1.py new file mode 100644 index 0000000..6ca51f6 --- /dev/null +++ b/tests/unit/test_sse_and_http1.py @@ -0,0 +1,100 @@ +from mcphawk.capture.http1 import Http1Stream, looks_like_request, looks_like_response +from mcphawk.capture.sse import SSEParser + + +def test_sse_basic_and_multiline(): + parser = SSEParser() + events = parser.feed(b"event: endpoint\ndata: /messages?x=1\n\ndata: a\ndata: b\nid: 7\n\n") + assert [(e.event, e.data, e.id) for e in events] == [ + ("endpoint", "/messages?x=1", None), ("message", "a\nb", "7")] + + +def test_sse_split_chunks_crlf_cr_and_comments(): + parser = SSEParser() + assert parser.feed(b": keepalive\r\ndata: {\"a\"") == [] + assert parser.feed(b": 1}\r") == [] + events = parser.feed(b"\n\r\ndata:x\r\r\n\n") + assert [e.data for e in events] == ['{"a": 1}', "x"] + assert parser.feed("event: only\n\n") == [] # no data -> no event + + +def test_sse_split_utf8_sequence(): + parser = SSEParser() + payload = "data: héllo\n\n".encode() + cut = payload.index(b"\xc3") + 1 + assert parser.feed(payload[:cut]) == [] + assert parser.feed(payload[cut:])[0].data == "héllo" + + +def test_sse_field_without_space(): + events = SSEParser().feed("data:no-space\nunknown: x\n\n") + assert events[0].data == "no-space" + + +class Collector: + def __init__(self, is_response): + self.messages = [] + self.stream = Http1Stream(is_response, self.head, self.data, self.end) + self.current = None + + def head(self, start, headers): + self.current = {"start": start, "headers": headers, "body": b""} + + def data(self, chunk): + self.current["body"] += chunk + + def end(self): + self.messages.append(self.current) + + +def test_http1_content_length_split_across_feeds(): + c = Collector(False) + raw = b"POST /mcp HTTP/1.1\r\nContent-Length: 11\r\nMcp-Method: x\r\n\r\nhello world" + for i in range(0, len(raw), 7): + c.stream.feed(raw[i:i + 7]) + assert c.messages[0]["body"] == b"hello world" + assert c.messages[0]["headers"]["Mcp-Method"] == "x" + + +def test_http1_chunked_with_extensions_and_trailers(): + c = Collector(True) + c.stream.feed(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + b"5;ext=1\r\nhello\r\n6\r\n world\r\n0\r\nX-T: 1\r\n\r\n" + b"HTTP/1.1 204 No Content\r\n\r\n") + assert [m["body"] for m in c.messages] == [b"hello world", b""] + + +def test_http1_until_close_and_no_body_request(): + c = Collector(True) + c.stream.feed(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\ndata: 1\n\n") + c.stream.feed(b"data: 2\n\n") + assert c.messages == [] + c.stream.close() + assert c.messages[0]["body"] == b"data: 1\n\ndata: 2\n\n" + r = Collector(False) + r.stream.feed(b"GET /sse HTTP/1.1\r\nAccept: text/event-stream\r\n\r\n") + assert r.messages[0]["body"] == b"" + c.stream.close() # closing when idle is a no-op + assert len(c.messages) == 1 + + +def test_http1_zero_length_and_broken_inputs(): + c = Collector(True) + c.stream.feed(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") + assert c.messages[0]["body"] == b"" + bad = Collector(True) + bad.stream.feed(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n") + assert bad.stream.broken + bad.stream.feed(b"more") + worse = Collector(True) + worse.stream.feed(b"HTTP/1.1 200 OK\r\nContent-Length: nope\r\n\r\n") + assert worse.stream.broken + huge = Collector(True) + huge.stream.feed(b"X" * (65 * 1024)) + assert huge.stream.broken + + +def test_request_response_sniffing_helpers(): + assert looks_like_request(b"POST /mcp HTTP/1.1") + assert not looks_like_request(b"HTTP/1.1 200") + assert looks_like_response(b"HTTP/1.1 200 OK") diff --git a/tests/unit/test_stdio_server_detector_fallback.py b/tests/unit/test_stdio_server_detector_fallback.py deleted file mode 100644 index 80652f3..0000000 --- a/tests/unit/test_stdio_server_detector_fallback.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Unit tests for stdio server detection fallback.""" - - -from mcphawk.stdio_server_detector_fallback import ( - detect_server_from_command, - extract_server_name, - merge_server_info, -) - - -class TestExtractServerName: - """Test the extract_server_name function.""" - - def test_mcp_server_pattern(self): - """Test mcp-server-{name} and mcp_server_{name} patterns.""" - assert extract_server_name("mcp-server-filesystem") == "MCP Filesystem Server" - assert extract_server_name("mcp_server_filesystem") == "MCP Filesystem Server" - assert extract_server_name("mcp-server-git") == "MCP Git Server" - assert extract_server_name("mcp_server_github") == "MCP Github Server" - assert extract_server_name("MCP-SERVER-SQLITE") == "MCP Sqlite Server" - assert extract_server_name("mcp-server-brave-search") == "MCP Brave Search Server" - assert extract_server_name("mcp_server_multi_word_name") == "MCP Multi Word Name Server" - - def test_name_mcp_server_pattern(self): - """Test {name}-mcp-server and {name}_mcp_server patterns.""" - assert extract_server_name("filesystem-mcp-server") == "Filesystem MCP Server" - assert extract_server_name("my_custom_mcp_server") == "My Custom MCP Server" - assert extract_server_name("github-actions-mcp-server") == "Github Actions MCP Server" - - def test_mcp_name_pattern(self): - """Test mcp-{name} and mcp_{name} patterns.""" - assert extract_server_name("mcp-filesystem") == "MCP Filesystem" - assert extract_server_name("mcp_git") == "MCP Git" - assert extract_server_name("mcp-query") == "MCP Query" - # Should not match mcp-server (too generic) - assert extract_server_name("mcp-server") is None - - def test_name_mcp_pattern(self): - """Test {name}-mcp and {name}_mcp patterns.""" - assert extract_server_name("filesystem-mcp") == "Filesystem MCP" - assert extract_server_name("github_mcp") == "Github MCP" - assert extract_server_name("my-custom-mcp") == "My Custom MCP" - - def test_contains_mcp(self): - """Test names that contain 'mcp' somewhere.""" - assert extract_server_name("my-mcp-tool") == "My MCP Tool" - assert extract_server_name("tool_with_mcp_support") == "Tool With MCP Support" - assert extract_server_name("mcptools") == "Mcptools" - - def test_no_mcp(self): - """Test names without 'mcp' return None.""" - assert extract_server_name("regular-server") is None - assert extract_server_name("python-app") is None - assert extract_server_name("node") is None - - def test_edge_cases(self): - """Test edge cases.""" - assert extract_server_name("") is None - assert extract_server_name(None) is None - assert extract_server_name("mcp") == "MCP" - assert extract_server_name("MCP") == "MCP" - assert extract_server_name("_mcp_") == "MCP" - - -class TestDetectServerFromCommand: - """Test the detect_server_from_command function.""" - - def test_empty_command(self): - """Test with empty or None command.""" - assert detect_server_from_command([]) is None - assert detect_server_from_command(None) is None - - def test_direct_executable(self): - """Test detection from direct executable names.""" - assert detect_server_from_command(["mcp-server-filesystem"]) == { - "name": "MCP Filesystem Server", - "version": "unknown" - } - assert detect_server_from_command(["/usr/local/bin/mcp-server-git"]) == { - "name": "MCP Git Server", - "version": "unknown" - } - assert detect_server_from_command(["./mcp_server_github"]) == { - "name": "MCP Github Server", - "version": "unknown" - } - - def test_python_module(self): - """Test detection from python -m module patterns.""" - assert detect_server_from_command(["python", "-m", "mcp_server_filesystem"]) == { - "name": "MCP Filesystem Server", - "version": "unknown" - } - assert detect_server_from_command(["python3", "-m", "mcp-server-git"]) == { - "name": "MCP Git Server", - "version": "unknown" - } - assert detect_server_from_command(["python3.exe", "-m", "my_mcp_tool"]) == { - "name": "My MCP Tool", - "version": "unknown" - } - - def test_mcphawk_special_case(self): - """Test special case for mcphawk mcp command.""" - assert detect_server_from_command(["python", "-m", "mcphawk", "mcp"]) == { - "name": "MCPHawk Query Server", - "version": "unknown" - } - assert detect_server_from_command(["python3", "-m", "mcphawk", "mcp", "--transport", "stdio"]) == { - "name": "MCPHawk Query Server", - "version": "unknown" - } - # Without 'mcp' subcommand, should use regular detection - assert detect_server_from_command(["python", "-m", "mcphawk", "wrap"]) == { - "name": "Mcphawk", - "version": "unknown" - } - - def test_python_script(self): - """Test detection from .py script names.""" - assert detect_server_from_command(["python", "mcp-server-custom.py"]) == { - "name": "MCP Custom Server", - "version": "unknown" - } - assert detect_server_from_command(["python3", "/path/to/my_mcp_server.py", "--port", "8080"]) == { - "name": "My MCP Server", - "version": "unknown" - } - # Non-MCP scripts should return None - assert detect_server_from_command(["python", "regular_script.py"]) is None - - def test_complex_commands(self): - """Test more complex command patterns.""" - assert detect_server_from_command([ - "/usr/bin/python3", "-u", "-m", "mcp_server_filesystem", "--path", "/tmp" - ]) == { - "name": "MCP Filesystem Server", - "version": "unknown" - } - assert detect_server_from_command([ - "node", "/app/mcp-server-nodejs.js" - ]) is None # .js files not supported - - def test_no_mcp_commands(self): - """Test commands without MCP should return None.""" - assert detect_server_from_command(["ls", "-la"]) is None - assert detect_server_from_command(["python", "script.py"]) is None - assert detect_server_from_command(["node", "server.js"]) is None - - -class TestMergeServerInfo: - """Test the merge_server_info function.""" - - def test_protocol_takes_precedence(self): - """Test that protocol info takes precedence over detected.""" - detected = {"name": "Detected Server", "version": "unknown"} - from_protocol = {"name": "Protocol Server", "version": "1.0.0"} - - assert merge_server_info(detected, from_protocol) == from_protocol - - def test_fallback_to_detected(self): - """Test fallback to detected when no protocol info.""" - detected = {"name": "Detected Server", "version": "unknown"} - - assert merge_server_info(detected, None) == detected - - def test_both_none(self): - """Test when both are None.""" - assert merge_server_info(None, None) is None - - def test_only_protocol(self): - """Test when only protocol info exists.""" - from_protocol = {"name": "Protocol Server", "version": "1.0.0"} - - assert merge_server_info(None, from_protocol) == from_protocol diff --git a/tests/unit/test_tcp_reassembly.py b/tests/unit/test_tcp_reassembly.py deleted file mode 100644 index bcf11fd..0000000 --- a/tests/unit/test_tcp_reassembly.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Tests for TCP stream reassembly and SSE parsing.""" - -from scapy.all import IP, TCP, Ether, Raw - -from mcphawk.tcp_reassembly import HTTPStream, TCPStreamReassembler - - -class TestHTTPStream: - """Test HTTPStream parsing.""" - - def test_extract_sse_messages_with_lf_separator(self): - """Test SSE message extraction with LF separator (\n\n).""" - stream = HTTPStream() - stream.is_sse = True - stream.is_chunked = False - stream.response_headers = {"content-type": "text/event-stream"} - - # Add SSE data with LF separator - stream.buffer = b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n' - - messages = stream.extract_sse_messages() - assert len(messages) == 1 - assert messages[0] == '{"jsonrpc":"2.0","id":1,"result":{}}' - - def test_extract_sse_messages_with_crlf_separator(self): - """Test SSE message extraction with CRLF separator (\r\n\r\n).""" - stream = HTTPStream() - stream.is_sse = True - stream.is_chunked = False - stream.response_headers = {"content-type": "text/event-stream"} - - # Add SSE data with CRLF separator (common with HTTP) - stream.buffer = b'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\r\n\r\n' - - messages = stream.extract_sse_messages() - assert len(messages) == 1 - assert messages[0] == '{"jsonrpc":"2.0","id":1,"result":{}}' - - def test_extract_sse_messages_mixed_separators(self): - """Test SSE message extraction with mixed separators.""" - stream = HTTPStream() - stream.is_sse = True - stream.is_chunked = False - stream.response_headers = {"content-type": "text/event-stream"} - - # Multiple messages with different separators - stream.buffer = ( - b'event: message\ndata: {"id":1}\n\n' # LF separator - b'event: message\r\ndata: {"id":2}\r\n\r\n' # CRLF separator - ) - - messages = stream.extract_sse_messages() - assert len(messages) == 2 - assert messages[0] == '{"id":1}' - assert messages[1] == '{"id":2}' - - def test_extract_sse_messages_with_chunked_encoding(self): - """Test SSE message extraction from chunked transfer encoding.""" - stream = HTTPStream() - stream.is_sse = True - stream.is_chunked = True - stream.response_headers = { - "content-type": "text/event-stream", - "transfer-encoding": "chunked" - } - - # Chunked data: size in hex, CRLF, data, CRLF - sse_data = b'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\r\n\r\n' - chunk_size = hex(len(sse_data))[2:].encode() - stream.buffer = chunk_size + b'\r\n' + sse_data + b'\r\n0\r\n\r\n' - - messages = stream.extract_sse_messages() - assert len(messages) == 1 - assert messages[0] == '{"jsonrpc":"2.0","id":1,"result":{}}' - - def test_extract_sse_messages_incomplete_chunk(self): - """Test SSE message extraction with incomplete chunked data.""" - stream = HTTPStream() - stream.is_sse = True - stream.is_chunked = True - stream.response_headers = { - "content-type": "text/event-stream", - "transfer-encoding": "chunked" - } - - # Incomplete chunk - missing data - stream.buffer = b'10\r\nonly5bytes' - - messages = stream.extract_sse_messages() - assert len(messages) == 0 # Should wait for more data - - def test_parse_http_response_headers(self): - """Test HTTP response header parsing.""" - stream = HTTPStream() - - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"Transfer-Encoding: chunked\r\n" - b"\r\n" - ) - - stream.add_response_data(response) - - assert stream.response_headers is not None - assert stream.is_sse is True - assert stream.is_chunked is True - assert stream.response_headers["content-type"] == "text/event-stream" - assert stream.response_headers["transfer-encoding"] == "chunked" - - -class TestTCPStreamReassembler: - """Test TCP stream reassembly.""" - - def test_http_request_response_flow(self): - """Test complete HTTP request/response flow.""" - reassembler = TCPStreamReassembler() - - # HTTP request - req_pkt = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=12345, dport=8765) / Raw( - b"POST /mcp HTTP/1.1\r\n" - b"Content-Type: application/json\r\n" - b"\r\n" - b'{"jsonrpc":"2.0","method":"test","id":1}' - ) - - messages = reassembler.process_packet(req_pkt) - assert len(messages) == 0 # Requests are not returned as messages - - # HTTP response with SSE - resp_pkt = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=8765, dport=12345) / Raw( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"\r\n" - b'event: message\r\n' - b'data: {"jsonrpc":"2.0","id":1,"result":"ok"}\r\n' - b'\r\n' - ) - - messages = reassembler.process_packet(resp_pkt) - assert len(messages) == 1 - assert messages[0]["message"] == '{"jsonrpc":"2.0","id":1,"result":"ok"}' - assert messages[0]["type"] == "sse_response" - - def test_chunked_sse_response_reassembly(self): - """Test reassembly of chunked SSE response across multiple packets.""" - reassembler = TCPStreamReassembler() - - # First, send request to establish stream - req_pkt = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=12345, dport=8765) / Raw( - b"POST /mcp HTTP/1.1\r\n\r\n" - ) - reassembler.process_packet(req_pkt) - - # Response headers - resp_hdr = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=8765, dport=12345) / Raw( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"Transfer-Encoding: chunked\r\n" - b"\r\n" - ) - - messages = reassembler.process_packet(resp_hdr) - assert len(messages) == 0 # Just headers, no data yet - - # Chunked SSE data - sse_data = b'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":"test"}\r\n\r\n' - chunk_size = hex(len(sse_data))[2:].encode() - - data_pkt = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=8765, dport=12345) / Raw( - chunk_size + b'\r\n' + sse_data + b'\r\n' - ) - - messages = reassembler.process_packet(data_pkt) - assert len(messages) == 1 - assert messages[0]["message"] == '{"jsonrpc":"2.0","id":1,"result":"test"}' - - # End chunk - end_pkt = Ether() / IP(src="127.0.0.1", dst="127.0.0.1") / TCP(sport=8765, dport=12345) / Raw( - b"0\r\n\r\n" - ) - - messages = reassembler.process_packet(end_pkt) - assert len(messages) == 0 # End chunk doesn't produce messages - - def test_multiple_streams(self): - """Test handling multiple concurrent TCP streams.""" - reassembler = TCPStreamReassembler() - - # Stream 1: Client A -> Server - req1 = Ether() / IP(src="10.0.0.1", dst="10.0.0.2") / TCP(sport=1111, dport=8765) / Raw( - b"POST /api HTTP/1.1\r\n\r\n" - ) - reassembler.process_packet(req1) - - # Stream 2: Client B -> Server - req2 = Ether() / IP(src="10.0.0.3", dst="10.0.0.2") / TCP(sport=2222, dport=8765) / Raw( - b"POST /api HTTP/1.1\r\n\r\n" - ) - reassembler.process_packet(req2) - - # Response to Client A - resp1 = Ether() / IP(src="10.0.0.2", dst="10.0.0.1") / TCP(sport=8765, dport=1111) / Raw( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"\r\n" - b'data: {"client":"A"}\r\n\r\n' - ) - - messages = reassembler.process_packet(resp1) - assert len(messages) == 1 - assert messages[0]["message"] == '{"client":"A"}' - assert messages[0]["dst_port"] == 1111 - - # Response to Client B - resp2 = Ether() / IP(src="10.0.0.2", dst="10.0.0.3") / TCP(sport=8765, dport=2222) / Raw( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"\r\n" - b'data: {"client":"B"}\r\n\r\n' - ) - - messages = reassembler.process_packet(resp2) - assert len(messages) == 1 - assert messages[0]["message"] == '{"client":"B"}' - assert messages[0]["dst_port"] == 2222 diff --git a/tests/unit/test_transport_detector.py b/tests/unit/test_transport_detector.py deleted file mode 100644 index 8082fce..0000000 --- a/tests/unit/test_transport_detector.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Test transport detection logic.""" - - -from mcphawk.transport_detector import ( - MCPTransport, - TransportTracker, - detect_transport_from_http, - extract_endpoint_from_sse, -) - - -class TestTransportDetection: - """Test MCP transport detection.""" - - def test_streamable_http_dual_accept(self): - """Test Streamable HTTP detection with dual accept headers.""" - transport = detect_transport_from_http( - method="POST", - path="/mcp", - headers={ - "accept": "application/json, text/event-stream", - "content-type": "application/json" - } - ) - assert transport == MCPTransport.STREAMABLE_HTTP - - def test_post_without_accept_headers(self): - """Test that POST without Accept headers returns UNKNOWN. - - POST to /mcp without proper Accept headers cannot be definitively - identified as Streamable HTTP. It could be HTTP+SSE posting to - the endpoint URL. - """ - transport = detect_transport_from_http( - method="POST", - path="/mcp", - headers={"content-type": "application/json"} - ) - assert transport == MCPTransport.UNKNOWN - - def test_streamable_http_sse_response_to_post(self): - """Test Streamable HTTP detection when SSE response to POST.""" - transport = detect_transport_from_http( - method="POST", - path="/api", - headers={"content-type": "application/json"}, - is_sse_response=True - ) - assert transport == MCPTransport.STREAMABLE_HTTP - - def test_http_sse_get_with_accept(self): - """Test HTTP+SSE detection with GET and SSE accept header.""" - transport = detect_transport_from_http( - method="GET", - path="/sse", - headers={"accept": "text/event-stream"} - ) - assert transport == MCPTransport.HTTP_SSE - - def test_http_sse_with_endpoint_event(self): - """Test HTTP+SSE detection with endpoint event.""" - transport = detect_transport_from_http( - method="GET", - path="/sse", - headers={"accept": "text/event-stream"}, - response_contains_endpoint_event=True - ) - assert transport == MCPTransport.HTTP_SSE - - def test_http_sse_response_to_get(self): - """Test HTTP+SSE detection when SSE response to GET.""" - transport = detect_transport_from_http( - method="GET", - path="/api", - headers={}, - is_sse_response=True - ) - assert transport == MCPTransport.HTTP_SSE - - def test_unknown_regular_http(self): - """Test unknown transport for regular HTTP.""" - transport = detect_transport_from_http( - method="POST", - path="/api", - headers={"content-type": "application/json"} - ) - assert transport == MCPTransport.UNKNOWN - - def test_unknown_no_clear_indicators(self): - """Test unknown transport when no clear indicators.""" - transport = detect_transport_from_http( - method="GET", - path="/api", - headers={"accept": "application/json"} - ) - assert transport == MCPTransport.UNKNOWN - - -class TestEndpointExtraction: - """Test SSE endpoint event extraction.""" - - def test_extract_endpoint_url(self): - """Test extracting endpoint URL from SSE data.""" - sse_data = 'event: endpoint\ndata: {"url": "http://localhost:8080/messages"}\n\n' - url = extract_endpoint_from_sse(sse_data) - assert url == "http://localhost:8080/messages" - - def test_extract_endpoint_url_with_crlf(self): - """Test extracting endpoint URL with CRLF line endings.""" - sse_data = 'event: endpoint\r\ndata: {"url": "http://localhost:8080/messages"}\r\n\r\n' - url = extract_endpoint_from_sse(sse_data) - assert url == "http://localhost:8080/messages" - - def test_extract_endpoint_url_not_found(self): - """Test when no endpoint event is present.""" - sse_data = 'event: message\ndata: {"jsonrpc": "2.0", "id": 1}\n\n' - url = extract_endpoint_from_sse(sse_data) - assert url is None - - def test_extract_endpoint_invalid_json(self): - """Test when endpoint data is invalid JSON.""" - sse_data = 'event: endpoint\ndata: invalid json\n\n' - url = extract_endpoint_from_sse(sse_data) - assert url is None - - -class TestTransportTracker: - """Test transport tracking across connections.""" - - def test_update_and_get_transport(self): - """Test updating and retrieving transport for a connection.""" - tracker = TransportTracker() - - # Update transport - tracker.update_transport( - "127.0.0.1", 12345, "127.0.0.1", 8080, - MCPTransport.STREAMABLE_HTTP - ) - - # Should work in both directions - assert tracker.get_transport("127.0.0.1", 12345, "127.0.0.1", 8080) == MCPTransport.STREAMABLE_HTTP - assert tracker.get_transport("127.0.0.1", 8080, "127.0.0.1", 12345) == MCPTransport.STREAMABLE_HTTP - - def test_get_unknown_transport(self): - """Test getting transport for unknown connection.""" - tracker = TransportTracker() - assert tracker.get_transport("127.0.0.1", 12345, "127.0.0.1", 8080) == MCPTransport.UNKNOWN - - def test_store_endpoint_url(self): - """Test storing endpoint URL for HTTP+SSE.""" - tracker = TransportTracker() - - tracker.store_endpoint_url( - "127.0.0.1", 12345, "127.0.0.1", 8080, - "http://localhost:8080/messages" - ) - - # Verify it was stored - key = ("127.0.0.1", 12345, "127.0.0.1", 8080) - assert tracker.endpoint_urls[key] == "http://localhost:8080/messages" - - def test_dont_update_unknown_transport(self): - """Test that UNKNOWN transport doesn't overwrite existing.""" - tracker = TransportTracker() - - # Set a known transport - tracker.update_transport( - "127.0.0.1", 12345, "127.0.0.1", 8080, - MCPTransport.STREAMABLE_HTTP - ) - - # Try to update with UNKNOWN (should not change) - tracker.update_transport( - "127.0.0.1", 12345, "127.0.0.1", 8080, - MCPTransport.UNKNOWN - ) - - # Should still be STREAMABLE_HTTP - assert tracker.get_transport("127.0.0.1", 12345, "127.0.0.1", 8080) == MCPTransport.STREAMABLE_HTTP diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py deleted file mode 100644 index aa2006d..0000000 --- a/tests/unit/test_utils.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Tests for utility functions.""" - -import json - -from mcphawk.utils import ( - extract_client_info, - extract_server_info, - get_message_type, - get_method_name, - parse_message, -) - - -class TestParseMessage: - """Test parse_message function.""" - - def test_parse_valid_json_string(self): - """Test parsing valid JSON string.""" - message = '{"jsonrpc": "2.0", "method": "test"}' - result = parse_message(message) - assert result == {"jsonrpc": "2.0", "method": "test"} - - def test_parse_invalid_json_string(self): - """Test parsing invalid JSON string.""" - message = '{"invalid": json}' - result = parse_message(message) - assert result is None - - def test_parse_non_string(self): - """Test parsing non-string input.""" - message = {"already": "parsed"} - result = parse_message(message) - assert result == {"already": "parsed"} - - def test_parse_empty_string(self): - """Test parsing empty string.""" - result = parse_message("") - assert result is None - - -class TestGetMessageType: - """Test get_message_type function.""" - - def test_request_message(self): - """Test identifying request messages.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "tools/list", - "id": "123" - }) - assert get_message_type(message) == "request" - - def test_response_message(self): - """Test identifying response messages.""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": {"tools": []}, - "id": "123" - }) - assert get_message_type(message) == "response" - - def test_notification_message(self): - """Test identifying notification messages.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "progress/update" - }) - assert get_message_type(message) == "notification" - - def test_error_message(self): - """Test identifying error messages.""" - message = json.dumps({ - "jsonrpc": "2.0", - "error": { - "code": -32600, - "message": "Invalid Request" - }, - "id": "123" - }) - assert get_message_type(message) == "error" - - def test_unknown_message_no_jsonrpc(self): - """Test message without jsonrpc field.""" - message = json.dumps({ - "method": "test", - "id": "123" - }) - assert get_message_type(message) == "unknown" - - def test_unknown_message_wrong_version(self): - """Test message with wrong JSON-RPC version.""" - message = json.dumps({ - "jsonrpc": "1.0", - "method": "test", - "id": "123" - }) - assert get_message_type(message) == "unknown" - - def test_unknown_message_invalid_json(self): - """Test invalid JSON.""" - message = "not json" - assert get_message_type(message) == "unknown" - - def test_edge_case_null_id(self): - """Test request with null id (valid in JSON-RPC 2.0).""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "test", - "id": None - }) - assert get_message_type(message) == "request" - - def test_edge_case_error_without_id(self): - """Test error without id (should be unknown).""" - message = json.dumps({ - "jsonrpc": "2.0", - "error": {"code": -32600, "message": "Error"} - }) - assert get_message_type(message) == "unknown" - - def test_edge_case_result_without_id(self): - """Test result without id (should be unknown).""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": "test" - }) - assert get_message_type(message) == "unknown" - - -class TestGetMethodName: - """Test get_method_name function.""" - - def test_get_method_from_request(self): - """Test extracting method from request.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "tools/list", - "id": "123" - }) - assert get_method_name(message) == "tools/list" - - def test_get_method_from_notification(self): - """Test extracting method from notification.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "progress/update" - }) - assert get_method_name(message) == "progress/update" - - def test_get_method_from_response(self): - """Test extracting method from response (should be None).""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": {"tools": []}, - "id": "123" - }) - assert get_method_name(message) is None - - def test_get_method_from_error(self): - """Test extracting method from error (should be None).""" - message = json.dumps({ - "jsonrpc": "2.0", - "error": {"code": -32600, "message": "Error"}, - "id": "123" - }) - assert get_method_name(message) is None - - def test_get_method_from_invalid_json(self): - """Test extracting method from invalid JSON.""" - message = "not json" - assert get_method_name(message) is None - - -class TestExtractServerInfo: - """Test extract_server_info function.""" - - def test_extract_server_info_from_initialize_response(self): - """Test extracting serverInfo from initialize response.""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": { - "protocolVersion": "2024-11-05", - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - }, - "id": "123" - }) - result = extract_server_info(message) - assert result == {"name": "test-server", "version": "1.0.0"} - - def test_extract_server_info_missing_version(self): - """Test extracting serverInfo without version.""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": { - "serverInfo": { - "name": "test-server" - } - }, - "id": "123" - }) - result = extract_server_info(message) - assert result == {"name": "test-server", "version": "unknown"} - - def test_extract_server_info_from_non_initialize(self): - """Test extracting serverInfo from non-initialize response.""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": {"tools": []}, - "id": "123" - }) - result = extract_server_info(message) - assert result is None - - def test_extract_server_info_from_request(self): - """Test extracting serverInfo from request (should be None).""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "initialize", - "params": {}, - "id": "123" - }) - result = extract_server_info(message) - assert result is None - - def test_extract_server_info_invalid_json(self): - """Test extracting serverInfo from invalid JSON.""" - message = "not json" - result = extract_server_info(message) - assert result is None - - def test_extract_server_info_missing_name(self): - """Test extracting serverInfo without name field.""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": { - "serverInfo": { - "version": "1.0.0" - } - }, - "id": "123" - }) - result = extract_server_info(message) - assert result is None - - -class TestExtractClientInfo: - """Test extract_client_info function.""" - - def test_extract_client_info_from_initialize_request(self): - """Test extracting clientInfo from initialize request.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "clientInfo": { - "name": "test-client", - "version": "2.0.0" - } - }, - "id": "123" - }) - result = extract_client_info(message) - assert result == {"name": "test-client", "version": "2.0.0"} - - def test_extract_client_info_missing_version(self): - """Test extracting clientInfo without version.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "clientInfo": { - "name": "test-client" - } - }, - "id": "123" - }) - result = extract_client_info(message) - assert result == {"name": "test-client", "version": "unknown"} - - def test_extract_client_info_from_non_initialize(self): - """Test extracting clientInfo from non-initialize request.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": "123" - }) - result = extract_client_info(message) - assert result is None - - def test_extract_client_info_from_response(self): - """Test extracting clientInfo from response (should be None).""" - message = json.dumps({ - "jsonrpc": "2.0", - "result": {"tools": []}, - "id": "123" - }) - result = extract_client_info(message) - assert result is None - - def test_extract_client_info_invalid_json(self): - """Test extracting clientInfo from invalid JSON.""" - message = "not json" - result = extract_client_info(message) - assert result is None - - def test_extract_client_info_missing_name(self): - """Test extracting clientInfo without name field.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "clientInfo": { - "version": "2.0.0" - } - }, - "id": "123" - }) - result = extract_client_info(message) - assert result is None - - def test_extract_client_info_wrong_method(self): - """Test extracting clientInfo with wrong method.""" - message = json.dumps({ - "jsonrpc": "2.0", - "method": "tools/list", - "params": { - "clientInfo": { - "name": "test-client", - "version": "2.0.0" - } - }, - "id": "123" - }) - result = extract_client_info(message) - assert result is None From 0b0577601336a3d5284a40dd8f1056e4163337b1 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:30:51 +0200 Subject: [PATCH 02/13] feat: replay captured requests, guard mutating API calls Replay re-sends a (possibly edited) client request to the same server over stdio or Streamable HTTP, performing the legacy handshake when needed, and records it as its own session for comparison. Refuses requests or commands containing masked secrets. Mutating API calls now require an X-MCPHawk header and a localhost Host, blocking cross-site requests and DNS rebinding. --- mcphawk/replay.py | 202 +++++++++++++++++++++++ mcphawk/web/app.py | 47 +++++- tests/integration/capture/test_replay.py | 168 +++++++++++++++++++ tests/integration/e2e/test_real_sdk.py | 11 ++ tests/integration/web/test_app.py | 26 ++- 5 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 mcphawk/replay.py create mode 100644 tests/integration/capture/test_replay.py diff --git a/mcphawk/replay.py b/mcphawk/replay.py new file mode 100644 index 0000000..a4980fd --- /dev/null +++ b/mcphawk/replay.py @@ -0,0 +1,202 @@ +"""Re-send a captured request (optionally edited) to the same server. + +The replay runs in a fresh connection and is recorded as its own session +(capture ``replay``), so it can be compared with the original. Replays can +have side effects - it is a real tool call - so the UI asks first and the +MCP server does not expose this at all. +""" + +import asyncio +import copy +import json +import shlex +from typing import Any + +import httpx + +from mcphawk.capture.sse import SSEParser +from mcphawk.protocol import mcp as proto +from mcphawk.protocol.masking import MASK +from mcphawk.query import Query +from mcphawk.store import C2S, S2C, Recorder + +REPLAY_ID = "mcphawk-replay" +LEGACY_FALLBACK_VERSION = "2025-06-18" +TIMEOUT_S = 30.0 + + +class ReplayError(Exception): + pass + + +def _initialize_request(q: Query, session: dict[str, Any]) -> dict[str, Any]: + """The session's own initialize request, or a minimal one.""" + for message in q.messages_for(session["id"]): + body = message["body"] + if isinstance(body, dict) and body.get("method") == "initialize": + return {**body, "id": f"{REPLAY_ID}-init"} + return { + "jsonrpc": "2.0", "id": f"{REPLAY_ID}-init", "method": "initialize", + "params": { + "protocolVersion": session.get("protocol_version") or LEGACY_FALLBACK_VERSION, + "capabilities": {}, "clientInfo": {"name": "mcphawk-replay", "version": "1"}, + }, + } + + +def _prepare(q: Query, exchange_id: int, params: dict[str, Any] | None + ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + exchange = q.get_exchange(exchange_id) + if not exchange: + raise ReplayError(f"exchange {exchange_id} not found") + if exchange["initiator"] != "client": + raise ReplayError("only client requests can be replayed") + request = copy.deepcopy(exchange["request"]["body"]) + if params is not None: + request["params"] = params + request["id"] = REPLAY_ID + if MASK in json.dumps(request, ensure_ascii=False): + raise ReplayError("the request contains masked secrets; edit them before replaying") + return exchange, exchange["session"], request + + +async def replay(q: Query, recorder: Recorder, exchange_id: int, + params: dict[str, Any] | None = None, + timeout: float = TIMEOUT_S) -> dict[str, Any]: + exchange, session, request = _prepare(q, exchange_id, params) + legacy = session.get("era") != proto.MODERN + handshake = [_initialize_request(q, session)] if legacy else [] + if session["transport"] == "stdio" and session["capture"] in ("wrap", "replay"): + runner = _replay_stdio + elif session["transport"] == "streamable_http": + runner = _replay_http + else: + raise ReplayError(f"replay is not supported for {session['transport']} sessions") + + new_session = recorder.open_session( + capture="replay", transport=session["transport"], + name=f"{session['display_name']} (replay)", target=session["target"], + run_key=f"replay:{exchange_id}") + try: + response = await asyncio.wait_for( + runner(session, handshake, request, lambda d, m: recorder.record( + new_session, d, json.dumps(m) if isinstance(m, dict) else m)), + timeout) + except asyncio.TimeoutError: + raise ReplayError(f"no response within {timeout:.0f}s") from None + finally: + recorder.end_session(new_session) + replayed = q.get_session(new_session) + new_exchange = next((e for e in reversed(replayed["exchanges"]) + if e["method"] == exchange["method"]), None) + return { + "session_id": new_session, + "exchange_id": new_exchange["id"] if new_exchange else None, + "status": new_exchange["status"] if new_exchange else None, + "response": response, + } + + +async def _replay_stdio(session, handshake, request, record) -> dict[str, Any]: + command = shlex.split(session["target"] or "") + if not command: + raise ReplayError("session has no command to run") + if any(MASK in part for part in command): + raise ReplayError("the server command contains masked secrets; " + "wrap it with --no-mask to replay") + proc = await asyncio.create_subprocess_exec( + *command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL) + + async def send(message: dict[str, Any]) -> None: + record(C2S, message) + proc.stdin.write(json.dumps(message).encode() + b"\n") + await proc.stdin.drain() + + async def wait_for(rpc_id: str) -> dict[str, Any]: + while True: + line = await proc.stdout.readline() + if not line: + raise ReplayError("server exited before answering") + if not line.strip(): + continue + record(S2C, line.decode("utf-8", "replace")) + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(message, dict) and message.get("id") == rpc_id: + return message + + try: + for init in handshake: + await send(init) + await wait_for(init["id"]) + await send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + await send(request) + return await wait_for(request["id"]) + finally: + if proc.returncode is None: + proc.terminate() + await proc.wait() + + +def _http_headers(message: dict[str, Any], session_id: str | None) -> dict[str, str]: + headers = {"Accept": "application/json, text/event-stream", + "Content-Type": "application/json"} + version = proto.protocol_version(message) + if version and proto.era_of_version(version) == proto.MODERN: + headers["MCP-Protocol-Version"] = version + headers["Mcp-Method"] = message["method"] + target = proto.target_of(message) + if target is not None: + headers["Mcp-Name"] = target + if session_id: + headers["Mcp-Session-Id"] = session_id + return headers + + +async def _post(client: httpx.AsyncClient, url: str, message: dict[str, Any], + session_id: str | None, record) -> tuple[dict[str, Any] | None, str | None]: + record(C2S, message) + async with client.stream("POST", url, json=message, + headers=_http_headers(message, session_id)) as response: + new_session_id = response.headers.get("mcp-session-id") or session_id + if response.status_code >= 400 and response.status_code != 202: + body = await response.aread() + raise ReplayError(f"server answered HTTP {response.status_code}: " + f"{body[:200].decode('utf-8', 'replace')}") + if "text/event-stream" in response.headers.get("content-type", ""): + parser = SSEParser() + async for chunk in response.aiter_bytes(): + for event in parser.feed(chunk): + if not event.data.strip(): + continue + record(S2C, event.data) + try: + data = json.loads(event.data) + except json.JSONDecodeError: + continue + if isinstance(data, dict) and data.get("id") == message.get("id"): + return data, new_session_id + return None, new_session_id + content = await response.aread() + if not content: + return None, new_session_id + record(S2C, content.decode("utf-8", "replace")) + return json.loads(content), new_session_id + + +async def _replay_http(session, handshake, request, record, + transport: httpx.AsyncBaseTransport | None = None) -> dict[str, Any]: + url = session["target"] + async with httpx.AsyncClient(transport=transport, timeout=TIMEOUT_S) as client: + session_id = None + for init in handshake: + _, session_id = await _post(client, url, init, None, record) + await _post(client, url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, + session_id, record) + response, _ = await _post(client, url, request, session_id, record) + if response is None: + raise ReplayError("server did not answer the request") + return response diff --git a/mcphawk/web/app.py b/mcphawk/web/app.py index 4f23c19..4c37464 100644 --- a/mcphawk/web/app.py +++ b/mcphawk/web/app.py @@ -6,9 +6,16 @@ import logging from collections.abc import Callable from pathlib import Path -from typing import Any - -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from typing import Annotated, Any + +from fastapi import ( + Body, + FastAPI, + HTTPException, + Request, + WebSocket, + WebSocketDisconnect, +) from fastapi import Query as Param from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -19,6 +26,7 @@ from mcphawk.capture.http_proxy import Proxy from mcphawk.install import installer from mcphawk.query import Query +from mcphawk.replay import ReplayError, replay from mcphawk.store import Recorder logger = logging.getLogger(__name__) @@ -26,6 +34,13 @@ STATIC_DIR = Path(__file__).parent / "static" LIVE_POLL_S = 0.5 SEVERITIES = ("error", "warning", "info") +GUARD_HEADER = "x-mcphawk" +LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]"}) + + +def _is_local_host(host_header: str) -> bool: + host = host_header.rsplit(":", 1)[0] if not host_header.endswith("]") else host_header + return host in LOCAL_HOSTS def create_app( @@ -61,6 +76,20 @@ async def lifespan(app: FastAPI): app.state.recorder = recorder app.state.proxy = proxy + @app.middleware("http") + async def guard_mutations(request: Request, call_next): + """State-changing API calls must come from our own UI on localhost. + + The custom header forces a CORS preflight (which we never grant), and + the Host check stops DNS-rebinding pages from reaching the API. + """ + mutating = request.method not in ("GET", "HEAD") + if request.url.path.startswith("/api/") and mutating and ( + request.headers.get(GUARD_HEADER) != "1" + or not _is_local_host(request.headers.get("host", ""))): + return JSONResponse({"detail": "forbidden"}, status_code=403) + return await call_next(request) + def need(value: Any, what: str) -> Any: if value is None: raise HTTPException(404, f"{what} not found") @@ -103,7 +132,7 @@ def session_lint(session_id: str) -> list[dict[str, Any]]: @app.get("/api/exchanges") def exchanges(session_id: str | None = None, status: str | None = None, method: str | None = None, target: str | None = None, - q_: str | None = Param(None, alias="q"), + q_: Annotated[str | None, Param(alias="q")] = None, limit: int = 500) -> list[dict[str, Any]]: return q.exchanges(session_id=session_id, status=status, method=method, target=target, text=q_, limit=limit) @@ -148,6 +177,16 @@ def setup() -> dict[str, Any]: else "would proxy" if c.action == installer.PROXY else c.reason), } for c in changes]} + @app.post("/api/exchanges/{exchange_id}/replay") + async def replay_exchange( + exchange_id: int, + params: Annotated[dict[str, Any] | None, Body(embed=True)] = None, + ) -> dict[str, Any]: + try: + return await replay(q, recorder, exchange_id, params) + except ReplayError as exc: + raise HTTPException(409, str(exc)) from None + @app.delete("/api/data") def clear(confirm: bool = False) -> dict[str, Any]: if not confirm: diff --git a/tests/integration/capture/test_replay.py b/tests/integration/capture/test_replay.py new file mode 100644 index 0000000..684af7e --- /dev/null +++ b/tests/integration/capture/test_replay.py @@ -0,0 +1,168 @@ +import json +import shlex +import sys +from pathlib import Path + +import httpx +import pytest + +from mcphawk import replay as rp +from mcphawk.protocol.masking import MASK +from mcphawk.store import C2S, S2C +from tests.integration.capture.test_http_proxy import upstream_app +from tests.traffic import frame, modern_meta + +ECHO = str(Path(__file__).resolve().parents[2] / "fixtures" / "echo_server.py") + + +def stdio_session(recorder, command=None, init=True): + target = shlex.join(command or [sys.executable, ECHO]) + sid = recorder.open_session(capture="wrap", transport="stdio", name="echo", target=target) + if init: + recorder.record(sid, C2S, frame(id=0, method="initialize", params={ + "protocolVersion": "2025-06-18", "clientInfo": {"name": "c", "version": "1"}})) + recorder.record(sid, S2C, frame(id=0, result={"serverInfo": {"name": "echo"}})) + recorder.record(sid, C2S, frame(id=1, method="tools/call", params={ + "name": "echo", "arguments": {"text": "hi"}})) + recorder.record(sid, S2C, frame(id=1, result={"content": []})) + return sid + + +def call_exchange(query, sid): + return next(e for e in query.get_session(sid)["exchanges"] if e["method"] == "tools/call") + + +async def test_replay_stdio_with_edited_params(recorder, query): + sid = stdio_session(recorder) + original = call_exchange(query, sid) + result = await rp.replay(query, recorder, original["id"], {"name": "echo", "arguments": { + "text": "edited"}}) + assert result["status"] == "ok" + assert result["response"]["result"]["echo"]["arguments"] == {"text": "edited"} + replayed = query.get_session(result["session_id"]) + assert replayed["capture"] == "replay" + assert replayed["run_key"] == f"replay:{original['id']}" + assert replayed["display_name"] == "echo (replay)" + assert replayed["ended_at"] is not None + assert [e["method"] for e in replayed["exchanges"]] == ["initialize", "tools/call"] + + +async def test_replay_stdio_synthesizes_initialize(recorder, query): + sid = stdio_session(recorder, init=False) + result = await rp.replay(query, recorder, call_exchange(query, sid)["id"]) + assert result["status"] == "ok" + init = query.get_session(result["session_id"])["exchanges"][0] + request = query.get_message(init["request_msg_id"])["body"] + assert request["params"]["clientInfo"]["name"] == "mcphawk-replay" + + +async def test_replay_refusals(recorder, query): + sid = stdio_session(recorder) + server_initiated = recorder.open_session(capture="wrap", transport="stdio") + recorder.record(server_initiated, S2C, frame(id="s", method="roots/list")) + masked = recorder.open_session(capture="wrap", transport="stdio", target="x") + recorder.record(masked, C2S, frame(id=1, method="tools/call", params={"token": "abc"})) + sse = recorder.open_session(capture="sniff", transport="http_sse", target="http://x") + recorder.record(sse, C2S, frame(id=1, method="tools/list")) + empty = recorder.open_session(capture="wrap", transport="stdio", target="") + recorder.record(empty, C2S, frame(id=1, method="tools/list")) + secret_cmd = recorder.open_session(capture="wrap", transport="stdio", + target=f"server --key {MASK}") + recorder.record(secret_cmd, C2S, frame(id=1, method="tools/list")) + + def first(session_id): + return query.get_session(session_id)["exchanges"][0]["id"] + + cases = { + 9999: "not found", + first(server_initiated): "only client requests", + first(masked): "masked secrets; edit", + first(sse): "not supported for http_sse", + first(empty): "no command", + first(secret_cmd): "--no-mask", + } + for exchange_id, message in cases.items(): + with pytest.raises(rp.ReplayError, match=message): + await rp.replay(query, recorder, exchange_id) + assert call_exchange(query, sid) + + +async def test_replay_stdio_server_dies_or_times_out(recorder, query): + dead = stdio_session(recorder, command=[sys.executable, "-c", "print('bye')"]) + with pytest.raises(rp.ReplayError, match="exited before answering"): + await rp.replay(query, recorder, call_exchange(query, dead)["id"]) + silent = stdio_session(recorder, command=[sys.executable, "-c", + "import time; time.sleep(5)"]) + with pytest.raises(rp.ReplayError, match="no response within"): + await rp.replay(query, recorder, call_exchange(query, silent)["id"], timeout=0.3) + + +class Upstream(httpx.AsyncBaseTransport): + def __init__(self): + self.app = httpx.ASGITransport(app=upstream_app) + self.requests = [] + + async def handle_async_request(self, request): + self.requests.append(request) + return await self.app.handle_async_request(request) + + +async def test_replay_http_modern_and_legacy(recorder, monkeypatch): + transport = Upstream() + original = rp._replay_http + + async def with_transport(*args): + return await original(*args, transport=transport) + + monkeypatch.setattr(rp, "_replay_http", with_transport) + from mcphawk.query import Query + + q = Query() + modern = recorder.open_session(capture="proxy", transport="streamable_http", + target="http://upstream.test/mcp") + recorder.record(modern, C2S, frame(id=1, method="tools/call", params={ + "name": "t", "_meta": modern_meta()})) + recorder.record(modern, S2C, frame(id=1, result={"resultType": "complete"})) + exchange = q.get_session(modern)["exchanges"][0] + result = await rp.replay(q, recorder, exchange["id"]) + assert result["response"]["result"]["content"][0]["text"] == "ok" + sent = transport.requests[-1] + assert sent.headers["mcp-method"] == "tools/call" + assert sent.headers["mcp-name"] == "t" + + legacy = recorder.open_session(capture="sniff", transport="streamable_http", + target="http://upstream.test/mcp") + recorder.record(legacy, C2S, frame(id=1, method="tools/list")) + recorder.record(legacy, S2C, frame(id=1, result={"tools": []})) + transport.requests.clear() + result = await rp.replay(q, recorder, q.get_session(legacy)["exchanges"][0]["id"]) + assert [json.loads(r.content)["method"] for r in transport.requests] == [ + "initialize", "notifications/initialized", "tools/list"] + assert transport.requests[-1].headers["mcp-session-id"] == "sess-1" + assert result["response"]["result"]["tools"] == [] + q.close() + + +async def test_replay_http_error_paths(): + async def handler(request): + body = json.loads(request.content) + if body.get("method") == "fail": + return httpx.Response(500, text="boom") + if body.get("method") == "silent-sse": + return httpx.Response(200, headers={"content-type": "text/event-stream"}, + content=b"data: \n\ndata: nope\n\n") + return httpx.Response(202) + + transport = httpx.MockTransport(handler) + session = {"target": "http://x/mcp"} + record = lambda *a: None # noqa: E731 + with pytest.raises(rp.ReplayError, match="HTTP 500"): + await rp._replay_http(session, [], {"jsonrpc": "2.0", "id": "r", "method": "fail"}, + record, transport=transport) + with pytest.raises(rp.ReplayError, match="did not answer"): + await rp._replay_http(session, [], {"jsonrpc": "2.0", "id": "r", "method": "x"}, + record, transport=transport) + with pytest.raises(rp.ReplayError, match="did not answer"): + await rp._replay_http(session, [], {"jsonrpc": "2.0", "id": "r", + "method": "silent-sse"}, record, + transport=transport) diff --git a/tests/integration/e2e/test_real_sdk.py b/tests/integration/e2e/test_real_sdk.py index db76f00..26e0ee1 100644 --- a/tests/integration/e2e/test_real_sdk.py +++ b/tests/integration/e2e/test_real_sdk.py @@ -14,6 +14,8 @@ from mcphawk.analysis import lint from mcphawk.query import Query +from mcphawk.replay import replay +from mcphawk.store import Recorder from mcphawk.web.app import create_app FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sdk_server.py" @@ -75,6 +77,15 @@ async def test_stdio_through_wrap(db, mode, era): if era == "modern": errors = [f for f in lint.lint_session(q, session["id"]) if f["severity"] == "error"] assert errors == [], errors + + # replay the weather call against the real server, with an edited city + original = q.get_exchange(calls[0]["id"])["request"]["body"]["params"] + recorder = Recorder(db) + result = await replay(q, recorder, calls[0]["id"], + {**original, "arguments": {"city": "Lima"}}) + assert result["status"] == "ok" + assert result["response"]["result"]["content"][0]["text"] == "Sunny in Lima" + recorder.close() q.close() diff --git a/tests/integration/web/test_app.py b/tests/integration/web/test_app.py index 4e20774..c1a6968 100644 --- a/tests/integration/web/test_app.py +++ b/tests/integration/web/test_app.py @@ -14,10 +14,13 @@ def client(db, recorder): modern_session(recorder, clock, run_key="pid:1", client_app="claude", name="search") app = create_app(db, upstreams=lambda: {"demo": "http://127.0.0.1:1/mcp"}, with_mcp=False, static_dir=None) - with TestClient(app) as c: + with TestClient(app, base_url="http://127.0.0.1:8484") as c: yield c +GUARD = {"X-MCPHawk": "1"} + + def test_read_api(client): stats = client.get("/api/stats").json() assert stats["sessions"] == 2 @@ -64,8 +67,9 @@ def test_setup_and_clear(client, monkeypatch): monkeypatch.setattr(installer, "plan", lambda **kw: []) assert client.get("/api/setup").json() == {"servers": []} - assert client.delete("/api/data").status_code == 400 - assert client.delete("/api/data", params={"confirm": True}).json() == {"cleared": True} + assert client.delete("/api/data", headers=GUARD).status_code == 400 + cleared = client.delete("/api/data", params={"confirm": True}, headers=GUARD) + assert cleared.json() == {"cleared": True} assert client.get("/api/stats").json()["sessions"] == 0 @@ -121,3 +125,19 @@ def test_mcp_endpoint_is_mounted(db): "Mcp-Method": "tools/list", "MCP-Protocol-Version": "2026-07-28"}) assert response.status_code == 200 assert "find_problems" in response.text + + +def test_mutations_require_guard_header_and_local_host(client): + assert client.delete("/api/data", params={"confirm": True}).status_code == 403 + rebound = client.delete("/api/data", params={"confirm": True}, + headers={**GUARD, "Host": "evil.example:8484"}) + assert rebound.status_code == 403 + ipv6 = client.delete("/api/data", headers={**GUARD, "Host": "[::1]"}) + assert ipv6.status_code == 400 # passed the guard, failed on confirm + assert client.get("/api/stats").status_code == 200 # reads are not guarded + + +def test_replay_endpoint_errors(client): + missing = client.post("/api/exchanges/999/replay", json={}, headers=GUARD) + assert missing.status_code == 409 + assert "not found" in missing.json()["detail"] From 83dde111f40c38b7567858770709fb1fd1843524 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:42:36 +0200 Subject: [PATCH 03/13] feat: new web UI - run waterfall, call inspector, cost, problems, compare, setup Rebuilt the Vue app around the v1 API: a DevTools-style waterfall of every call in a run across servers, a detail drawer with rendered tool results, JSON tree, headers, multi round-trip chains and replay, plus context cost, problems, session comparison and a setup page that dry-runs install. Keeps the MCPHawk logo and takes the accent colour from it; fonts are bundled locally. Drops Tailwind, Pinia, axios and headlessui. Also: - pair responses recorded before their request (stdio pumps race) - recognise the v0.x 'mcphawk wrap ' syntax in install/uninstall - name version-titled client processes (Claude Code) by argv[0] - replace the old examples with a demo that drives three SDK servers --- examples/demo/run_demo.py | 51 + examples/demo/servers.py | 82 + examples/http_sse_example.py | 198 - examples/mcp_sdk_client.py | 61 - examples/stdio_client.py | 207 - examples/streamable_http_example.py | 115 - examples/test_mcp_http.sh | 177 - examples/test_mcp_sdk.sh | 77 - examples/test_mcp_simple.sh | 29 - frontend/index.html | 12 +- frontend/package-lock.json | 3836 +++-------------- frontend/package.json | 28 +- frontend/postcss.config.js | 6 - frontend/src/App-original.vue | 64 - frontend/src/App-sidebar.vue | 98 - frontend/src/App.vue | 161 +- frontend/src/api.js | 36 + frontend/src/assets/main.css | 64 - frontend/src/components/ContentBlocks.vue | 45 + frontend/src/components/CostReport.vue | 129 + frontend/src/components/ExchangeDetail.vue | 199 + frontend/src/components/JsonTree.vue | 67 + .../src/components/LogTable/LogFilters.vue | 132 - .../components/LogTable/LogFiltersModern.vue | 380 -- .../components/LogTable/LogFiltersSidebar.vue | 411 -- frontend/src/components/LogTable/LogRow.vue | 256 -- .../src/components/LogTable/LogSearchBar.vue | 107 - frontend/src/components/LogTable/LogTable.vue | 89 - .../components/LogTable/MessageTypeBadge.vue | 49 - .../MessageDetail/MessageDetailModal.vue | 281 -- frontend/src/components/MessageLog.vue | 48 + frontend/src/components/ProblemList.vue | 75 + frontend/src/components/RunRail.vue | 69 + frontend/src/components/Stats/StatsPanel.vue | 36 - frontend/src/components/StatusBadge.vue | 24 + frontend/src/components/Waterfall.vue | 142 + .../components/common/ConnectionStatus.vue | 71 - .../src/components/common/PairedMessages.vue | 125 - .../src/components/common/ThemeToggle.vue | 62 - frontend/src/format.js | 42 + frontend/src/live.js | 24 + frontend/src/main.js | 31 +- frontend/src/stores/logs.js | 214 - frontend/src/stores/websocket.js | 98 - frontend/src/style.css | 101 + frontend/src/utils/messageParser.js | 112 - frontend/src/utils/transportFormatter.js | 30 - frontend/src/views/CompareView.vue | 131 + frontend/src/views/CostView.vue | 21 + frontend/src/views/ExchangeRedirect.vue | 25 + frontend/src/views/HomeView.vue | 23 + frontend/src/views/ProblemsView.vue | 17 + frontend/src/views/SetupView.vue | 78 + frontend/src/views/TrafficView.vue | 215 + frontend/tailwind.config.js | 22 - frontend/vite.config.js | 32 +- mcphawk/capture/process.py | 19 +- mcphawk/install/installer.py | 12 +- mcphawk/store/recorder.py | 19 + mcphawk/web/app.py | 2 +- ...ex-mono-cyrillic-400-normal-BSMlKf0J.woff2 | Bin 0 -> 8356 bytes ...lex-mono-cyrillic-400-normal-CEL4l2ZJ.woff | Bin 0 -> 7204 bytes ...lex-mono-cyrillic-500-normal-Ael50iVv.woff | Bin 0 -> 7208 bytes ...ex-mono-cyrillic-500-normal-Bq9vWWag.woff2 | Bin 0 -> 8460 bytes ...mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff | Bin 0 -> 5820 bytes ...ono-cyrillic-ext-400-normal-xuaO2J-f.woff2 | Bin 0 -> 6912 bytes ...mono-cyrillic-ext-500-normal-BIfNGwUT.woff | Bin 0 -> 5772 bytes ...ono-cyrillic-ext-500-normal-BqneJy0T.woff2 | Bin 0 -> 6972 bytes ...m-plex-mono-latin-400-normal-CvHOgSBP.woff | Bin 0 -> 13144 bytes ...-plex-mono-latin-400-normal-DMJ8VG8y.woff2 | Bin 0 -> 14708 bytes ...m-plex-mono-latin-500-normal-CB9ihrfo.woff | Bin 0 -> 13156 bytes ...-plex-mono-latin-500-normal-DSY6xOcd.woff2 | Bin 0 -> 14888 bytes ...x-mono-latin-ext-400-normal-BmRBH3aV.woff2 | Bin 0 -> 13348 bytes ...ex-mono-latin-ext-400-normal-D3D2R8hC.woff | Bin 0 -> 11768 bytes ...x-mono-latin-ext-500-normal-CAhNIIs5.woff2 | Bin 0 -> 13432 bytes ...ex-mono-latin-ext-500-normal-CZ70TYgx.woff | Bin 0 -> 11768 bytes ...-mono-vietnamese-400-normal-BulugwFq.woff2 | Bin 0 -> 5868 bytes ...x-mono-vietnamese-400-normal-DDuiU_S-.woff | Bin 0 -> 5620 bytes ...x-mono-vietnamese-500-normal-C8zxqsMH.woff | Bin 0 -> 5616 bytes ...-mono-vietnamese-500-normal-DZ4AoWbu.woff2 | Bin 0 -> 6040 bytes ...lex-sans-cyrillic-400-normal-BTotfTJu.woff | Bin 0 -> 13512 bytes ...ex-sans-cyrillic-400-normal-DZqxrq2p.woff2 | Bin 0 -> 14800 bytes ...lex-sans-cyrillic-500-normal-ByOcLdNv.woff | Bin 0 -> 13856 bytes ...ex-sans-cyrillic-500-normal-CocWQlwt.woff2 | Bin 0 -> 15000 bytes ...ex-sans-cyrillic-600-normal-71GNu3SW.woff2 | Bin 0 -> 15040 bytes ...lex-sans-cyrillic-600-normal-BGq0mW3O.woff | Bin 0 -> 13888 bytes ...sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff | Bin 0 -> 10288 bytes ...ans-cyrillic-ext-400-normal-g30qAdWV.woff2 | Bin 0 -> 12148 bytes ...ans-cyrillic-ext-500-normal-Cs5J6C77.woff2 | Bin 0 -> 12308 bytes ...sans-cyrillic-ext-500-normal-DB5PtV2g.woff | Bin 0 -> 10592 bytes ...sans-cyrillic-ext-600-normal-Bz0x94Yp.woff | Bin 0 -> 10616 bytes ...ans-cyrillic-ext-600-normal-DUMzJB7m.woff2 | Bin 0 -> 12304 bytes ...m-plex-sans-greek-400-normal-D9ESIMu3.woff | Bin 0 -> 8760 bytes ...-plex-sans-greek-400-normal-_efipK4i.woff2 | Bin 0 -> 9940 bytes ...m-plex-sans-greek-500-normal-CuWXN6rf.woff | Bin 0 -> 9148 bytes ...-plex-sans-greek-500-normal-JMMifIXV.woff2 | Bin 0 -> 10376 bytes ...m-plex-sans-greek-600-normal-D-CqTdkO.woff | Bin 0 -> 9220 bytes ...-plex-sans-greek-600-normal-DzTrcv_p.woff2 | Bin 0 -> 10368 bytes ...-plex-sans-latin-400-normal-CDDApCn2.woff2 | Bin 0 -> 22588 bytes ...m-plex-sans-latin-400-normal-CYLoc0-x.woff | Bin 0 -> 22104 bytes ...-plex-sans-latin-500-normal-6ng42L7E.woff2 | Bin 0 -> 24184 bytes ...m-plex-sans-latin-500-normal-BgVn5rGT.woff | Bin 0 -> 23916 bytes ...m-plex-sans-latin-600-normal-Cu4Hd6ag.woff | Bin 0 -> 23876 bytes ...-plex-sans-latin-600-normal-CuJfVYMP.woff2 | Bin 0 -> 24252 bytes ...x-sans-latin-ext-400-normal-C5H60-Va.woff2 | Bin 0 -> 15980 bytes ...ex-sans-latin-ext-400-normal-RBey6euL.woff | Bin 0 -> 14360 bytes ...ex-sans-latin-ext-500-normal-D0aIdm-b.woff | Bin 0 -> 14736 bytes ...x-sans-latin-ext-500-normal-DakdToA3.woff2 | Bin 0 -> 16456 bytes ...ex-sans-latin-ext-600-normal-DIrixKbi.woff | Bin 0 -> 14772 bytes ...x-sans-latin-ext-600-normal-DOrvGEcy.woff2 | Bin 0 -> 16448 bytes ...-sans-vietnamese-400-normal-DG4YqDda.woff2 | Bin 0 -> 8204 bytes ...x-sans-vietnamese-400-normal-fK1oJ5dG.woff | Bin 0 -> 6392 bytes ...x-sans-vietnamese-500-normal-BEb3_waV.woff | Bin 0 -> 6588 bytes ...-sans-vietnamese-500-normal-e4dixQRQ.woff2 | Bin 0 -> 8416 bytes ...x-sans-vietnamese-600-normal-DgdngZtN.woff | Bin 0 -> 6584 bytes ...-sans-vietnamese-600-normal-DpPYBSTl.woff2 | Bin 0 -> 8460 bytes mcphawk/web/static/assets/index-DC4K23Wn.js | 6 - mcphawk/web/static/assets/index-DUfspmuq.css | 1 + mcphawk/web/static/assets/index-Do4cWYV7.css | 1 - mcphawk/web/static/assets/index-enwNUTrO.js | 26 + mcphawk/web/static/assets/ui-CZuBMD6M.js | 4 - mcphawk/web/static/assets/vendor-CmqOmjtB.js | 21 - mcphawk/web/static/index.html | 18 +- tests/integration/capture/test_stdio_shim.py | 27 + tests/integration/db/test_recorder.py | 22 + tests/integration/install/test_installer.py | 5 + 126 files changed, 2586 insertions(+), 6912 deletions(-) create mode 100644 examples/demo/run_demo.py create mode 100644 examples/demo/servers.py delete mode 100644 examples/http_sse_example.py delete mode 100755 examples/mcp_sdk_client.py delete mode 100644 examples/stdio_client.py delete mode 100644 examples/streamable_http_example.py delete mode 100755 examples/test_mcp_http.sh delete mode 100755 examples/test_mcp_sdk.sh delete mode 100755 examples/test_mcp_simple.sh delete mode 100644 frontend/postcss.config.js delete mode 100644 frontend/src/App-original.vue delete mode 100644 frontend/src/App-sidebar.vue create mode 100644 frontend/src/api.js delete mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/ContentBlocks.vue create mode 100644 frontend/src/components/CostReport.vue create mode 100644 frontend/src/components/ExchangeDetail.vue create mode 100644 frontend/src/components/JsonTree.vue delete mode 100644 frontend/src/components/LogTable/LogFilters.vue delete mode 100644 frontend/src/components/LogTable/LogFiltersModern.vue delete mode 100644 frontend/src/components/LogTable/LogFiltersSidebar.vue delete mode 100644 frontend/src/components/LogTable/LogRow.vue delete mode 100644 frontend/src/components/LogTable/LogSearchBar.vue delete mode 100644 frontend/src/components/LogTable/LogTable.vue delete mode 100644 frontend/src/components/LogTable/MessageTypeBadge.vue delete mode 100644 frontend/src/components/MessageDetail/MessageDetailModal.vue create mode 100644 frontend/src/components/MessageLog.vue create mode 100644 frontend/src/components/ProblemList.vue create mode 100644 frontend/src/components/RunRail.vue delete mode 100644 frontend/src/components/Stats/StatsPanel.vue create mode 100644 frontend/src/components/StatusBadge.vue create mode 100644 frontend/src/components/Waterfall.vue delete mode 100644 frontend/src/components/common/ConnectionStatus.vue delete mode 100644 frontend/src/components/common/PairedMessages.vue delete mode 100644 frontend/src/components/common/ThemeToggle.vue create mode 100644 frontend/src/format.js create mode 100644 frontend/src/live.js delete mode 100644 frontend/src/stores/logs.js delete mode 100644 frontend/src/stores/websocket.js create mode 100644 frontend/src/style.css delete mode 100644 frontend/src/utils/messageParser.js delete mode 100644 frontend/src/utils/transportFormatter.js create mode 100644 frontend/src/views/CompareView.vue create mode 100644 frontend/src/views/CostView.vue create mode 100644 frontend/src/views/ExchangeRedirect.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/ProblemsView.vue create mode 100644 frontend/src/views/SetupView.vue create mode 100644 frontend/src/views/TrafficView.vue delete mode 100644 frontend/tailwind.config.js create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff create mode 100644 mcphawk/web/static/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 delete mode 100644 mcphawk/web/static/assets/index-DC4K23Wn.js create mode 100644 mcphawk/web/static/assets/index-DUfspmuq.css delete mode 100644 mcphawk/web/static/assets/index-Do4cWYV7.css create mode 100644 mcphawk/web/static/assets/index-enwNUTrO.js delete mode 100644 mcphawk/web/static/assets/ui-CZuBMD6M.js delete mode 100644 mcphawk/web/static/assets/vendor-CmqOmjtB.js diff --git a/examples/demo/run_demo.py b/examples/demo/run_demo.py new file mode 100644 index 0000000..acfae39 --- /dev/null +++ b/examples/demo/run_demo.py @@ -0,0 +1,51 @@ +"""Generate realistic MCP traffic through `mcphawk wrap`, then open the UI. + + python examples/demo/run_demo.py + mcphawk up --open + +Runs three servers (one modern 2026-07-28, two with the legacy handshake) from +a single client process, so they show up as one run with a shared timeline. +""" + +import asyncio +import os +import sys +from pathlib import Path + +from mcp.client.client import Client +from mcp.client.stdio import StdioServerParameters + +SERVERS = Path(__file__).with_name("servers.py") + + +def wrapped(name: str) -> StdioServerParameters: + return StdioServerParameters( + command=sys.executable, + args=["-m", "mcphawk", "wrap", "--name", name, "--", sys.executable, str(SERVERS), name], + env=dict(os.environ), # the SDK passes a filtered env by default; keep MCPHAWK_DB + ) + + +async def agent_turns() -> None: + async with ( + Client(wrapped("weather")) as weather, + Client(wrapped("docs"), mode="legacy") as docs, + Client(wrapped("flaky"), mode="legacy") as tickets, + ): + for client in (weather, docs, tickets): + await client.list_tools() + await docs.call_tool("search_docs", {"query": "deploy rollback", "limit": 5}) + await weather.call_tool("get_weather", {"city": "Berlin"}) + await weather.call_tool("forecast", {"city": "Berlin", "days": 5}) + await weather.call_tool("get_weather", {"city": "Atlantis"}) + await docs.call_tool("read_page", {"page_id": "runbook-42"}) + for _ in range(3): # an agent stuck in a loop + await docs.call_tool("search_docs", {"query": "rollback", "limit": 3}) + await tickets.call_tool("create_ticket", {"title": "Rollback failed", "priority": "urgent"}) + await tickets.call_tool("create_ticket", {"title": "Rollback failed"}) + await tickets.call_tool("slow_report", {}) + + +if __name__ == "__main__": + asyncio.run(agent_turns()) + print("Done. Run `mcphawk up --open` to explore the traffic.") diff --git a/examples/demo/servers.py b/examples/demo/servers.py new file mode 100644 index 0000000..4f4f2a0 --- /dev/null +++ b/examples/demo/servers.py @@ -0,0 +1,82 @@ +"""Three small MCP servers for the MCPHawk demo: python servers.py .""" + +import asyncio +import random +import sys + +from mcp.server.mcpserver import MCPServer + +weather = MCPServer("weather", version="1.4.0", instructions="Current weather and forecasts.") + + +@weather.tool() +def get_weather(city: str) -> str: + """Current weather for a city.""" + if city.lower() == "atlantis": + raise ValueError("unknown city") + return f"{city}: {random.choice(['sunny', 'light rain', 'overcast'])}, {random.randint(8, 27)}°C" + + +@weather.tool() +def forecast(city: str, days: int = 3) -> str: + """Daily forecast for the next few days.""" + return "\n".join(f"Day {d + 1}: {random.randint(8, 27)}°C" for d in range(days)) + + +docs = MCPServer("docs-search", version="0.9.2", instructions=( + "Search the internal documentation. Always prefer search_docs over reading whole pages. " + "Results are ranked by relevance and include section anchors. " * 6)) + +_VERBOSE = ( + "Searches every document in the knowledge base, including archived pages, design notes, " + "meeting transcripts and API references. Supports boolean operators, phrase queries, " + "field filters (author:, team:, updated:), fuzzy matching and synonym expansion. " * 8) + + +@docs.tool(description=_VERBOSE) +def search_docs(query: str, limit: int = 10, include_archived: bool = False, + team: str | None = None, updated_after: str | None = None) -> str: + return "\n\n".join( + f"## Result {i + 1}: {query} in practice\n" + ("Lorem ipsum dolor sit amet. " * 60) + for i in range(limit)) + + +@docs.tool() +def read_page(page_id: str) -> str: + """Read one documentation page in full.""" + return f"# Page {page_id}\n" + ("A very long page body. " * 3000) + + +@docs.tool() +def list_spaces() -> list[str]: + """List documentation spaces.""" + return ["engineering", "product", "support"] + + +@docs.tool() +def export_pdf(page_id: str) -> str: + """Export a page as PDF (rarely useful for agents).""" + return "ok" + + +flaky = MCPServer("tickets", version="2.0.0") + + +@flaky.tool() +async def create_ticket(title: str, priority: str = "normal") -> str: + """Create a support ticket.""" + await asyncio.sleep(random.uniform(0.05, 0.4)) + if priority == "urgent": + raise RuntimeError("upstream ticketing API timed out") + return f"Created TCK-{random.randint(1000, 9999)}: {title}" + + +@flaky.tool() +async def slow_report() -> str: + """Generate a weekly report (slow).""" + await asyncio.sleep(1.5) + return "Report ready." + + +if __name__ == "__main__": + {"weather": weather, "docs": docs, "flaky": flaky}[sys.argv[1]].run("stdio") diff --git a/examples/http_sse_example.py b/examples/http_sse_example.py deleted file mode 100644 index c484c62..0000000 --- a/examples/http_sse_example.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -Proper HTTP+SSE MCP client traffic generator. - -This simulates the legacy HTTP+SSE transport pattern as documented: -1. GET request to /sse endpoint to establish SSE connection -2. Server sends "endpoint" event with the message endpoint URL -3. Client POSTs JSON-RPC messages to the message endpoint - -This example creates traffic that MCPHawk can properly detect as HTTP+SSE. -""" - -import json -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer - -import requests - - -class MockHTTPSSEServer(BaseHTTPRequestHandler): - """Mock server that implements HTTP+SSE pattern.""" - - def log_message(self, format, *args): - """Suppress default logging.""" - pass - - def do_GET(self): - """Handle GET request for SSE connection.""" - if self.path == '/sse': - print("[Mock Server] Received GET /sse - sending SSE response") - self.send_response(200) - self.send_header('Content-Type', 'text/event-stream') - self.send_header('Cache-Control', 'no-cache') - self.send_header('Connection', 'keep-alive') - self.end_headers() - - # Send the endpoint event as per HTTP+SSE spec - endpoint_event = 'event: endpoint\ndata: {"url": "/messages"}\n\n' - self.wfile.write(endpoint_event.encode()) - self.wfile.flush() - - # For this example, close after sending endpoint - # Real servers would keep the connection open for streaming - return - else: - self.send_error(404) - - def do_POST(self): - """Handle POST request to message endpoint.""" - if self.path == '/messages': - print("[Mock Server] Received POST /messages") - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - - # Parse the JSON-RPC request - try: - request = json.loads(post_data) - print(f"[Mock Server] Request: {request}") - - # Send a simple response - response = { - "jsonrpc": "2.0", - "result": {"initialized": True}, - "id": request.get("id") - } - - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write(json.dumps(response).encode()) - except Exception as e: - print(f"[Mock Server] Error: {e}") - self.send_error(400) - else: - self.send_error(404) - - -def run_mock_server(port=8766): - """Run the mock HTTP+SSE server in a thread.""" - server = HTTPServer(('localhost', port), MockHTTPSSEServer) - server_thread = threading.Thread(target=server.serve_forever) - server_thread.daemon = True - server_thread.start() - return server - - -def simulate_http_sse_client(server_port=8766): - """Simulate HTTP+SSE client traffic pattern.""" - - print("\nSimulating HTTP+SSE MCP Client (Legacy Pattern)") - print("=" * 50) - - server_url = f"http://localhost:{server_port}" - - # Step 1: Establish SSE connection with GET request - print("\n1. Establishing SSE connection...") - print(f" GET {server_url}/sse") - print(" Accept: text/event-stream") - - # Use requests library for better control - session = requests.Session() - - endpoint_url = None - - try: - # Make GET request with SSE accept header - headers = {'Accept': 'text/event-stream'} - response = session.get(f"{server_url}/sse", headers=headers, stream=True, timeout=2) - - print(f" Response: {response.status_code} {response.reason}") - print(f" Content-Type: {response.headers.get('Content-Type')}") - - # Read the endpoint event with timeout on iter_lines - try: - for line in response.iter_lines(decode_unicode=True, chunk_size=1): - if line: - print(f" SSE: {line}") - if line.startswith('data:'): - data = json.loads(line[5:].strip()) - endpoint_url = data.get('url') - break - except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError): - print(" SSE connection closed/timed out (expected)") - - response.close() # Close the streaming connection - - except Exception as e: - print(f" Error during GET: {e}") - - # Always try the POST request, even if GET had issues - if not endpoint_url: - endpoint_url = "/messages" # Default endpoint for HTTP+SSE - print(f"\n2. Using default endpoint URL: {endpoint_url}") - else: - print(f"\n2. Server sent endpoint URL: {endpoint_url}") - - # Step 2: Send JSON-RPC request to the endpoint - print("\n3. Sending JSON-RPC request to endpoint...") - print(f" POST {server_url}{endpoint_url}") - print(" Content-Type: application/json") - - try: - # Send initialize request - initialize_request = { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "http-sse-test", - "version": "1.0.0" - } - }, - "id": 1 - } - - # Important: No Accept header with dual types for HTTP+SSE POST - post_response = session.post( - f"{server_url}{endpoint_url}", - json=initialize_request, - headers={'Content-Type': 'application/json'}, - timeout=5 - ) - - print(f" Response: {post_response.status_code}") - if post_response.status_code == 200: - print(f" Result: {post_response.json()}") - - except Exception as e: - print(f" Error during POST: {e}") - - print("\n" + "=" * 50) - print("HTTP+SSE pattern demonstration complete") - print("Check MCPHawk to see the detected transport type:") - print("- GET /sse with Accept: text/event-stream → HTTP+SSE") - print("- Server sends 'endpoint' event → Confirms HTTP+SSE") - print("- POST to endpoint without dual Accept → HTTP+SSE pattern") - - -if __name__ == "__main__": - print("HTTP+SSE MCP Client Example (Proper Implementation)") - print("This demonstrates the legacy HTTP+SSE transport pattern") - print("with a mock server that properly implements the protocol\n") - - # Start mock server - port = 8766 - print(f"Starting mock HTTP+SSE server on port {port}...") - server = run_mock_server(port) - time.sleep(1) # Give server time to start - - # Run client simulation - simulate_http_sse_client(port) - - # Keep server running briefly for any remaining packets - time.sleep(1) - print("\nDone!") diff --git a/examples/mcp_sdk_client.py b/examples/mcp_sdk_client.py deleted file mode 100755 index 20637fc..0000000 --- a/examples/mcp_sdk_client.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -"""Test MCPHawk server using the official MCP SDK client.""" - -import asyncio -import json - -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client - - -async def test_with_sdk_client(): - """Test using the SDK's official client.""" - - # Connect to the server - async with streamablehttp_client("http://localhost:8765/mcp") as (read_stream, write_stream, session_id): - print(f"Connected with session ID: {session_id}") - - # Create a session - async with ClientSession(read_stream, write_stream) as session: - # Initialize - print("\n1. Initializing...") - await session.initialize() - print("Initialized successfully") - - # List tools - print("\n2. Listing tools...") - tools_result = await session.list_tools() - print(f"Available tools: {len(tools_result.tools)}") - for tool in tools_result.tools: - print(f" - {tool.name}: {tool.description}") - - # Call a tool - print("\n3. Calling get_stats...") - result = await session.call_tool("get_stats", arguments={}) - print("Result:") - for content in result.content: - print(f" {content.text}") - - # Try query_traffic - print("\n4. Querying recent traffic...") - result = await session.call_tool("query_traffic", arguments={"limit": 5}) - print("Result:") - for content in result.content: - data = json.loads(content.text) - print(f" Found {len(data)} log entries") - - -if __name__ == "__main__": - print("Testing MCPHawk MCP Server with SDK Client") - print("==========================================") - print("Make sure the server is running with:") - print(" mcphawk mcp --transport http --mcp-port 8765") - print() - - try: - asyncio.run(test_with_sdk_client()) - except Exception as e: - print(f"Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - diff --git a/examples/stdio_client.py b/examples/stdio_client.py deleted file mode 100644 index b483088..0000000 --- a/examples/stdio_client.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -""" -Example stdio client for MCPHawk MCP server. - -This demonstrates how to communicate with MCPHawk's MCP server using the stdio transport. -The MCP protocol requires: -1. Initialize request -2. Initialized notification -3. Then you can make tool calls -""" - -import json -import queue -import subprocess -import threading -from typing import Any, Optional - - -class MCPHawkStdioClient: - """Client for communicating with MCPHawk MCP server over stdio.""" - - def __init__(self, debug: bool = False): - self.debug = debug - self.proc = None - self.stderr_queue = queue.Queue() - self.request_id = 0 - - def connect(self) -> bool: - """Start the MCP server process and initialize connection.""" - try: - # Start the MCP server - self.proc = subprocess.Popen( - ["mcphawk", "mcp", "--transport", "stdio"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0 # Unbuffered - ) - - # Start stderr reader thread - self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) - self.stderr_thread.start() - - # Send initialize request - init_response = self._send_request({ - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "mcphawk-stdio-client", "version": "1.0"} - } - }) - - if not init_response or "error" in init_response: - print(f"Failed to initialize: {init_response}") - return False - - # Send initialized notification - self._send_notification({ - "method": "notifications/initialized", - "params": {} - }) - - if self.debug: - print(f"Connected to server: {init_response['result']['serverInfo']}") - - return True - - except Exception as e: - print(f"Failed to connect: {e}") - return False - - def _read_stderr(self): - """Read stderr in a separate thread.""" - while self.proc and self.proc.poll() is None: - line = self.proc.stderr.readline() - if line: - self.stderr_queue.put(line.strip()) - - def _send_request(self, request: dict[str, Any]) -> Optional[dict[str, Any]]: - """Send a JSON-RPC request and wait for response.""" - self.request_id += 1 - request["jsonrpc"] = "2.0" - request["id"] = self.request_id - - request_str = json.dumps(request) - if self.debug: - print(f">>> {request_str}") - - self.proc.stdin.write(request_str + "\n") - self.proc.stdin.flush() - - # Read response - response_line = self.proc.stdout.readline() - if response_line: - try: - response = json.loads(response_line) - if self.debug: - print(f"<<< {json.dumps(response, indent=2)}") - return response - except json.JSONDecodeError as e: - print(f"Failed to decode response: {e}") - print(f"Raw: {response_line}") - return None - return None - - def _send_notification(self, notification: dict[str, Any]) -> None: - """Send a JSON-RPC notification (no response expected).""" - notification["jsonrpc"] = "2.0" - - notification_str = json.dumps(notification) - if self.debug: - print(f">>> {notification_str}") - - self.proc.stdin.write(notification_str + "\n") - self.proc.stdin.flush() - - def list_tools(self) -> Optional[list]: - """Get list of available tools.""" - response = self._send_request({ - "method": "tools/list", - "params": {} - }) - - if response and "result" in response: - return response["result"]["tools"] - return None - - def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]] = None) -> Optional[Any]: - """Call a tool with given arguments.""" - response = self._send_request({ - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments or {} - } - }) - - if response and "result" in response: - # Extract the text content from the response - content = response["result"]["content"] - if content and len(content) > 0: - text = content[0]["text"] - try: - # Try to parse as JSON - return json.loads(text) - except json.JSONDecodeError: - # Return as plain text if not JSON - return text - return None - - def close(self): - """Close the connection.""" - if self.proc: - self.proc.terminate() - self.proc.wait() - self.proc = None - - -def main(): - """Example usage of the MCPHawk stdio client.""" - client = MCPHawkStdioClient(debug=True) - - print("Connecting to MCPHawk MCP server...") - if not client.connect(): - print("Failed to connect!") - return - - print("\n1. Listing available tools:") - tools = client.list_tools() - if tools: - for tool in tools: - print(f" - {tool['name']}: {tool['description']}") - - print("\n2. Getting traffic statistics:") - stats = client.call_tool("get_stats") - if stats: - print(f" Total logs: {stats['total']}") - print(f" Requests: {stats['requests']}") - print(f" Responses: {stats['responses']}") - print(f" Notifications: {stats['notifications']}") - print(f" Errors: {stats['errors']}") - - print("\n3. Querying recent traffic:") - logs = client.call_tool("query_traffic", {"limit": 5}) - if logs: - print(f" Found {len(logs)} recent log entries") - for log in logs: - msg_preview = log['message'][:50] + "..." if len(log['message']) > 50 else log['message'] - print(f" - {log['timestamp']}: {msg_preview}") - - print("\n4. Listing captured methods:") - methods = client.call_tool("list_methods") - if methods: - print(f" Found {len(methods)} unique methods:") - for method in methods[:10]: # Show first 10 - print(f" - {method}") - if len(methods) > 10: - print(f" ... and {len(methods) - 10} more") - - print("\nClosing connection...") - client.close() - - -if __name__ == "__main__": - main() diff --git a/examples/streamable_http_example.py b/examples/streamable_http_example.py deleted file mode 100644 index aaeaa66..0000000 --- a/examples/streamable_http_example.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -Example Streamable HTTP MCP client traffic generator. - -This demonstrates the Streamable HTTP transport pattern for testing MCPHawk's transport detection. -Streamable HTTP uses: -1. POST request with dual Accept headers (application/json, text/event-stream) -2. Server can respond with either JSON or SSE -""" - -import json -import urllib.error -import urllib.request - - -def simulate_streamable_http_client(): - """Simulate Streamable HTTP client traffic pattern.""" - - print("Simulating Streamable HTTP MCP Client") - print("=" * 50) - - server_url = "http://localhost:8765" - - print("\n1. Sending request with dual Accept headers (Streamable HTTP pattern)...") - print(f" POST {server_url}/mcp") - print(" Accept: application/json, text/event-stream") - print(" Content-Type: application/json") - - # Send a sample initialize request - initialize_request = { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", # New protocol version - "capabilities": {}, - "clientInfo": { - "name": "streamable-http-test", - "version": "1.0.0" - } - }, - "id": 1 - } - - try: - data = json.dumps(initialize_request).encode('utf-8') - req = urllib.request.Request( - f"{server_url}/mcp", - data=data, - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream" # Key difference! - } - ) - - print(f"\n Request body: {json.dumps(initialize_request, indent=2)}") - - with urllib.request.urlopen(req) as response: - print(f"\n Response: {response.status}") - content_type = response.headers.get('Content-Type', '') - print(f" Content-Type: {content_type}") - - if 'text/event-stream' in content_type: - print(" Server returned SSE response (streaming)") - else: - print(" Server returned JSON response") - - except urllib.error.URLError as e: - print(f" Connection failed: {e}") - - # Send another request that might get a different response type - print("\n2. Sending tool call request...") - print(f" POST {server_url}/mcp") - print(" Accept: application/json, text/event-stream") - - tool_request = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "long_running_tool", - "arguments": {} - }, - "id": 2 - } - - try: - data = json.dumps(tool_request).encode('utf-8') - req = urllib.request.Request( - f"{server_url}/mcp", - data=data, - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream" - } - ) - - with urllib.request.urlopen(req) as response: - print(f"\n Response: {response.status}") - content_type = response.headers.get('Content-Type', '') - print(f" Content-Type: {content_type}") - - except urllib.error.URLError as e: - print(f" Connection failed: {e}") - - print("\n" + "=" * 50) - print("Streamable HTTP pattern demonstration complete") - print("Check MCPHawk to see how it detected the transport type:") - print("- POST with Accept: application/json, text/event-stream → Streamable HTTP") - - -if __name__ == "__main__": - print("Streamable HTTP MCP Client Example") - print("This demonstrates the Streamable HTTP transport pattern") - print("This should work with our MCP server\n") - - simulate_streamable_http_client() diff --git a/examples/test_mcp_http.sh b/examples/test_mcp_http.sh deleted file mode 100755 index d3f58f5..0000000 --- a/examples/test_mcp_http.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/bin/bash - -# Test MCPHawk MCP Server with various requests -# Make sure MCPHawk is running with: -# sudo mcphawk web --auto-detect --with-mcp --mcp-transport http --mcp-port 8765 --debug - -SESSION_ID="test-session-$(date +%s)" -MCP_URL="http://localhost:8765/mcp" - -echo "Testing MCPHawk MCP Server with session: $SESSION_ID" -echo "================================================" - -# 1. Initialize session -echo -e "\n1. Initializing MCP session..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0" - } - }, - "id": 1 - }' | jq . - -sleep 1 - -# 2. Send initialized notification -echo -e "\n2. Sending initialized notification..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {} - }' | jq . - -sleep 1 - -# 3. List available tools -echo -e "\n3. Listing available tools..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 2 - }' | jq . - -sleep 1 - -# 4. Get traffic statistics -echo -e "\n4. Getting traffic statistics..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "get_stats", - "arguments": {} - }, - "id": 3 - }' | jq . - -sleep 1 - -# 5. Query recent traffic -echo -e "\n5. Querying recent traffic (limit 10)..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "query_traffic", - "arguments": { - "limit": 10 - } - }, - "id": 4 - }' | jq . - -sleep 1 - -# 6. List unique methods captured -echo -e "\n6. Listing unique JSON-RPC methods..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "list_methods", - "arguments": {} - }, - "id": 5 - }' | jq . - -sleep 1 - -# 7. Search for specific traffic -echo -e "\n7. Searching for 'initialize' in traffic..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "search_traffic", - "arguments": { - "search_term": "initialize" - } - }, - "id": 6 - }' | jq . - -sleep 1 - -# 8. Test error handling - call non-existent tool -echo -e "\n8. Testing error handling (calling non-existent tool)..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "non_existent_tool", - "arguments": {} - }, - "id": 7 - }' | jq . - -sleep 1 - -# 9. Send a notification (no ID, no response expected) -echo -e "\n9. Sending a notification..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "notifications/progress", - "params": { - "progress": 50, - "message": "Test progress notification" - } - }' - -echo -e "\n\nAll tests completed!" -echo "Check the MCPHawk web UI at http://localhost:8000 to see:" -echo "- All requests marked with purple 'MCP' badges" -echo "- Use the MCPHawk toggle button to filter these messages" -echo "- Click on messages to see full JSON details" \ No newline at end of file diff --git a/examples/test_mcp_sdk.sh b/examples/test_mcp_sdk.sh deleted file mode 100755 index e5dfc38..0000000 --- a/examples/test_mcp_sdk.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash - -# Test MCPHawk MCP Server (SDK version) with proper session flow -# The SDK requires: -# 1. First initialize request WITHOUT session ID -# 2. Server returns session ID in response -# 3. Use that session ID for subsequent requests - -MCP_URL="http://localhost:8765/mcp" - -echo "Testing MCPHawk MCP Server (SDK version)" -echo "========================================" - -# 1. Initialize WITHOUT session ID - server will assign one -echo -e "\n1. Initializing MCP session (no session ID)..." -response=$(curl -s -i -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0" - } - }, - "id": 1 - }') - -echo "$response" - -# Extract session ID from response headers -SESSION_ID=$(echo "$response" | grep -i "mcp-session-id:" | sed 's/.*: //' | tr -d '\r\n') - -if [ -z "$SESSION_ID" ]; then - echo "ERROR: No session ID received from server" - exit 1 -fi - -echo -e "\nReceived session ID: $SESSION_ID" - -sleep 1 - -# 2. Now use the server-provided session ID for subsequent requests -echo -e "\n2. Listing tools with session ID..." -curl -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 2 - }' | jq . - -sleep 1 - -# 3. Test a notification (should return no response) -echo -e "\n3. Sending notification..." -curl -i -X POST $MCP_URL \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "notifications/progress", - "params": { - "progress": 50, - "message": "Test progress notification" - } - }' - -echo -e "\n\nCheck http://localhost:8000 for captured traffic" \ No newline at end of file diff --git a/examples/test_mcp_simple.sh b/examples/test_mcp_simple.sh deleted file mode 100755 index cb8c054..0000000 --- a/examples/test_mcp_simple.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -# Simple test for MCPHawk MCP Server -# Run MCPHawk with: sudo mcphawk web --auto-detect --with-mcp --mcp-transport http --mcp-port 8765 --debug - -SESSION_ID="test-$(date +%s)" -echo "Testing with session: $SESSION_ID" - -# Single test request -echo "Sending initialize request..." -curl -v -X POST http://localhost:8765/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0" - } - }, - "id": 1 - }' 2>&1 - -echo -e "\n\nCheck http://localhost:8000 for captured traffic" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 95cd717..2090b54 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - + - - - - MCPHawk - Model Context Protocol Debugger + + + + MCPHawk
- \ No newline at end of file + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41ce338..2c15a13 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,65 +1,48 @@ { "name": "mcphawk-frontend", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcphawk-frontend", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { - "@headlessui/vue": "^1.7.17", - "@heroicons/vue": "^2.1.1", - "@tanstack/vue-virtual": "^3.0.2", - "axios": "^1.6.5", - "dayjs": "^1.11.10", - "pinia": "^2.1.7", - "vue": "^3.4.15" + "@fontsource/ibm-plex-mono": "^5.1.0", + "@fontsource/ibm-plex-sans": "^5.1.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.0.3", - "autoprefixer": "^10.4.17", - "eslint": "^8.56.0", - "eslint-plugin-vue": "^9.20.1", - "postcss": "^8.4.33", - "tailwindcss": "^3.4.1", - "vite": "^5.0.11" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -69,925 +52,906 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@headlessui/vue": { - "version": "1.7.23", - "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", - "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", - "dependencies": { - "@tanstack/vue-virtual": "^3.0.0-beta.60" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/@heroicons/vue": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", - "integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==", - "peerDependencies": { - "vue": ">= 3" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", - "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", - "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", - "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", - "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", - "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", - "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", - "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", - "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", - "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", - "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", - "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", - "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", - "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", - "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", - "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", - "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", - "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", - "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", - "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", - "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@tanstack/virtual-core": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/vue-virtual": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz", - "integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==", - "dependencies": { - "@tanstack/virtual-core": "3.13.12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.0.0" - } + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", "dev": true, + "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" }, @@ -997,2567 +961,373 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", - "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", - "dependencies": { - "@babel/parser": "^7.28.0", - "@vue/shared": "3.5.18", - "entities": "^4.5.0", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", - "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.18", - "@vue/shared": "3.5.18" + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", - "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", - "dependencies": { - "@babel/parser": "^7.28.0", - "@vue/compiler-core": "3.5.18", - "@vue/compiler-dom": "3.5.18", - "@vue/compiler-ssr": "3.5.18", - "@vue/shared": "3.5.18", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", "estree-walker": "^2.0.2", - "magic-string": "^0.30.17", - "postcss": "^8.5.6", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", - "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.18", - "@vue/shared": "3.5.18" + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" } }, "node_modules/@vue/devtools-api": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz", - "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", "dependencies": { - "@vue/shared": "3.5.18" + "@vue/shared": "3.5.43" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz", - "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.18", - "@vue/shared": "3.5.18" + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", - "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.18", - "@vue/runtime-core": "3.5.18", - "@vue/shared": "3.5.18", - "csstype": "^3.1.3" + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz", - "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.18", - "@vue/shared": "3.5.18" - }, - "peerDependencies": { - "vue": "3.5.18" + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" } }, "node_modules/@vue/shared": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz", - "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.5.190", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", - "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-vue": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", - "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "vue-eslint-parser": "^9.4.3", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinia": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", - "dependencies": { - "@vue/devtools-api": "^6.6.3", - "vue-demi": "^0.14.10" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", - "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.45.1", - "@rollup/rollup-android-arm64": "4.45.1", - "@rollup/rollup-darwin-arm64": "4.45.1", - "@rollup/rollup-darwin-x64": "4.45.1", - "@rollup/rollup-freebsd-arm64": "4.45.1", - "@rollup/rollup-freebsd-x64": "4.45.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", - "@rollup/rollup-linux-arm-musleabihf": "4.45.1", - "@rollup/rollup-linux-arm64-gnu": "4.45.1", - "@rollup/rollup-linux-arm64-musl": "4.45.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-musl": "4.45.1", - "@rollup/rollup-linux-s390x-gnu": "4.45.1", - "@rollup/rollup-linux-x64-gnu": "4.45.1", - "@rollup/rollup-linux-x64-musl": "4.45.1", - "@rollup/rollup-win32-arm64-msvc": "4.45.1", - "@rollup/rollup-win32-ia32-msvc": "4.45.1", - "@rollup/rollup-win32-x64-msvc": "4.45.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, + "hasInstallScript": true, + "license": "MIT", "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/sucrase/node_modules/brace-expansion": { + "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" + "node": ">=12.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "picomatch": "^3 || ^4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=8.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/browserslist" + "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" + "url": "https://tidelift.com/funding/github/npm/postcss" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "@types/estree": "1.0.9" }, "bin": { - "update-browserslist-db": "cli.js" + "rollup": "dist/bin/rollup" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, + "license": "MIT", "dependencies": { - "punycode": "^2.1.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3566,19 +1336,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -3599,19 +1375,26 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vue": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz", - "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.18", - "@vue/compiler-sfc": "3.5.18", - "@vue/runtime-dom": "3.5.18", - "@vue/server-renderer": "3.5.18", - "@vue/shared": "3.5.18" + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" }, "peerDependencies": { "typescript": "*" @@ -3622,210 +1405,19 @@ } } }, - "node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" + "@vue/devtools-api": "^6.6.4" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" + "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "vue": "^3.5.0" } } } diff --git a/frontend/package.json b/frontend/package.json index e124d6d..bf41a46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,31 +1,21 @@ { "name": "mcphawk-frontend", - "version": "0.1.0", + "version": "1.0.0", "type": "module", "private": true, "scripts": { "dev": "vite", "build": "vite build", - "build:watch": "vite build --watch", - "preview": "vite preview", - "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" + "preview": "vite preview" }, "dependencies": { - "vue": "^3.4.15", - "pinia": "^2.1.7", - "@headlessui/vue": "^1.7.17", - "@heroicons/vue": "^2.1.1", - "@tanstack/vue-virtual": "^3.0.2", - "dayjs": "^1.11.10", - "axios": "^1.6.5" + "@fontsource/ibm-plex-mono": "^5.1.0", + "@fontsource/ibm-plex-sans": "^5.1.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.0.3", - "vite": "^5.0.11", - "autoprefixer": "^10.4.17", - "postcss": "^8.4.33", - "tailwindcss": "^3.4.1", - "eslint": "^8.56.0", - "eslint-plugin-vue": "^9.20.1" + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0" } -} \ No newline at end of file +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index e99ebc2..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file diff --git a/frontend/src/App-original.vue b/frontend/src/App-original.vue deleted file mode 100644 index b364615..0000000 --- a/frontend/src/App-original.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/App-sidebar.vue b/frontend/src/App-sidebar.vue deleted file mode 100644 index 099c986..0000000 --- a/frontend/src/App-sidebar.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6a5a36d..6874fdd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,121 +1,52 @@ -