package com.mobilehackinglab.jadxplugin;
import jadx.api.*;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.JadxPluginContext;
import jadx.api.plugins.JadxPluginInfo;
import jadx.core.xmlgen.ResContainer;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class McpPlugin implements JadxPlugin {
public static final String PLUGIN_ID = "jadx-mcp";
private ServerSocket serverSocket;
private ExecutorService executor;
private JadxPluginContext context;
private McpPluginOptions pluginOptions;
private boolean running = false;
public McpPlugin() {
}
/**
* Called by Jadx to initialize the plugin.
*/
@Override
public void init(JadxPluginContext context) {
this.context = context;
this.pluginOptions = new McpPluginOptions();
this.context.registerOptions(this.pluginOptions);
new Thread(this::safePluginStartup).start();
}
/**
* Provides metadata for the plugin to Jadx.
*/
@Override
public JadxPluginInfo getPluginInfo() {
return new JadxPluginInfo(
PLUGIN_ID,
"JADX MCP Plugin",
"Exposes Jadx info over HTTP",
"https://github.com/mobilehackinglab/jadx-mcp-plugin",
"1.4.0");
}
/**
* Starts the HTTP server if Jadx is ready.
*/
private void safePluginStartup() {
if (!waitForJadxLoad()) {
System.err.println("[MCP] Jadx initialization failed. Not starting server.");
return;
}
try {
URL httpInterface = parseHttpInterface(pluginOptions.getHttpInterface());
startServer(httpInterface);
System.out.println("[MCP] Server started successfully at " + httpInterface.getProtocol() + "://" + httpInterface.getHost() + ":" + httpInterface.getPort());
} catch (IOException | IllegalArgumentException e) {
System.err.println("[MCP] Failed to start server: " + e.getMessage());
}
}
/**
* Waits for the Jadx decompiler to finish loading classes.
*/
private boolean waitForJadxLoad() {
int retries = 0;
while (retries < 30) {
if (isDecompilerValid()) {
int count = context.getDecompiler().getClassesWithInners().size();
System.out.println("[MCP] Jadx fully loaded. Classes found: " + count);
return true;
}
System.out.println("[MCP] Waiting for Jadx to finish loading classes...");
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
retries++;
}
System.err.println("[MCP] Jadx failed to load classes within expected time.");
return false;
}
/**
* Validates that the decompiler is loaded and usable.
* This is needed because: When you use "File → Open" to load a new file,
* Jadx replaces the internal decompiler instance, but your plugin still holds a
* stale reference to the old one.
*/
private boolean isDecompilerValid() {
try {
return context != null
&& context.getDecompiler() != null
&& context.getDecompiler().getRoot() != null
&& !context.getDecompiler().getClassesWithInners().isEmpty();
} catch (Exception e) {
return false;
}
}
/**
* Parses and validates the given HTTP interface string.
*
* <p>This method strictly enforces that the input must be a complete HTTP URL
* with a protocol of {@code http}, a non-empty host, and an explicit port.</p>
*
* <p>Examples of valid input: {@code http://127.0.0.1:8080}, {@code http://localhost:3000}</p>
*
* @param httpInterface A string representing the HTTP interface.
* @return A {@link URL} object representing the parsed interface.
* @throws IllegalArgumentException if the URL is malformed or missing required components.
*/
private URL parseHttpInterface(String httpInterface) throws IllegalArgumentException {
URL url;
try {
url = new URL(httpInterface);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Malformed HTTP interface URL: " + httpInterface, e);
}
if (!"http".equalsIgnoreCase(url.getProtocol())) {
throw new IllegalArgumentException("Invalid protocol: " + url.getProtocol() + ". Only 'http' is supported.");
}
if (url.getHost() == null || url.getHost().isEmpty()) {
throw new IllegalArgumentException("Missing or invalid host in HTTP interface: " + httpInterface);
}
if (url.getPort() == -1) {
throw new IllegalArgumentException("Port must be explicitly specified in HTTP interface: " + httpInterface);
}
if (url.getPath() != null && !url.getPath().isEmpty() && !url.getPath().equals("/")) {
throw new IllegalArgumentException("Path is not allowed in HTTP interface: " + httpInterface);
}
if (url.getQuery() != null || url.getRef() != null || url.getUserInfo() != null) {
throw new IllegalArgumentException("HTTP interface must not contain query, fragment, or user info: " + httpInterface);
}
return url;
}
/**
* Starts the TCP server and accepts incoming connections.
*/
private void startServer(URL httpInterface) throws IOException {
String host = httpInterface.getHost();
int port = httpInterface.getPort();
InetAddress bindAddr = InetAddress.getByName(host);
serverSocket = new ServerSocket(port, 50, bindAddr);
executor = Executors.newFixedThreadPool(5);
running = true;
new Thread(() -> {
while (running) {
try {
Socket clientSocket = serverSocket.accept();
executor.submit(() -> handleConnection(clientSocket));
} catch (IOException e) {
if (running) {
System.err.println("[MCP] Error accepting connection: " + e.getMessage());
}
}
}
}).start();
}
/**
* Handles incoming HTTP requests to the plugin.
*/
private void handleConnection(Socket socket) {
try (socket;
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
OutputStream outStream = socket.getOutputStream()) {
String requestLine = in.readLine();
if (requestLine == null) {
return;
}
String[] parts = requestLine.split(" ");
if (parts.length < 2) {
return;
}
String method = parts[0];
String path = parts[1];
int contentLength = 0;
String header;
while ((header = in.readLine()) != null && !header.isEmpty()) {
if (header.toLowerCase().startsWith("content-length:")) {
contentLength = Integer.parseInt(header.substring("content-length:".length()).trim());
}
}
String body = "";
if (contentLength > 0) {
char[] buffer = new char[contentLength];
int bytesRead = in.read(buffer);
body = new String(buffer, 0, bytesRead);
}
JSONObject responseJson;
if ("/invoke".equals(path) && "POST".equalsIgnoreCase(method)) {
responseJson = processInvokeRequest(body);
} else if ("/tools".equals(path)) {
responseJson = getToolsJson();
} else {
responseJson = errorJson("Not found");
}
byte[] respBytes = responseJson.toString(2).getBytes(StandardCharsets.UTF_8);
PrintWriter out = new PrintWriter(outStream, true);
out.printf(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
respBytes.length);
out.flush();
outStream.write(respBytes);
outStream.flush();
} catch (Exception e) {
System.err.println("[MCP] Error handling connection: " + e.getMessage());
// At this point, response may be partially written; best-effort logging only.
}
}
/**
* Handles tool invocation from the client, routing to the correct handler.
*
* @param requestBody JSON request with tool and parameters
* @return JSON response object
*/
private JSONObject processInvokeRequest(String requestBody) {
try {
JSONObject requestJson = new JSONObject(requestBody);
String toolName = requestJson.optString("tool", null);
if (toolName == null || toolName.isEmpty()) {
return errorJson("Missing required field 'tool'");
}
JSONObject params = requestJson.optJSONObject("parameters");
if (params == null) {
params = new JSONObject();
}
return switch (toolName) {
// 1) Manifest
case "get_android_manifest" -> handleGetAndroidManifest();
// 2) Discover classes
case "list_all_classes" -> handleListAllClasses(params);
// 3) Search classes
case "search_class_by_name" -> handleSearchClassByName(params);
// 4) Inspect a class
case "get_class_source" -> handleGetClassSource(params);
case "get_methods_of_class" -> handleGetMethodsOfClass(params);
case "get_fields_of_class" -> handleGetFieldsOfClass(params);
// 5) Search methods
case "search_method_by_name" -> handleSearchMethodByName(params);
// 6) Inspect a specific method
case "get_method_code" -> handleGetMethodCode(params);
default -> errorJson("Unknown tool: " + toolName);
};
} catch (JSONException e) {
return errorJson("Invalid JSON in request body: " + e.getMessage());
} catch (Exception e) {
return errorJson("Unexpected error while processing request: " + e.getMessage());
}
}
/**
* Return available tools for MCP server in JSON.
*/
private JSONObject getToolsJson() {
JSONArray tools = new JSONArray();
// 1) Get Manifest
tools.put(new JSONObject()
.put("name", "get_android_manifest")
.put("description", "Returns the content of AndroidManifest.xml if available.")
.put("parameters", new JSONObject()));
// 2) Discover classes
tools.put(new JSONObject()
.put("name", "list_all_classes")
.put("description", "Returns a list of all class names.")
.put("parameters", new JSONObject()
.put("offset", "int")
.put("limit", "int")));
// 3) Search classes
tools.put(new JSONObject()
.put("name", "search_class_by_name")
.put("description", "Search class names containing a keyword.")
.put("parameters", new JSONObject().put("query", "string")));
// 4) Inspect a class
tools.put(new JSONObject()
.put("name", "get_class_source")
.put("description", "Returns the decompiled source of a class.")
.put("parameters", new JSONObject().put("class_name", "string")));
tools.put(new JSONObject()
.put("name", "get_methods_of_class")
.put("description", "Returns all method names of a class.")
.put("parameters", new JSONObject().put("class_name", "string")));
tools.put(new JSONObject()
.put("name", "get_fields_of_class")
.put("description", "Returns all field names of a class.")
.put("parameters", new JSONObject().put("class_name", "string")));
// 5) Search methods
tools.put(new JSONObject()
.put("name", "search_method_by_name")
.put("description", "Search methods by name.")
.put("parameters", new JSONObject().put("method_name", "string")));
// 6) Inspect a specific method
tools.put(new JSONObject()
.put("name", "get_method_code")
.put("description", "Returns the code for a specific method.")
.put("parameters", new JSONObject()
.put("class_name", "string")
.put("method_name", "string")));
return new JSONObject().put("tools", tools);
}
/**
* Small helper to create a standard error JSON object.
*/
private JSONObject errorJson(String message) {
JSONObject obj = new JSONObject();
obj.put("error", message);
return obj;
}
/**
* Retrieves the content of AndroidManifest.xml
* <p>
* This extracts the decoded XML from the ResContainer returned by loadContent().
*
* @return The manifest XML as a string or an error message.
*/
private JSONObject handleGetAndroidManifest() {
try {
for (ResourceFile resFile : context.getDecompiler().getResources()) {
if (resFile.getType() == ResourceType.MANIFEST) {
ResContainer container = resFile.loadContent();
if (container.getText() != null) {
String manifestCode = container.getText().getCodeStr();
return new JSONObject()
.put("manifest", manifestCode);
} else {
return errorJson("Manifest content is empty or could not be decoded.");
}
}
}
return errorJson("AndroidManifest.xml not found.");
} catch (Exception e) {
return errorJson("Error retrieving AndroidManifest.xml: " + e.getMessage());
}
}
/**
* Lists all classes with optional pagination.
*
* @param params JSON with optional offset and limit
* @return JSON response with class list and metadata
*/
private JSONObject handleListAllClasses(JSONObject params) {
int offset = params.optInt("offset", 0);
int limit = params.optInt("limit", 250);
int maxLimit = 500;
if (limit > maxLimit) {
limit = maxLimit;
}
List<JavaClass> allClasses = context.getDecompiler().getClassesWithInners();
int total = allClasses.size();
JSONArray array = new JSONArray();
for (int i = offset; i < Math.min(offset + limit, total); i++) {
JavaClass cls = allClasses.get(i);
array.put(cls.getFullName());
}
return new JSONObject()
.put("total", total)
.put("offset", offset)
.put("limit", limit)
.put("classes", array);
}
/**
* Search class names based on a partial query string and return matches.
*
* @param params JSON object with key "query"
* @return JSON object with array of matched class names under "results"
*/
private JSONObject handleSearchClassByName(JSONObject params) {
String query = params.optString("query", "").toLowerCase();
JSONArray array = new JSONArray();
for (JavaClass cls : context.getDecompiler().getClassesWithInners()) {
String fullName = cls.getFullName();
if (fullName.toLowerCase().contains(query)) {
array.put(fullName);
}
}
return new JSONObject()
.put("query", query)
.put("results", array);
}
/**
* Retrieves the full decompiled source code of a specific Java class.
*
* @param params A JSON object containing the required parameter:
* - "class_name": The fully qualified name of the class to
* retrieve.
*/
private JSONObject handleGetClassSource(JSONObject params) {
String className = params.optString("class_name", null);
if (className == null || className.isEmpty()) {
return errorJson("Missing required parameter 'class_name'");
}
try {
for (JavaClass cls : context.getDecompiler().getClasses()) {
if (cls.getFullName().equals(className)) {
String code = cls.getCode();
return new JSONObject()
.put("class_name", className)
.put("source", code);
}
}
return errorJson("Class not found: " + className);
} catch (Exception e) {
return errorJson("Error fetching class: " + e.getMessage());
}
}
/**
* Retrieves a list of all method names declared in the specified Java class.
*
* @param params A JSON object containing the required parameter:
* - "class_name": The fully qualified name of the class.
*/
private JSONObject handleGetMethodsOfClass(JSONObject params) {
String className = params.optString("class_name", null);
if (className == null || className.isEmpty()) {
return errorJson("Missing required parameter 'class_name'");
}
try {
for (JavaClass cls : context.getDecompiler().getClasses()) {
if (cls.getFullName().equals(className)) {
JSONArray array = new JSONArray();
for (JavaMethod method : cls.getMethods()) {
array.put(method.getName());
}
return new JSONObject()
.put("class_name", className)
.put("methods", array);
}
}
return errorJson("Class not found: " + className);
} catch (Exception e) {
return errorJson("Error fetching methods: " + e.getMessage());
}
}
/**
* Retrieves all field names defined in the specified Java class.
*
* @param params A JSON object containing the required parameter:
* - "class_name": The fully qualified name of the class.
*/
private JSONObject handleGetFieldsOfClass(JSONObject params) {
String className = params.optString("class_name", null);
if (className == null || className.isEmpty()) {
return errorJson("Missing required parameter 'class_name'");
}
try {
for (JavaClass cls : context.getDecompiler().getClasses()) {
if (cls.getFullName().equals(className)) {
JSONArray array = new JSONArray();
for (JavaField field : cls.getFields()) {
array.put(field.getName());
}
return new JSONObject()
.put("class_name", className)
.put("fields", array);
}
}
return errorJson("Class not found: " + className);
} catch (Exception e) {
return errorJson("Error fetching fields: " + e.getMessage());
}
}
/**
* Searches all decompiled classes for methods whose names match or contain the
* provided string.
*
* @param params A JSON object containing the required parameter:
* - "method_name": A case-insensitive string to match method
* names.
*/
private JSONObject handleSearchMethodByName(JSONObject params) {
String methodName = params.optString("method_name", null);
if (methodName == null || methodName.isEmpty()) {
return errorJson("Missing required parameter 'method_name'");
}
try {
JSONArray results = new JSONArray();
for (JavaClass cls : context.getDecompiler().getClasses()) {
cls.decompile();
for (JavaMethod method : cls.getMethods()) {
if (method.getName().toLowerCase().contains(methodName.toLowerCase())) {
JSONObject entry = new JSONObject()
.put("class_name", cls.getFullName())
.put("method_name", method.getName());
results.put(entry);
}
}
}
JSONObject response = new JSONObject()
.put("query", methodName)
.put("results", results);
if (results.isEmpty()) {
response.put("message", "No methods found for: " + methodName);
}
return response;
} catch (Exception e) {
return errorJson("Error searching methods: " + e.getMessage());
}
}
/**
* Extracts the decompiled source code of a specific method within a given class.
*
* @param params A JSON object containing:
* - "class_name": The fully qualified name of the class.
* - "method_name": The name of the method to extract.
*/
private JSONObject handleGetMethodCode(JSONObject params) {
String className = params.optString("class_name", null);
String methodName = params.optString("method_name", null);
if (className == null || className.isEmpty()) {
return errorJson("Missing required parameter 'class_name'");
}
if (methodName == null || methodName.isEmpty()) {
return errorJson("Missing required parameter 'method_name'");
}
try {
for (JavaClass cls : context.getDecompiler().getClasses()) {
if (cls.getFullName().equals(className)) {
cls.decompile();
for (JavaMethod method : cls.getMethods()) {
if (method.getName().equals(methodName)) {
String methodCode = method.getCodeStr();
if (methodCode == null || methodCode.trim().isEmpty()) {
String classCode = cls.getCode();
String extracted = MethodExtractor.extract(method, classCode);
if (extracted != null && !extracted.trim().isEmpty()) {
return new JSONObject()
.put("class_name", className)
.put("method_name", methodName)
.put("code", extracted);
}
}
return new JSONObject()
.put("class_name", className)
.put("method_name", methodName)
.put("code", methodCode);
}
}
return errorJson("Method '" + methodName + "' not found in class '" + className + "'");
}
}
return errorJson("Class '" + className + "' not found");
} catch (Exception e) {
return errorJson("Error fetching method code: " + e.getMessage());
}
}
}