-
-
- {{ statusText }}
-
+
+
+
+
+
+ {{ wsStatusText }}
+
+
+
+
+
\ No newline at end of file
diff --git a/mcphawk/cli.py b/mcphawk/cli.py
index ce56c04..9d514b7 100644
--- a/mcphawk/cli.py
+++ b/mcphawk/cli.py
@@ -137,7 +137,8 @@ def run_mcp():
filter_expr=filter_expr,
auto_detect=auto_detect,
debug=debug,
- excluded_ports=[mcp_port] if with_mcp else []
+ excluded_ports=[mcp_port] if with_mcp else [],
+ with_mcp=with_mcp
)
diff --git a/mcphawk/web/server.py b/mcphawk/web/server.py
index c943237..080d0ef 100644
--- a/mcphawk/web/server.py
+++ b/mcphawk/web/server.py
@@ -15,6 +15,9 @@
# 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
@@ -27,6 +30,16 @@
)
+@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):
"""
@@ -103,7 +116,7 @@ def safe_start():
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):
+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.
@@ -115,6 +128,10 @@ def run_web(sniffer: bool = True, host: str = "127.0.0.1", port: int = 8000, fil
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
+
if sniffer:
if not filter_expr:
raise ValueError("filter_expr is required when sniffer is enabled")
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 62e40e0..bd7b350 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -102,7 +102,7 @@ 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=[])
+ 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')
@@ -110,7 +110,7 @@ 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=[])
+ 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')
@@ -118,7 +118,7 @@ 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=[])
+ 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')
@@ -126,7 +126,7 @@ 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=[])
+ 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')
@@ -134,7 +134,7 @@ 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=[])
+ 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():
@@ -170,7 +170,7 @@ 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=[])
+ 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')
@@ -196,7 +196,8 @@ def test_web_command_with_mcp(mock_thread, mock_mcp_server, mock_run_web):
filter_expr="tcp port 3000",
auto_detect=False,
debug=False,
- excluded_ports=[8765] # Default MCP port
+ excluded_ports=[8765], # Default MCP port
+ with_mcp=True
)
diff --git a/tests/test_web_server.py b/tests/test_web_server.py
index b4943c4..2af82b3 100644
--- a/tests/test_web_server.py
+++ b/tests/test_web_server.py
@@ -172,3 +172,32 @@ def test_run_web_sniffer_without_filter():
# 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
From d609e3e283c0f96358da9cda20d876c89bb86053 Mon Sep 17 00:00:00 2001
From: tech4242 <5933291+tech4242@users.noreply.github.com>
Date: Mon, 28 Jul 2025 20:04:54 +0200
Subject: [PATCH 03/16] add: HTTP Streaming for MCP
---
README.md | 69 ++++++-
mcphawk/cli.py | 55 +++---
mcphawk/mcp_server/server.py | 193 ++++++++++++++++++-
tests/test_cli.py | 252 ++++++++++++++++++++++++-
tests/test_mcp_server.py | 350 ++++++++++++++++++++++++++++++++++-
5 files changed, 884 insertions(+), 35 deletions(-)
diff --git a/README.md b/README.md
index dbef113..8153446 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,10 @@ Non-exhaustive list:
- expand mode to directly see JSON withtout detailed view
- filtering
- always see if WS connection is up for live updates
+- **MCP Server for querying captured data** - Query captured traffic via MCP protocol
+ - Standalone mode or integrated with sniffer/web commands
+ - Streamable HTTP transport for easy integration and testing
+ - Tools for traffic search, statistics, and analysis
## Comparison with Similar Tools
@@ -141,6 +145,69 @@ sudo mcphawk web --port 3000 --host 0.0.0.0 --web-port 9000
# Enable debug output for troubleshooting
sudo mcphawk sniff --port 3000 --debug
sudo mcphawk web --port 3000 --debug
+
+# Start MCP server with captured data (stdio transport)
+mcphawk mcp
+
+# Start MCP server with Streamable HTTP transport
+mcphawk mcp --transport http --mcp-port 8765
+
+# Start sniffer with integrated MCP server (HTTP transport)
+sudo mcphawk sniff --port 3000 --with-mcp --mcp-transport http
+
+# Start web UI with integrated MCP server
+sudo mcphawk web --port 3000 --with-mcp --mcp-transport http --mcp-port 8765
+```
+
+## MCP Server for Querying Captured Data
+
+MCPHawk includes an MCP server that allows you to query captured traffic using the Model Context Protocol. This is perfect for:
+- Building AI agents that analyze captured traffic
+- Integrating traffic analysis into your MCP-enabled tools
+- Programmatically searching and filtering captured data
+
+### Available MCP Tools
+
+- **query_traffic** - Fetch logs with pagination (limit/offset)
+- **get_log** - Retrieve specific log entry by ID
+- **search_traffic** - Search by content, message type, or traffic type
+- **get_stats** - Get traffic statistics (requests, responses, errors, etc.)
+- **list_methods** - List all unique JSON-RPC methods seen
+
+### Using with Streamable HTTP Transport
+
+The HTTP transport makes it easy to test and integrate:
+
+```bash
+# Start MCP server on HTTP
+mcphawk mcp --transport http --mcp-port 8765
+
+# Initialize session
+curl -X POST http://localhost:8765/mcp \
+ -H 'Content-Type: application/json' \
+ -H 'X-Session-Id: my-session' \
+ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"my-client","version":"1.0"}},"id":1}'
+
+# Query traffic statistics
+curl -X POST http://localhost:8765/mcp \
+ -H 'Content-Type: application/json' \
+ -H 'X-Session-Id: my-session' \
+ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_stats","arguments":{}},"id":2}'
+```
+
+### Using with stdio Transport (Claude Desktop)
+
+Configure in Claude Desktop settings:
+
+```json
+{
+ "mcpServers": {
+ "mcphawk": {
+ "command": "mcphawk",
+ "args": ["mcp"]
+ }
+ }
+}
```
## Platform Support
@@ -192,7 +259,7 @@ Vote for features by opening a GitHub issue!
- [ ] **Interactive Replay** - Click any request to re-send it, edit and replay captured messages
- [ ] **Real-time Alerts** - Alert on specific methods or error patterns with webhook support
- [ ] **Visualization** - Sequence diagrams, resource heat maps, method dependency graphs
-- [ ] **MCP Server Interface** - Expose captured traffic via MCP server for AI agents to query and analyze traffic patterns
+- [x] **MCP Server Interface** - Expose captured traffic via MCP server for AI agents to query and analyze traffic patterns
... and a few more off the deep end:
- [ ] **TLS/HTTPS Support (MITM Proxy Mode)** - Optional man-in-the-middle proxy with certificate installation for encrypted traffic
diff --git a/mcphawk/cli.py b/mcphawk/cli.py
index 9d514b7..218fd66 100644
--- a/mcphawk/cli.py
+++ b/mcphawk/cli.py
@@ -26,7 +26,8 @@ def sniff(
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_port: int = typer.Option(8765, "--mcp-port", help="Port for MCP server (stdio if not specified)"),
+ 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)."""
@@ -52,24 +53,27 @@ def sniff(
# Start MCP server if requested
mcp_thread = None
+ excluded_ports = []
if with_mcp:
- print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
server = MCPHawkServer()
- def run_mcp():
- asyncio.run(server.run_stdio())
+ if mcp_transport == "http":
+ print(f"[MCPHawk] Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
+ excluded_ports = [mcp_port] # Exclude MCP port from sniffing
+ def run_mcp():
+ asyncio.run(server.run_http(port=mcp_port))
+ else:
+ print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
+ def run_mcp():
+ asyncio.run(server.run_stdio())
mcp_thread = threading.Thread(target=run_mcp, daemon=True)
mcp_thread.start()
print(f"[MCPHawk] Starting sniffer with filter: {filter_expr}")
- if with_mcp and filter_expr != "tcp":
- print(f"[MCPHawk] Note: MCP server traffic on port {mcp_port} will be excluded from capture")
print("[MCPHawk] Press Ctrl+C to stop...")
try:
- # Exclude MCP port if running MCP server
- excluded_ports = [mcp_port] if with_mcp else []
start_sniffer(
filter_expr=filter_expr,
auto_detect=auto_detect,
@@ -90,7 +94,8 @@ def web(
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_port: int = typer.Option(8765, "--mcp-port", help="Port for MCP server (stdio if not specified)"),
+ 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."""
@@ -116,20 +121,23 @@ def web(
# Start MCP server if requested
mcp_thread = None
+ excluded_ports = []
if with_mcp:
- print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
server = MCPHawkServer()
- def run_mcp():
- asyncio.run(server.run_stdio())
+ if mcp_transport == "http":
+ print(f"[MCPHawk] Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
+ excluded_ports = [mcp_port] # Exclude MCP port from sniffing
+ def run_mcp():
+ asyncio.run(server.run_http(port=mcp_port))
+ else:
+ print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
+ def run_mcp():
+ asyncio.run(server.run_stdio())
mcp_thread = threading.Thread(target=run_mcp, daemon=True)
mcp_thread.start()
- # Update filter to exclude MCP port if needed
- if with_mcp and filter_expr and filter_expr != "tcp":
- print(f"[MCPHawk] Note: MCP server traffic on port {mcp_port} will be excluded from capture")
-
run_web(
sniffer=not no_sniffer,
host=host,
@@ -137,7 +145,7 @@ def run_mcp():
filter_expr=filter_expr,
auto_detect=auto_detect,
debug=debug,
- excluded_ports=[mcp_port] if with_mcp else [],
+ excluded_ports=excluded_ports,
with_mcp=with_mcp
)
@@ -164,10 +172,10 @@ def mcp(
}
}
""")
- elif transport == "tcp":
- print(f"[MCPHawk] MCP server will listen on port {mcp_port}")
- print("[MCPHawk] Note: TCP transport not yet implemented")
- raise typer.Exit(1)
+ elif transport == "http":
+ print(f"[MCPHawk] MCP server will listen on http://localhost:{mcp_port}/mcp")
+ print("[MCPHawk] Example test command:")
+ print(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:
print(f"[ERROR] Unknown transport: {transport}")
raise typer.Exit(1)
@@ -176,7 +184,10 @@ def mcp(
server = MCPHawkServer()
try:
- asyncio.run(server.run_stdio())
+ if transport == "stdio":
+ asyncio.run(server.run_stdio())
+ elif transport == "http":
+ asyncio.run(server.run_http(port=mcp_port))
except KeyboardInterrupt:
print("\n[MCPHawk] MCP server stopped.")
sys.exit(0)
diff --git a/mcphawk/mcp_server/server.py b/mcphawk/mcp_server/server.py
index 2f3b4ca..e9ef311 100644
--- a/mcphawk/mcp_server/server.py
+++ b/mcphawk/mcp_server/server.py
@@ -1,6 +1,5 @@
"""MCP server implementation for MCPHawk."""
-import asyncio
import json
from typing import Any, Optional
@@ -23,6 +22,10 @@ def __init__(self, db_path: Optional[str] = None):
def _setup_handlers(self):
"""Setup MCP protocol handlers."""
+ # Store handlers as instance attributes for HTTP transport
+ self._handle_list_tools = None
+ self._handle_call_tool = None
+
@self.server.list_tools()
async def handle_list_tools() -> list[Tool]:
"""List available tools."""
@@ -102,6 +105,9 @@ async def handle_list_tools() -> list[Tool]:
)
]
+ # Store the handler
+ self._handle_list_tools = handle_list_tools
+
@self.server.call_tool()
async def handle_call_tool(name: str, arguments: Optional[dict[str, Any]] = None) -> list[TextContent]:
"""Handle tool calls."""
@@ -215,13 +221,182 @@ async def handle_call_tool(name: str, arguments: Optional[dict[str, Any]] = None
else:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
+ # Store the handler
+ self._handle_call_tool = handle_call_tool
+
async def run_stdio(self):
"""Run the MCP server using stdio transport."""
- async with self.server.run():
- # The server will run until interrupted
- await asyncio.Event().wait()
-
- async def run_tcp(self, host: str = "127.0.0.1", port: int = 8765):
- """Run the MCP server using TCP transport."""
- # TODO: Implement TCP transport when needed
- raise NotImplementedError("TCP transport not implemented yet")
+ import sys
+
+ import anyio
+ from mcp.server.stdio import stdio_server
+
+ # Wrap stdin/stdout in async file objects
+ async_stdin = anyio.wrap_file(sys.stdin)
+ async_stdout = anyio.wrap_file(sys.stdout)
+
+ async with stdio_server(async_stdin, async_stdout) as (read_stream, write_stream):
+ await self.server.run(
+ read_stream,
+ write_stream,
+ self.server.create_initialization_options()
+ )
+
+ async def run_http(self, host: str = "127.0.0.1", port: int = 8765):
+ """Run the MCP server using Streamable HTTP transport."""
+ import uuid
+
+ import uvicorn
+ from fastapi import FastAPI, Request
+ from fastapi.responses import JSONResponse
+
+ # Create FastAPI app for HTTP transport
+ app = FastAPI(title="MCPHawk MCP Server")
+
+ # Store active sessions
+ sessions = {}
+
+ # Get handlers for use in closures
+ handle_list_tools = self._handle_list_tools
+ handle_call_tool = self._handle_call_tool
+
+ @app.post("/mcp")
+ async def handle_mcp_request(request: Request):
+ """Handle MCP JSON-RPC requests over HTTP."""
+ try:
+ # Get request body
+ body = await request.json()
+
+ # Get or create session ID from headers
+ session_id = request.headers.get("X-Session-Id", str(uuid.uuid4()))
+
+ # Process the JSON-RPC request
+ method = body.get("method")
+ params = body.get("params", {})
+ request_id = body.get("id")
+
+ # Handle different methods
+ if method == "initialize":
+ # Mark session as initialized
+ sessions[session_id] = True
+ result = {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "experimental": {},
+ "tools": {"listChanged": False}
+ },
+ "serverInfo": {
+ "name": "mcphawk-mcp",
+ "version": "1.12.2"
+ }
+ }
+
+ elif method == "tools/list":
+ # Check if session is initialized
+ if session_id not in sessions:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32602,
+ "message": "Session not initialized"
+ }
+ }
+ )
+
+ # Get tools list - call our handler directly
+ tools = await handle_list_tools()
+ result = {
+ "tools": [
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "inputSchema": tool.inputSchema
+ }
+ for tool in tools
+ ]
+ }
+
+ elif method == "tools/call":
+ # Check if session is initialized
+ if session_id not in sessions:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32602,
+ "message": "Session not initialized"
+ }
+ }
+ )
+
+ # Call the tool
+ tool_name = params.get("name")
+ tool_args = params.get("arguments", {})
+
+ # Call our handler directly
+ content = await handle_call_tool(tool_name, tool_args)
+
+ result = {
+ "content": [
+ {"type": c.type, "text": c.text}
+ for c in content
+ ]
+ }
+
+ else:
+ # Unknown method
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32601,
+ "message": f"Unknown method: {method}"
+ }
+ }
+ )
+
+ # Return successful response
+ response = {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": result
+ }
+
+ # Include session ID in response headers
+ return JSONResponse(
+ content=response,
+ headers={"X-Session-Id": session_id}
+ )
+
+ except Exception as e:
+ import traceback
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": body.get("id") if "body" in locals() else None,
+ "error": {
+ "code": -32603,
+ "message": "Internal error",
+ "data": str(e) + "\n" + traceback.format_exc()
+ }
+ }
+ )
+
+ # Run the server
+ config = uvicorn.Config(
+ app,
+ host=host,
+ port=port,
+ log_level="info",
+ access_log=False
+ )
+ server = uvicorn.Server(config)
+ await server.serve()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bd7b350..d67c772 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -11,11 +11,12 @@
def test_cli_help():
- """Test that CLI help shows both commands."""
+ """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
@@ -51,6 +52,252 @@ def test_sniff_command_requires_flags():
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
+ assert "--transport" in result.stdout
+ assert "--mcp-port" in result.stdout
+ assert ("stdio or http" in result.stdout) or ("stdio or tcp" 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 "[MCPHawk] Starting MCP server (transport: stdio)" in result.stdout
+ assert "mcpServers" in result.stdout
+
+ # 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 "[MCPHawk] Starting MCP server (transport: http)" in result.stdout
+ assert "http://localhost:8765/mcp" in result.stdout
+ assert "curl -X POST" in result.stdout
+
+ # 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 "[ERROR] 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 "[MCPHawk] Starting MCP HTTP server on http://localhost:8765/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 excluded ports
+ mock_start_sniffer.assert_called_once()
+ call_args = mock_start_sniffer.call_args[1]
+ assert call_args['excluded_ports'] == [8765]
+
+
+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'):
+
+ result = runner.invoke(app, [
+ "sniff",
+ "--port", "3000",
+ "--with-mcp",
+ "--mcp-transport", "stdio"
+ ])
+
+ # Check MCP server startup message
+ assert "[MCPHawk] 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 "[MCPHawk] 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'] == [8766]
+
+
+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 "[MCPHawk] 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 "[MCPHawk] 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'] == [7777]
+
+
+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 "[MCPHawk] Starting MCP server (transport: stdio)" in result.stdout
+ assert "9999" not in result.stdout
+ assert "mcpServers" in result.stdout
+
+ # 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 "[MCPHawk] 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'] == [8765]
+
+ # 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 "[MCPHawk] 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'] == [5555]
+
+
@patch('mcphawk.cli.start_sniffer')
def test_sniff_command_with_port(mock_start_sniffer):
"""Test sniff command with port option."""
@@ -189,6 +436,7 @@ def test_web_command_with_mcp(mock_thread, mock_mcp_server, mock_run_web):
mock_thread.return_value.start.assert_called_once()
# Check run_web was called with excluded ports
+ # Default MCP transport is HTTP on port 8765
mock_run_web.assert_called_once_with(
sniffer=True,
host="127.0.0.1",
@@ -196,7 +444,7 @@ def test_web_command_with_mcp(mock_thread, mock_mcp_server, mock_run_web):
filter_expr="tcp port 3000",
auto_detect=False,
debug=False,
- excluded_ports=[8765], # Default MCP port
+ excluded_ports=[8765], # Default HTTP MCP port is excluded
with_mcp=True
)
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index cc847cc..f859885 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -1,9 +1,13 @@
"""Tests for MCP server functionality."""
+import asyncio
+import contextlib
import json
+import os
+import tempfile
import uuid
from datetime import datetime, timezone
-from unittest.mock import Mock
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from mcp.types import TextContent
@@ -257,3 +261,347 @@ def decorator(func):
# Test missing search term
result = await handlers['call_tool']("search_traffic", {})
assert "search_term is required" in result[0].text
+
+ @pytest.mark.asyncio
+ async def test_run_stdio(self):
+ """Test that run_stdio properly handles stdio transport."""
+ server = MCPHawkServer()
+
+ # Mock dependencies
+ with patch('anyio.wrap_file') as mock_wrap_file, \
+ patch('mcp.server.stdio.stdio_server') as mock_stdio_server:
+
+ # Setup mocks
+ mock_async_stdin = AsyncMock()
+ mock_async_stdout = AsyncMock()
+ mock_wrap_file.side_effect = [mock_async_stdin, mock_async_stdout]
+
+ mock_read_stream = AsyncMock()
+ mock_write_stream = AsyncMock()
+ mock_context = AsyncMock()
+ mock_context.__aenter__.return_value = (mock_read_stream, mock_write_stream)
+ mock_context.__aexit__.return_value = None
+ mock_stdio_server.return_value = mock_context
+
+ # Mock server.run to complete immediately
+ server.server.run = AsyncMock()
+ server.server.create_initialization_options = MagicMock(return_value={})
+
+ # Run the method
+ await server.run_stdio()
+
+ # Verify wrap_file was called correctly
+ assert mock_wrap_file.call_count == 2
+
+ # Verify stdio_server was called with wrapped files
+ mock_stdio_server.assert_called_once_with(mock_async_stdin, mock_async_stdout)
+
+ # Verify server.run was called
+ server.server.run.assert_called_once_with(
+ mock_read_stream,
+ mock_write_stream,
+ {}
+ )
+
+ @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_http_handlers(self, sample_logs):
+ """Test HTTP transport handlers with actual requests."""
+ from fastapi.testclient import TestClient
+
+ # Create a temporary database for this test
+ with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_db:
+ db_path = tmp_db.name
+
+ # Initialize database with sample data
+ logger.set_db_path(db_path)
+ logger.init_db()
+
+ # Re-create sample logs in the new database
+ 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"
+ }
+ ]
+
+ for i, msg in enumerate(test_messages):
+ entry = {
+ "log_id": str(uuid.uuid4()),
+ "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),
+ "traffic_type": "TCP/WS" if i % 2 == 0 else "TCP/Direct"
+ }
+ logger.log_message(entry)
+
+ # Create server instance
+ server = MCPHawkServer(db_path)
+
+ # Get the FastAPI app by running the HTTP server setup
+ app = None
+ sessions = {}
+
+ # Extract the app creation logic
+ import uuid as uuid_module
+
+ from fastapi import FastAPI, Request
+ from fastapi.responses import JSONResponse
+
+ app = FastAPI(title="MCPHawk MCP Server")
+
+ # Get references to handlers
+ handle_list_tools = server._handle_list_tools
+ handle_call_tool = server._handle_call_tool
+
+ @app.post("/mcp")
+ async def handle_mcp_request(request: Request):
+ """Handle MCP JSON-RPC requests over HTTP."""
+ try:
+ body = await request.json()
+ session_id = request.headers.get("X-Session-Id", str(uuid_module.uuid4()))
+ method = body.get("method")
+ params = body.get("params", {})
+ request_id = body.get("id")
+
+ if method == "initialize":
+ sessions[session_id] = True
+ result = {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "experimental": {},
+ "tools": {"listChanged": False}
+ },
+ "serverInfo": {
+ "name": "mcphawk-mcp",
+ "version": "1.12.2"
+ }
+ }
+
+ elif method == "tools/list":
+ if session_id not in sessions:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32602,
+ "message": "Session not initialized"
+ }
+ }
+ )
+
+ tools = await handle_list_tools()
+ result = {
+ "tools": [
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "inputSchema": tool.inputSchema
+ }
+ for tool in tools
+ ]
+ }
+
+ elif method == "tools/call":
+ if session_id not in sessions:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32602,
+ "message": "Session not initialized"
+ }
+ }
+ )
+
+ tool_name = params.get("name")
+ tool_args = params.get("arguments", {})
+ content = await handle_call_tool(tool_name, tool_args)
+
+ result = {
+ "content": [
+ {"type": c.type, "text": c.text}
+ for c in content
+ ]
+ }
+
+ else:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": -32601,
+ "message": f"Unknown method: {method}"
+ }
+ }
+ )
+
+ response = {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": result
+ }
+
+ return JSONResponse(
+ content=response,
+ headers={"X-Session-Id": session_id}
+ )
+
+ except Exception as e:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "jsonrpc": "2.0",
+ "id": body.get("id") if "body" in locals() else None,
+ "error": {
+ "code": -32603,
+ "message": "Internal error",
+ "data": str(e)
+ }
+ }
+ )
+
+ # Create test client
+ client = TestClient(app)
+
+ # Test initialize
+ response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "1.0"}
+ },
+ "id": 1
+ },
+ headers={"X-Session-Id": "test-session"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["result"]["protocolVersion"] == "2024-11-05"
+ assert "test-session" in sessions
+
+ # Test tools/list without session
+ response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/list",
+ "params": {},
+ "id": 2
+ },
+ headers={"X-Session-Id": "new-session"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["message"] == "Session not initialized"
+
+ # Test tools/list with session
+ response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/list",
+ "params": {},
+ "id": 3
+ },
+ headers={"X-Session-Id": "test-session"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["result"]["tools"]) == 5
+
+ # Test tools/call get_stats
+ response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "get_stats",
+ "arguments": {}
+ },
+ "id": 4
+ },
+ headers={"X-Session-Id": "test-session"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ stats = json.loads(data["result"]["content"][0]["text"])
+ assert stats["total"] == 4
+
+ # Test unknown method
+ response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "method": "unknown/method",
+ "params": {},
+ "id": 5
+ },
+ headers={"X-Session-Id": "test-session"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert "Unknown method" in data["error"]["message"]
+
+ # Clean up
+ os.unlink(db_path)
From 9b8ef69f6947e9b0eced14b64253911940829865 Mon Sep 17 00:00:00 2001
From: tech4242 <5933291+tech4242@users.noreply.github.com>
Date: Mon, 28 Jul 2025 21:30:16 +0200
Subject: [PATCH 04/16] fix: tests, stdio
---
README.md | 12 +-
examples/stdio_client.py | 207 ++++++++++++++++++++
mcphawk/cli.py | 90 ++++++---
requirements-dev.txt | 3 +-
tests/test_cli.py | 21 +-
tests/test_mcp_http_simple.py | 138 +++++++++++++
tests/test_mcp_stdio_integration.py | 287 ++++++++++++++++++++++++++++
7 files changed, 714 insertions(+), 44 deletions(-)
create mode 100644 examples/stdio_client.py
create mode 100644 tests/test_mcp_http_simple.py
create mode 100644 tests/test_mcp_stdio_integration.py
diff --git a/README.md b/README.md
index 8153446..3f1faa6 100644
--- a/README.md
+++ b/README.md
@@ -146,12 +146,12 @@ sudo mcphawk web --port 3000 --host 0.0.0.0 --web-port 9000
sudo mcphawk sniff --port 3000 --debug
sudo mcphawk web --port 3000 --debug
-# Start MCP server with captured data (stdio transport)
-mcphawk mcp
-
-# Start MCP server with Streamable HTTP transport
+# Start MCP server with Streamable HTTP transport (default)
mcphawk mcp --transport http --mcp-port 8765
+# Start MCP server with stdio transport (for Claude Desktop integration)
+mcphawk mcp --transport stdio
+
# Start sniffer with integrated MCP server (HTTP transport)
sudo mcphawk sniff --port 3000 --with-mcp --mcp-transport http
@@ -204,12 +204,14 @@ Configure in Claude Desktop settings:
"mcpServers": {
"mcphawk": {
"command": "mcphawk",
- "args": ["mcp"]
+ "args": ["mcp", "--transport", "stdio"]
}
}
}
```
+See [examples/stdio_client.py](examples/stdio_client.py) for a complete working example of stdio communication.
+
## Platform Support
### Tested Platforms
diff --git a/examples/stdio_client.py b/examples/stdio_client.py
new file mode 100644
index 0000000..b483088
--- /dev/null
+++ b/examples/stdio_client.py
@@ -0,0 +1,207 @@
+#!/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/mcphawk/cli.py b/mcphawk/cli.py
index 218fd66..ea38365 100644
--- a/mcphawk/cli.py
+++ b/mcphawk/cli.py
@@ -13,6 +13,9 @@
# Suppress Scapy warnings about network interfaces
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
+# Setup logger for CLI
+logger = logging.getLogger("mcphawk.cli")
+
# ✅ Typer multi-command app
app = typer.Typer(help="MCPHawk: Passive MCP traffic sniffer + dashboard")
@@ -31,13 +34,20 @@ def sniff(
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('[MCPHawk] %(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]):
- print("[ERROR] You must specify either --port, --filter, or --auto-detect")
- print("Examples:")
- print(" mcphawk sniff --port 3000")
- print(" mcphawk sniff --filter 'tcp port 3000 or tcp port 3001'")
- print(" mcphawk sniff --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)
if filter:
@@ -49,7 +59,7 @@ def sniff(
else:
# Auto-detect mode - capture all TCP traffic
filter_expr = "tcp"
- print("[MCPHawk] Auto-detect mode: monitoring all TCP traffic for MCP messages")
+ logger.info("Auto-detect mode: monitoring all TCP traffic for MCP messages")
# Start MCP server if requested
mcp_thread = None
@@ -58,20 +68,20 @@ def sniff(
server = MCPHawkServer()
if mcp_transport == "http":
- print(f"[MCPHawk] Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
+ logger.info(f"Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
excluded_ports = [mcp_port] # Exclude MCP port from sniffing
def run_mcp():
asyncio.run(server.run_http(port=mcp_port))
else:
- print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
+ logger.info("Starting MCP server on stdio (configure in your MCP client)")
def run_mcp():
asyncio.run(server.run_stdio())
mcp_thread = threading.Thread(target=run_mcp, daemon=True)
mcp_thread.start()
- print(f"[MCPHawk] Starting sniffer with filter: {filter_expr}")
- print("[MCPHawk] Press Ctrl+C to stop...")
+ logger.info(f"Starting sniffer with filter: {filter_expr}")
+ logger.info("Press Ctrl+C to stop...")
try:
start_sniffer(
@@ -81,7 +91,7 @@ def run_mcp():
excluded_ports=excluded_ports
)
except KeyboardInterrupt:
- print("\n[MCPHawk] Sniffer stopped.")
+ logger.info("Sniffer stopped.")
sys.exit(0)
@@ -99,14 +109,21 @@ def web(
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('[MCPHawk] %(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]):
- print("[ERROR] You must specify either --port, --filter, or --auto-detect (or use --no-sniffer)")
- print("Examples:")
- print(" mcphawk web --port 3000")
- print(" mcphawk web --filter 'tcp port 3000 or tcp port 3001'")
- print(" mcphawk web --auto-detect")
- print(" mcphawk web --no-sniffer # View historical logs only")
+ 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)
# Prepare filter expression
@@ -126,12 +143,12 @@ def web(
server = MCPHawkServer()
if mcp_transport == "http":
- print(f"[MCPHawk] Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
+ logger.info(f"Starting MCP HTTP server on http://localhost:{mcp_port}/mcp")
excluded_ports = [mcp_port] # Exclude MCP port from sniffing
def run_mcp():
asyncio.run(server.run_http(port=mcp_port))
else:
- print("[MCPHawk] Starting MCP server on stdio (configure in your MCP client)")
+ logger.info("Starting MCP server on stdio (configure in your MCP client)")
def run_mcp():
asyncio.run(server.run_stdio())
@@ -157,12 +174,25 @@ def mcp(
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug output")
):
"""Run MCPHawk MCP server standalone (query existing captured data)."""
- print(f"[MCPHawk] Starting MCP server (transport: {transport})")
+ # 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)
+
+ handler.setFormatter(logging.Formatter('[MCPHawk] %(message)s'))
+ logger.addHandler(handler)
+ logger.setLevel(logging.DEBUG if debug else logging.INFO)
+
+ logger.info(f"Starting MCP server (transport: {transport})")
if transport == "stdio":
- print("[MCPHawk] Use this server with MCP clients by configuring stdio transport")
- print("[MCPHawk] Example MCP client configuration:")
- print("""
+ logger.debug("Use this server with MCP clients by configuring stdio transport")
+ logger.debug("Example MCP client configuration:")
+ logger.debug("""
{
"mcpServers": {
"mcphawk": {
@@ -173,11 +203,11 @@ def mcp(
}
""")
elif transport == "http":
- print(f"[MCPHawk] MCP server will listen on http://localhost:{mcp_port}/mcp")
- print("[MCPHawk] Example test command:")
- print(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}}'")
+ 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:
- print(f"[ERROR] Unknown transport: {transport}")
+ logger.error(f"Unknown transport: {transport}")
raise typer.Exit(1)
# Create and run MCP server
@@ -189,5 +219,9 @@ def mcp(
elif transport == "http":
asyncio.run(server.run_http(port=mcp_port))
except KeyboardInterrupt:
- print("\n[MCPHawk] MCP server stopped.")
+ logger.info("MCP server stopped.")
sys.exit(0)
+
+
+if __name__ == "__main__":
+ app()
diff --git a/requirements-dev.txt b/requirements-dev.txt
index d6ff5d5..a0a5e1f 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -4,4 +4,5 @@ pytest>=8.0.0
pytest-asyncio==1.1.0
pytest-cov>=5.0.0
ruff>=0.8.0
-websockets>=12.0
\ No newline at end of file
+websockets>=12.0
+requests>=2.31.0
\ No newline at end of file
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d67c772..1a90c90 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -46,7 +46,7 @@ def test_sniff_command_requires_flags():
"""Test sniff command requires port, filter, or auto-detect."""
result = runner.invoke(app, ["sniff"])
assert result.exit_code == 1
- assert "[ERROR] You must specify either --port, --filter, or --auto-detect" in result.stdout
+ 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
@@ -72,8 +72,9 @@ def test_mcp_command_stdio_transport():
result = runner.invoke(app, ["mcp", "--transport", "stdio"])
# Check output
- assert "[MCPHawk] Starting MCP server (transport: stdio)" in result.stdout
- assert "mcpServers" in result.stdout
+ 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()
@@ -95,9 +96,9 @@ def test_mcp_command_http_transport():
result = runner.invoke(app, ["mcp", "--transport", "http", "--mcp-port", "8765"])
# Check output
- assert "[MCPHawk] Starting MCP server (transport: http)" in result.stdout
+ assert "Starting MCP server (transport: http)" in result.stdout
assert "http://localhost:8765/mcp" in result.stdout
- assert "curl -X POST" 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()
@@ -113,7 +114,7 @@ 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 "[ERROR] Unknown transport: websocket" in result.stdout
+ assert "Unknown transport: websocket" in result.stdout
def test_sniff_with_mcp_http():
@@ -200,7 +201,7 @@ def test_mcp_command_custom_port():
result = runner.invoke(app, ["mcp", "--transport", "http", "--mcp-port", "9999"])
# Check output shows custom port
- assert "[MCPHawk] Starting MCP server (transport: http)" in result.stdout
+ assert "Starting MCP server (transport: http)" in result.stdout
assert "http://localhost:9999/mcp" in result.stdout
# Verify server was created
@@ -252,9 +253,9 @@ def test_mcp_stdio_ignores_port():
result = runner.invoke(app, ["mcp", "--transport", "stdio", "--mcp-port", "9999"])
# Check output doesn't mention the port
- assert "[MCPHawk] Starting MCP server (transport: stdio)" in result.stdout
+ assert "Starting MCP server (transport: stdio)" in result.stdout
assert "9999" not in result.stdout
- assert "mcpServers" in result.stdout
+ # mcpServers only shows in debug output
# Verify run_stdio was called (not run_http)
assert mock_server_instance.run_stdio.called
@@ -337,7 +338,7 @@ 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 "[ERROR] You must specify either --port, --filter, or --auto-detect (or use --no-sniffer)" in result.stdout
+ 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
diff --git a/tests/test_mcp_http_simple.py b/tests/test_mcp_http_simple.py
new file mode 100644
index 0000000..92483aa
--- /dev/null
+++ b/tests/test_mcp_http_simple.py
@@ -0,0 +1,138 @@
+"""Simple HTTP integration tests for MCP server."""
+
+import json
+import subprocess
+import sys
+import time
+
+import pytest
+import requests
+
+
+class TestMCPHTTPSimple:
+ """Test MCP server HTTP transport with subprocess."""
+
+ @pytest.fixture
+ def mcp_server_url(self):
+ """Start MCP server in subprocess and return URL."""
+ # Start server
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "mcphawk.cli", "mcp", "--transport", "http", "--mcp-port", "8768"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True
+ )
+
+ # Give server time to start
+ time.sleep(2)
+
+ # Check if server started
+ assert proc.poll() is None, "Server failed to start"
+
+ yield "http://localhost:8768/mcp"
+
+ # Cleanup
+ proc.terminate()
+ proc.wait()
+
+ def test_http_basic_flow(self, mcp_server_url):
+ """Test basic HTTP flow: initialize, list tools, call tool."""
+ session_id = "test-session-basic"
+
+ # 1. Initialize
+ response = requests.post(
+ mcp_server_url,
+ json={
+ "jsonrpc": "2.0",
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": {"name": "test-http", "version": "1.0"}
+ },
+ "id": 1
+ },
+ headers={"X-Session-Id": session_id}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == 1
+ assert data["result"]["protocolVersion"] == "2024-11-05"
+
+ # 2. List tools
+ response = requests.post(
+ mcp_server_url,
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/list",
+ "params": {},
+ "id": 2
+ },
+ headers={"X-Session-Id": session_id}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == 2
+ assert len(data["result"]["tools"]) == 5
+
+ # 3. Call get_stats
+ response = requests.post(
+ mcp_server_url,
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "get_stats",
+ "arguments": {}
+ },
+ "id": 3
+ },
+ headers={"X-Session-Id": session_id}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == 3
+ assert "content" in data["result"]
+
+ # Parse stats
+ stats = json.loads(data["result"]["content"][0]["text"])
+ assert "total" in stats
+ assert "requests" in stats
+
+ def test_http_error_handling(self, mcp_server_url):
+ """Test HTTP error handling."""
+ # Try to call tool without initialization
+ response = requests.post(
+ mcp_server_url,
+ json={
+ "jsonrpc": "2.0",
+ "method": "tools/list",
+ "params": {},
+ "id": 1
+ },
+ headers={"X-Session-Id": "uninitialized"}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert "not initialized" in data["error"]["message"]
+
+ # Unknown method
+ response = requests.post(
+ mcp_server_url,
+ json={
+ "jsonrpc": "2.0",
+ "method": "unknown/method",
+ "params": {},
+ "id": 2
+ }
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert "Unknown method" in data["error"]["message"]
diff --git a/tests/test_mcp_stdio_integration.py b/tests/test_mcp_stdio_integration.py
new file mode 100644
index 0000000..58c7331
--- /dev/null
+++ b/tests/test_mcp_stdio_integration.py
@@ -0,0 +1,287 @@
+"""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),
+ "traffic_type": "TCP/Direct"
+ }
+ 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 "[MCPHawk]" in stderr
+ 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)
From 3328660e82970c623294bcf5c5e721533222a8be Mon Sep 17 00:00:00 2001
From: tech4242 <5933291+tech4242@users.noreply.github.com>
Date: Mon, 28 Jul 2025 21:42:10 +0200
Subject: [PATCH 05/16] fix: README
---
README.md | 6 ++++--
examples/branding/mcphawk_claudedesktop.png | Bin 0 -> 339085 bytes
2 files changed, 4 insertions(+), 2 deletions(-)
create mode 100644 examples/branding/mcphawk_claudedesktop.png
diff --git a/README.md b/README.md
index 3f1faa6..037db2b 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ MCPHawk is a passive sniffer for **Model Context Protocol (MCP)** traffic, simil
- MCPHawk can reconstruct full JSON-RPC messages from raw TCP traffic without requiring a handshake.
- It captures traffic "on the wire" between any MCP client and server—does not require client/server modification.
-

+

## Features
@@ -166,6 +166,8 @@ MCPHawk includes an MCP server that allows you to query captured traffic using t
- Integrating traffic analysis into your MCP-enabled tools
- Programmatically searching and filtering captured data
+

+
### Available MCP Tools
- **query_traffic** - Fetch logs with pagination (limit/offset)
@@ -251,6 +253,7 @@ sudo mcphawk web --auto-detect
Vote for features by opening a GitHub issue!
- [x] **Auto-detect MCP traffic** - Automatically discover MCP traffic on any port without prior configuration
+- [x] **MCP Server Interface** - Expose captured traffic via MCP server for AI agents to query and analyze traffic patterns
- [ ] **Protocol Version Detection** - Identify and display MCP protocol version from captured traffic
- [ ] **Smart Search & Filtering** - Search by method name, params, or any JSON field with regex support
- [ ] **Performance Analytics** - Request/response timing, method frequency charts, and latency distribution
@@ -261,7 +264,6 @@ Vote for features by opening a GitHub issue!
- [ ] **Interactive Replay** - Click any request to re-send it, edit and replay captured messages
- [ ] **Real-time Alerts** - Alert on specific methods or error patterns with webhook support
- [ ] **Visualization** - Sequence diagrams, resource heat maps, method dependency graphs
-- [x] **MCP Server Interface** - Expose captured traffic via MCP server for AI agents to query and analyze traffic patterns
... and a few more off the deep end:
- [ ] **TLS/HTTPS Support (MITM Proxy Mode)** - Optional man-in-the-middle proxy with certificate installation for encrypted traffic
diff --git a/examples/branding/mcphawk_claudedesktop.png b/examples/branding/mcphawk_claudedesktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..bf475321fe37de120dae59acf3ffaf68687b402b
GIT binary patch
literal 339085
zcma%jXH-+$);20Ciqe}@0g(
Ll
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