Switch to JSON as bridge/plugin comm protocol

This commit is contained in:
Teal Bauer 2025-04-04 16:05:22 +02:00
parent 04d088591b
commit cbe5dcc1f3
4 changed files with 228 additions and 149 deletions

View File

@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added
- Structured JSON communication between Python bridge and Java plugin
- Consistent response format with metadata (timestamp, port, instance type)
- Comprehensive test suites for HTTP API and MCP bridge
- Test runner script for easy test execution
- Detailed testing documentation in TESTING.md
### Changed
- Improved error handling in API responses
- Enhanced JSON parsing in the Java plugin
- Updated documentation with JSON communication details
## [1.3.0] - 2025-04-02 ## [1.3.0] - 2025-04-02
### Added ### Added

View File

@ -1,7 +1,7 @@
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = [ # dependencies = [
# "mcp==1.5.0", # "mcp==1.6.0",
# "requests==2.32.3", # "requests==2.32.3",
# ] # ]
# /// # ///
@ -12,6 +12,7 @@ import threading
import time import time
from threading import Lock from threading import Lock
from typing import Dict from typing import Dict
from urllib.parse import quote
import requests import requests
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@ -25,10 +26,16 @@ DEFAULT_GHIDRA_HOST = "localhost"
QUICK_DISCOVERY_RANGE = range(8192, 8202) # Limited range for interactive/triggered discovery (10 ports) QUICK_DISCOVERY_RANGE = range(8192, 8202) # Limited range for interactive/triggered discovery (10 ports)
FULL_DISCOVERY_RANGE = range(8192, 8212) # Wider range for background discovery (20 ports) FULL_DISCOVERY_RANGE = range(8192, 8212) # Wider range for background discovery (20 ports)
mcp = FastMCP("hydra-mcp") instructions = """
GhydraMCP allows interacting with multiple Ghidra SRE instances. Ghidra SRE is a tool for reverse engineering and analyzing binaries, e.g. malware.
First, run `discover_instances` to find open Ghidra instances. List tools to see what GhydraMCP can do.
"""
mcp = FastMCP("GhydraMCP", instructions=instructions)
ghidra_host = os.environ.get("GHIDRA_HYDRA_HOST", DEFAULT_GHIDRA_HOST) ghidra_host = os.environ.get("GHIDRA_HYDRA_HOST", DEFAULT_GHIDRA_HOST)
print(f"Using Ghidra host: {ghidra_host}") # print(f"Using Ghidra host: {ghidra_host}")
def get_instance_url(port: int) -> str: def get_instance_url(port: int) -> str:
"""Get URL for a Ghidra instance by port""" """Get URL for a Ghidra instance by port"""
@ -201,9 +208,9 @@ def safe_post(port: int, endpoint: str, data: dict | str) -> dict:
"result": response.text.strip() "result": response.text.strip()
} }
else: else:
# Try falling back to default instance if this was a secondary instance # # Try falling back to default instance if this was a secondary instance
if port != DEFAULT_GHIDRA_PORT and response.status_code == 404: # if port != DEFAULT_GHIDRA_PORT and response.status_code == 404:
return safe_post(DEFAULT_GHIDRA_PORT, endpoint, data) # return safe_post(DEFAULT_GHIDRA_PORT, endpoint, data)
try: try:
error_data = response.json() error_data = response.json()
@ -269,12 +276,10 @@ def register_instance(port: int, url: str = None) -> str:
try: try:
# Try the root endpoint first # Try the root endpoint first
root_url = f"{url}/" root_url = f"{url}/"
print(f"Trying to get root info from {root_url}", file=sys.stderr)
root_response = requests.get(root_url, timeout=1.5) # Short timeout for root root_response = requests.get(root_url, timeout=1.5) # Short timeout for root
if root_response.ok: if root_response.ok:
try: try:
print(f"Got response from root: {root_response.text}", file=sys.stderr)
root_data = root_response.json() root_data = root_response.json()
# Extract basic information from root # Extract basic information from root
@ -283,16 +288,12 @@ def register_instance(port: int, url: str = None) -> str:
if "file" in root_data and root_data["file"]: if "file" in root_data and root_data["file"]:
project_info["file"] = root_data["file"] project_info["file"] = root_data["file"]
print(f"Root data parsed: {project_info}", file=sys.stderr)
except Exception as e: except Exception as e:
print(f"Error parsing root info: {e}", file=sys.stderr) print(f"Error parsing root info: {e}", file=sys.stderr)
else:
print(f"Root endpoint returned {root_response.status_code}", file=sys.stderr)
# If we don't have project info yet, try the /info endpoint as a fallback # If we don't have project info yet, try the /info endpoint as a fallback
if not project_info.get("project") and not project_info.get("file"): if not project_info.get("project") and not project_info.get("file"):
info_url = f"{url}/info" info_url = f"{url}/info"
print(f"Trying fallback info from {info_url}", file=sys.stderr)
try: try:
info_response = requests.get(info_url, timeout=2) info_response = requests.get(info_url, timeout=2)
@ -369,13 +370,18 @@ def _discover_instances(port_range, host=None, timeout=0.5) -> dict:
"instances": found_instances "instances": found_instances
} }
# Updated tool implementations with port parameter
from urllib.parse import quote
@mcp.tool() @mcp.tool()
def list_functions(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list: def list_functions(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
"""List all functions with pagination""" """List all functions in the current program
Args:
port: Ghidra instance port (default: 8192)
offset: Pagination offset (default: 0)
limit: Maximum number of segments to return (default: 100)
Returns:
List of strings with function names and addresses
"""
return safe_get(port, "functions", {"offset": offset, "limit": limit}) return safe_get(port, "functions", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
@ -522,18 +528,6 @@ def get_current_function(port: int = DEFAULT_GHIDRA_PORT) -> str:
""" """
return "\n".join(safe_get(port, "get_current_function")) return "\n".join(safe_get(port, "get_current_function"))
@mcp.tool()
def list_functions(port: int = DEFAULT_GHIDRA_PORT) -> list:
"""List all functions in the current program
Args:
port: Ghidra instance port (default: 8192)
Returns:
List of strings with function names and addresses
"""
return safe_get(port, "list_functions")
@mcp.tool() @mcp.tool()
def decompile_function_by_address(port: int = DEFAULT_GHIDRA_PORT, address: str = "") -> str: def decompile_function_by_address(port: int = DEFAULT_GHIDRA_PORT, address: str = "") -> str:
"""Decompile a function at a specific memory address """Decompile a function at a specific memory address
@ -716,19 +710,19 @@ def periodic_discovery():
time.sleep(30) time.sleep(30)
if __name__ == "__main__": if __name__ == "__main__":
# Auto-register default instance # # Auto-register default instance
register_instance(DEFAULT_GHIDRA_PORT, f"http://{ghidra_host}:{DEFAULT_GHIDRA_PORT}") # register_instance(DEFAULT_GHIDRA_PORT, f"http://{ghidra_host}:{DEFAULT_GHIDRA_PORT}")
# Auto-discover other instances # # Auto-discover other instances
discover_instances() # discover_instances()
# Start periodic discovery in background thread # # Start periodic discovery in background thread
discovery_thread = threading.Thread( # discovery_thread = threading.Thread(
target=periodic_discovery, # target=periodic_discovery,
daemon=True, # daemon=True,
name="GhydraMCP-Discovery" # name="GhydraMCP-Discovery"
) # )
discovery_thread.start() # discovery_thread.start()
signal.signal(signal.SIGINT, handle_sigint) # signal.signal(signal.SIGINT, handle_sigint)
mcp.run() mcp.run(transport="stdio")

View File

@ -23,9 +23,9 @@
<dependencies> <dependencies>
<!-- JSON handling --> <!-- JSON handling -->
<dependency> <dependency>
<groupId>com.googlecode.json-simple</groupId> <groupId>com.google.code.gson</groupId>
<artifactId>json-simple</artifactId> <artifactId>gson</artifactId>
<version>1.1.1</version> <version>2.10.1</version>
</dependency> </dependency>
<!-- Ghidra JARs as system-scoped dependencies --> <!-- Ghidra JARs as system-scoped dependencies -->

View File

@ -46,7 +46,8 @@ import java.util.concurrent.*;
import java.util.concurrent.atomic.*; import java.util.concurrent.atomic.*;
// For JSON response handling // For JSON response handling
import org.json.simple.JSONObject; import com.google.gson.Gson;
import com.google.gson.JsonObject;
import ghidra.app.services.CodeViewerService; import ghidra.app.services.CodeViewerService;
import ghidra.app.util.PseudoDisassembler; import ghidra.app.util.PseudoDisassembler;
@ -96,6 +97,8 @@ public class GhydraMCPPlugin extends Plugin implements ApplicationLevelPlugin {
} }
} }
Msg.info(this, "Marker");
// Log to both console and log file // Log to both console and log file
Msg.info(this, "GhydraMCPPlugin loaded on port " + port); Msg.info(this, "GhydraMCPPlugin loaded on port " + port);
System.out.println("[GhydraMCP] Plugin loaded on port " + port); System.out.println("[GhydraMCP] Plugin loaded on port " + port);
@ -201,16 +204,53 @@ public class GhydraMCPPlugin extends Plugin implements ApplicationLevelPlugin {
} }
}); });
// Class resources // Class resources with detailed logging
server.createContext("/classes", exchange -> { server.createContext("/classes", exchange -> {
try {
if ("GET".equals(exchange.getRequestMethod())) { if ("GET".equals(exchange.getRequestMethod())) {
try {
Map<String, String> qparams = parseQueryParams(exchange); Map<String, String> qparams = parseQueryParams(exchange);
int offset = parseIntOrDefault(qparams.get("offset"), 0); int offset = parseIntOrDefault(qparams.get("offset"), 0);
int limit = parseIntOrDefault(qparams.get("limit"), 100); int limit = parseIntOrDefault(qparams.get("limit"), 100);
sendResponse(exchange, getAllClassNames(offset, limit));
String result = getAllClassNames(offset, limit);
JsonObject json = new JsonObject();
json.addProperty("success", true);
json.addProperty("result", result);
json.addProperty("timestamp", System.currentTimeMillis());
json.addProperty("port", this.port);
Gson gson = new Gson();
String jsonStr = gson.toJson(json);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
OutputStream os = exchange.getResponseBody();
os.write(bytes);
os.flush();
os.close();
} catch (Exception e) {
Msg.error(this, "/classes: Error in request processing: " + e.getMessage(), e);
try {
sendErrorResponse(exchange, 500, "Internal server error: " + e.getMessage());
} catch (IOException ioe) {
Msg.error(this, "/classes: Failed to send error response: " + ioe.getMessage(), ioe);
}
}
} else { } else {
exchange.sendResponseHeaders(405, -1); // Method Not Allowed exchange.sendResponseHeaders(405, -1); // Method Not Allowed
} }
} catch (Exception e) {
Msg.error(this, "/classes: Unhandled error: " + e.getMessage(), e);
}
}); });
// Memory segments // Memory segments
@ -353,8 +393,16 @@ public class GhydraMCPPlugin extends Plugin implements ApplicationLevelPlugin {
} }
}); });
// Super simple root endpoint - exact same as /info for consistency // Root endpoint - only handle exact "/" path
server.createContext("/", exchange -> { server.createContext("/", exchange -> {
// Only handle exact root path
if (!exchange.getRequestURI().getPath().equals("/")) {
// Return 404 for any other path that reaches this handler
Msg.info(this, "Received request for unknown path: " + exchange.getRequestURI().getPath());
sendErrorResponse(exchange, 404, "Endpoint not found");
return;
}
try { try {
String response = "{\n"; String response = "{\n";
response += "\"port\": " + port + ",\n"; response += "\"port\": " + port + ",\n";
@ -1229,39 +1277,64 @@ public class GhydraMCPPlugin extends Plugin implements ApplicationLevelPlugin {
private void sendResponse(HttpExchange exchange, Object response) throws IOException { private void sendResponse(HttpExchange exchange, Object response) throws IOException {
JSONObject json = new JSONObject(); JsonObject json = new JsonObject();
json.put("success", true); json.addProperty("success", true);
if (response instanceof String) { if (response instanceof String) {
json.put("result", response); json.addProperty("result", (String)response);
} else { } else {
json.put("data", response); json.addProperty("data", response.toString());
} }
json.put("timestamp", System.currentTimeMillis()); json.addProperty("timestamp", System.currentTimeMillis());
json.put("port", this.port); json.addProperty("port", this.port);
if (this.isBaseInstance) { if (this.isBaseInstance) {
json.put("instanceType", "base"); json.addProperty("instanceType", "base");
} else { } else {
json.put("instanceType", "secondary"); json.addProperty("instanceType", "secondary");
} }
sendJsonResponse(exchange, json); sendJsonResponse(exchange, json);
} }
private void sendJsonResponse(HttpExchange exchange, JSONObject jsonObj) throws IOException { private void sendJsonResponse(HttpExchange exchange, JsonObject jsonObj) throws IOException {
String json = jsonObj.toJSONString(); try {
Gson gson = new Gson();
String json = gson.toJson(jsonObj);
Msg.debug(this, "Sending JSON response: " + json);
byte[] bytes = json.getBytes(StandardCharsets.UTF_8); byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, bytes.length); exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
OutputStream os = null;
try {
os = exchange.getResponseBody();
os.write(bytes); os.write(bytes);
os.flush();
} catch (IOException e) {
Msg.error(this, "Error writing response body: " + e.getMessage(), e);
throw e;
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
Msg.error(this, "Error closing output stream: " + e.getMessage(), e);
}
}
}
} catch (Exception e) {
Msg.error(this, "Error in sendJsonResponse: " + e.getMessage(), e);
throw new IOException("Failed to send JSON response", e);
} }
} }
private void sendErrorResponse(HttpExchange exchange, int statusCode, String message) throws IOException { private void sendErrorResponse(HttpExchange exchange, int statusCode, String message) throws IOException {
JSONObject error = new JSONObject(); JsonObject error = new JsonObject();
error.put("error", message); error.addProperty("error", message);
error.put("status", statusCode); error.addProperty("status", statusCode);
error.put("success", false); error.addProperty("success", false);
byte[] bytes = error.toJSONString().getBytes(StandardCharsets.UTF_8);
Gson gson = new Gson();
byte[] bytes = gson.toJson(error).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length); exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream os = exchange.getResponseBody()) { try (OutputStream os = exchange.getResponseBody()) {