# Directory Structure
```
├── .gitattributes
├── .gitignore
├── fastmcp_adapter.py
├── gradle
│ ├── libs.versions.toml
│ └── wrapper
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── img
│ └── jadx-mcp-running.png
├── plugin
│ ├── bin
│ │ └── main
│ │ └── META-INF
│ │ └── services
│ │ └── jadx.api.plugins.JadxPlugin
│ ├── build.gradle
│ └── src
│ └── main
│ ├── java
│ │ └── com
│ │ └── mobilehackinglab
│ │ └── jadxplugin
│ │ ├── McpPlugin.java
│ │ └── McpPluginOptions.java
│ └── resources
│ └── META-INF
│ └── services
│ └── jadx.api.plugins.JadxPlugin
├── README.md
├── requirements.txt
└── settings.gradle
```
# Files
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
```
1 | #
2 | # https://help.github.com/articles/dealing-with-line-endings/
3 | #
4 | # Linux start script should use lf
5 | /gradlew text eol=lf
6 |
7 | # These are Windows script files and should use crlf
8 | *.bat text eol=crlf
9 |
10 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Gradle build files
2 | .gradle/
3 | build/
4 | # keep wrapper
5 | !gradle/wrapper/gradle-wrapper.jar
6 |
7 | # Java class files
8 | *.class
9 |
10 | # Python files
11 | __pycache__/
12 |
13 | # IDE files
14 | .idea/
15 | .vscode/
16 | *.iml
17 |
18 | # Environment files
19 | venv/
20 | .env
21 |
22 | # Logs
23 | *.log
24 |
25 | # OS-specific files
26 | .DS_Store
27 | Thumbs.db
28 |
29 | # Local cache
30 | cache/
31 | *.jar
32 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | ## ⚙️ Jadx MCP Plugin — Decompiler Access for Claude via MCP
2 |
3 | This project provides a [Jadx](https://github.com/skylot/jadx) plugin written in **Java**, which exposes the **Jadx API over HTTP** — enabling live interaction through MCP clients like [Claude](https://www.anthropic.com/index/introducing-claude) via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). A lightweight [FastMCP](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#resources) adapter in Python acts as a bridge between Claude and the plugin. This enables intelligent navigation and automation of reverse engineering workflows, ideal for AI-assisted security analysis of Android apps.
4 |
5 | ---
6 |
7 | ### 🧰 Setup Instructions
8 |
9 | ```bash
10 | # Clone this repository
11 | git clone https://github.com/mobilehackinglab/jadx-mcp-plugin.git
12 | cd jadx-mcp-plugin
13 |
14 | # Create and activate a virtual environment
15 | python3 -m venv venv
16 |
17 | # Activate:
18 | source venv/bin/activate # Linux/Mac
19 | .\venv\Scripts\activate # Windows
20 | ```
21 |
22 | ### Install Python dependencies
23 | ```bash
24 | pip install -r requirements.txt
25 | ```
26 |
27 | ### 🧠 Setup Claude MCP CLient Integration
28 | To use this adapter in Claude Desktop, go to `File` -> `Settings` -> `Developer` -> `Edit Config` -> `claude_desktop_config.json` and add an MCP server pointing to the Python executable in the venv (to prevent depedency issues) and the full adapter path following below examples:
29 |
30 | Windows:
31 |
32 | ```json
33 | {
34 | "mcpServers": {
35 | "Jadx MCP Server": {
36 | "command": "C:\\Workset\\jadx-mcp-plugin\\venv\\Scripts\\python.exe",
37 | "args": ["C:\\Workset\\jadx-mcp-plugin\\fastmcp_adapter.py"]
38 | }
39 | }
40 | }
41 | ```
42 |
43 | MacOS / Linux:
44 | ```json
45 | {
46 | "mcpServers": {
47 | "Jadx MCP Server": {
48 | "command": "/Users/yourname/jadx-mcp-plugin/venv/bin/python",
49 | "args": ["/Users/yourname/jadx-mcp-plugin/fastmcp_adapter.py"]
50 | }
51 | }
52 | }
53 | ```
54 |
55 | Make sure to restart (Quit) Claude after editing the config.
56 | After restart it should look like this:
57 | 
58 |
59 | ### ✅ Usage Flow
60 |
61 | 1. Open **Jadx** with the latest plugin JAR from [the releases](https://github.com/mobilehackinglab/jadx-mcp-plugin/releases) placed in its `plugins/` folder or load it via `Plugins` -> `install plugin`.
62 | 2. Load an APK or DEX file
63 | 3. (Optional) You can specify the HTTP interface address by launching Jadx with:
64 |
65 | ```bash
66 | jadx-gui -Pjadx-mcp.http-interface=http://localhost:8085
67 | ```
68 |
69 | This is useful if you want to change the default host/port (`http://localhost:8085`).
70 |
71 | > **Note:** If you change the interface address here, make sure to also update the corresponding URL in `fastmcp_adapter.py` to match.
72 |
73 | 4. Claude will detect and activate the Jadx MCP Server tools.
74 | 5. You can now list classes, fetch source, inspect methods/fields, and extract code live.
75 |
76 | ---
77 |
78 | ## 🧪 Tools Provided
79 |
80 | | Tool | Description |
81 | |-----------------------|---------------------------------------|
82 | | `list_all_classes` | Get all decompiled class names |
83 | | `search_class_by_name` | Find classes matching a string |
84 | | `get_class_source` | Get full source of a given class |
85 | | `search_method_by_name` | Find methods matching a string |
86 | | `get_methods_of_class` | List all method names in a class |
87 | | `get_fields_of_class` | List all field names in a class |
88 | | `get_method_code` | Extract decompiled code for a method |
89 |
90 | ---
91 |
92 | ## 🛠 Development
93 |
94 | ### ☕ Java Plugin
95 |
96 | The Java plugin is located at:
97 |
98 | ```
99 | plugin/src/main/java/com/mobilehackinglab/jadxplugin/McpPlugin.java
100 | ```
101 |
102 | It uses the `JadxPlugin` API (`jadx.api.*`) to:
103 | - Load decompiled classes and methods
104 | - Serve structured data via an embedded HTTP server
105 | - Respond to `/invoke` and `/tools` endpoints
106 |
107 | #### 🚀 Automated Installation with Gradle Tasks
108 |
109 | You can use the following Gradle task to build and install the plugin in one step:
110 |
111 | ```bash
112 | ./gradlew installPlugin
113 | ```
114 |
115 | > This uses the `jadx plugins` CLI. Make sure Jadx is installed and available in your `PATH`.
116 |
117 | For other plugin-related tasks (uninstall, enable/disable), see the task definitions in [`plugin/build.gradle`](./plugin/build.gradle).
118 |
119 | #### 🔧 Manual Installation
120 |
121 | To build the plugin:
122 |
123 | ```bash
124 | ./gradlew build
125 | # Output: plugin/build/libs/jadx-mcp-plugin-<version>.jar
126 | ```
127 |
128 | Install the plugin JAR using the `jadx plugins` CLI:
129 |
130 | ```bash
131 | jadx plugins --install-jar path/to/jadx-mcp-plugin-<version>.jar
132 | ```
133 |
134 | Alternatively, place the built `.jar` file into your Jadx `plugins/` folder, typically located at: `~/.jadx/plugins/`
135 |
136 | If you place the JAR manually, you’ll also need to enable the plugin through the Jadx GUI or by running:
137 |
138 | ```bash
139 | jadx plugins --enable jadx-mcp
140 | ```
141 |
142 | ---
143 |
144 | ### Python FastMCP Adapter
145 |
146 | The adapter file is:
147 |
148 | ```
149 | fastmcp_adapter.py
150 | ```
151 |
152 | It translates Claude’s MCP tool calls into HTTP POSTs to the running Jadx plugin server. Make sure Jadx is open **before** starting Claude.
153 |
154 | ---
155 |
156 | ## 🤝 Contributing
157 |
158 | PRs, feature requests, and tool extensions are welcome!
159 | This project is maintained by [Mobile Hacking Lab](https://github.com/mobilehackinglab).
160 |
161 | ---
162 |
163 | ## 🧩 Credits
164 |
165 | - [Jadx](https://github.com/skylot/jadx)
166 | - [FastMCP](https://github.com/modelcontextprotocol/python-sdk)
167 | - [Claude by Anthropic](https://www.anthropic.com)
168 |
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | mcp
2 | requests
```
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
```
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
```
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
```toml
1 | # This file was generated by the Gradle 'init' task.
2 | # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
3 |
4 | [versions]
5 | guava = "33.0.0-jre"
6 | junit-jupiter = "5.10.2"
7 |
8 | [libraries]
9 | guava = { module = "com.google.guava:guava", version.ref = "guava" }
10 | junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
11 |
```
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
```
1 | /*
2 | * This file was generated by the Gradle 'init' task.
3 | *
4 | * The settings file is used to specify which projects to include in your build.
5 | * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.8/userguide/multi_project_builds.html in the Gradle documentation.
6 | */
7 |
8 | plugins {
9 | // Apply the foojay-resolver plugin to allow automatic download of JDKs
10 | id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
11 | }
12 |
13 | rootProject.name = 'jadx-mcp-plugin'
14 | include('plugin')
15 |
```
--------------------------------------------------------------------------------
/plugin/src/main/java/com/mobilehackinglab/jadxplugin/McpPluginOptions.java:
--------------------------------------------------------------------------------
```java
1 | package com.mobilehackinglab.jadxplugin;
2 |
3 | import jadx.api.plugins.options.OptionFlag;
4 | import jadx.api.plugins.options.impl.BasePluginOptionsBuilder;
5 |
6 | import static com.mobilehackinglab.jadxplugin.McpPlugin.PLUGIN_ID;
7 |
8 | public class McpPluginOptions extends BasePluginOptionsBuilder {
9 |
10 | private String httpInterface;
11 |
12 | @Override
13 | public void registerOptions() {
14 | strOption(PLUGIN_ID + ".http-interface")
15 | .description("interface to run mcp server on")
16 | .defaultValue("http://localhost:8085")
17 | .flags(OptionFlag.PER_PROJECT)
18 | .setter(v -> httpInterface = v);
19 | }
20 |
21 | public String getHttpInterface() {
22 | return httpInterface;
23 | }
24 |
25 | }
26 |
```
--------------------------------------------------------------------------------
/plugin/build.gradle:
--------------------------------------------------------------------------------
```
1 | plugins {
2 | id 'com.github.johnrengelman.shadow' version '8.1.1'
3 | id 'java'
4 | }
5 |
6 | group = 'jadx.plugins'
7 | version = '1.3'
8 |
9 | repositories {
10 | mavenCentral()
11 | }
12 |
13 | dependencies {
14 | implementation 'org.json:json:20231013'
15 | compileOnly("io.github.skylot:jadx-core:1.5.2")
16 | }
17 |
18 | java {
19 | toolchain {
20 | languageVersion = JavaLanguageVersion.of(17) // Also supported older Java versions
21 | }
22 | }
23 |
24 | jar {
25 | duplicatesStrategy = DuplicatesStrategy.EXCLUDE
26 | archiveBaseName = 'jadx-mcp-plugin'
27 |
28 | manifest {
29 | attributes(
30 | 'Jadx-Plugin': 'true',
31 | 'Jadx-Plugin-Class': 'com.mobilehackinglab.jadxplugin.McpPlugin',
32 | 'Jadx-Plugin-Version': '1.2.0',
33 | 'Jadx-Plugin-Id': 'jadx-mcp-plugin',
34 | 'Jadx-Plugin-Description': 'Exposes Jadx info over HTTP',
35 | 'Jadx-Plugin-Author': 'Mobile Hacking Lab'
36 | )
37 | }
38 |
39 | // This ensures all dependencies are included
40 | from {
41 | configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
42 | }
43 |
44 | // Exclude problematic files from dependencies
45 | exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/MANIFEST.MF'
46 | }
47 |
48 | shadowJar {
49 | archiveBaseName = 'jadx-mcp-plugin'
50 | archiveVersion = '1.3.0'
51 | mergeServiceFiles()
52 | }
53 |
54 | // Installs the plugin JAR into Jadx
55 | tasks.register("installPlugin", Exec) {
56 | group = "plugin"
57 | description = "Installs the jadx-mcp plugin."
58 |
59 | dependsOn jar
60 |
61 | doFirst {
62 | def pluginJar = layout.buildDirectory.file("libs/jadx-mcp-plugin-${project.version}.jar").get().asFile.absolutePath
63 | commandLine "sh", "-c", """
64 | jadx plugins --uninstall jadx-mcp >/dev/null
65 | jadx plugins --install-jar \"${pluginJar}\"
66 | """
67 | }
68 | }
69 |
70 | // Uninstalls the plugin from Jadx
71 | tasks.register("uninstallPlugin", Exec) {
72 | group = "plugin"
73 | description = "Uninstalls the jadx-mcp plugin."
74 | commandLine "sh", "-c", "jadx plugins --uninstall jadx-mcp"
75 | }
76 |
77 | // Enables the plugin after it's installed
78 | tasks.register("enablePlugin", Exec) {
79 | group = "plugin"
80 | description = "Enables the jadx-mcp plugin."
81 | commandLine "sh", "-c", "jadx plugins --enable jadx-mcp"
82 | }
83 |
84 | // Disables the plugin
85 | tasks.register("disablePlugin", Exec) {
86 | group = "plugin"
87 | description = "Disables the jadx-mcp plugin."
88 | commandLine "sh", "-c", "jadx plugins --disable jadx-mcp"
89 | }
90 |
```
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
```
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo. 1>&2
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48 | echo. 1>&2
49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50 | echo location of your Java installation. 1>&2
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo. 1>&2
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62 | echo. 1>&2
63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64 | echo location of your Java installation. 1>&2
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
```
--------------------------------------------------------------------------------
/fastmcp_adapter.py:
--------------------------------------------------------------------------------
```python
1 | from mcp.server.fastmcp import FastMCP
2 | import requests
3 | from requests.exceptions import ConnectionError
4 | import sys
5 |
6 | # Create the MCP adapter with a human-readable name
7 | mcp = FastMCP("Jadx MCP Server")
8 |
9 | # Address of the Jadx MCP plugin's HTTP server
10 | DEFAULT_MCP_SERVER = "http://localhost:8085"
11 | mcp_server = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MCP_SERVER
12 |
13 | def invoke_jadx(tool: str, parameters: dict = {}) -> dict:
14 | """
15 | Internal helper to send a tool request to the Jadx MCP HTTP server.
16 | """
17 | try:
18 | resp = requests.post(f"{mcp_server}/invoke", json={"tool": tool, "parameters": parameters})
19 | resp.raise_for_status()
20 | data = resp.json()
21 | if "error" in data:
22 | raise ValueError(data["error"])
23 | return data.get("result", data)
24 | except ConnectionError:
25 | raise ConnectionError("Jadx MCP server is not running. Please start Jadx and try again.")
26 | except Exception as e:
27 | raise RuntimeError(f"Unexpected error: {str(e)}")
28 |
29 |
30 | @mcp.tool()
31 | def list_all_classes(limit: int = 250, offset: int = 0) -> dict:
32 | """
33 | Returns a paginated list of class names.
34 |
35 | Params:
36 | - limit: Max number of classes to return (default 250)
37 | - offset: Starting index of class list
38 | """
39 | return invoke_jadx("list_all_classes", {"limit": limit, "offset": offset})
40 |
41 |
42 | @mcp.tool()
43 | def search_class_by_name(query: str) -> dict:
44 | """Search for class names that contain the given query string (case-insensitive)."""
45 | return invoke_jadx("search_class_by_name", {"query": query})
46 |
47 |
48 | @mcp.tool()
49 | def get_class_source(class_name: str) -> str:
50 | """
51 | Returns the full decompiled source code of a given class.
52 | """
53 | return invoke_jadx("get_class_source", {"class_name": class_name})
54 |
55 |
56 | @mcp.tool()
57 | def search_method_by_name(method_name: str) -> str:
58 | """
59 | Searches for all methods matching the provided name.
60 | Returns class and method pairs as string.
61 | """
62 | return invoke_jadx("search_method_by_name", {"method_name": method_name})
63 |
64 |
65 | @mcp.tool()
66 | def get_methods_of_class(class_name: str) -> list:
67 | """
68 | Returns all method names declared in the specified class.
69 | """
70 | return invoke_jadx("get_methods_of_class", {"class_name": class_name})
71 |
72 |
73 | @mcp.tool()
74 | def get_fields_of_class(class_name: str) -> list:
75 | """
76 | Returns all field names declared in the specified class.
77 | """
78 | return invoke_jadx("get_fields_of_class", {"class_name": class_name})
79 |
80 |
81 | @mcp.tool()
82 | def get_method_code(class_name: str, method_name: str) -> str:
83 | """
84 | Returns only the source code block of a specific method within a class.
85 | """
86 | return invoke_jadx("get_method_code", {
87 | "class_name": class_name,
88 | "method_name": method_name
89 | })
90 |
91 | @mcp.tool()
92 | def get_android_manifest() -> str:
93 | """
94 | Returns the content of AndroidManifest.xml
95 | """
96 | return invoke_jadx("get_android_manifest")
97 |
98 | @mcp.resource("jadx://tools")
99 | def get_tools_resource() -> dict:
100 | """
101 | Fetches the list of all available tools and their descriptions from the Jadx plugin.
102 | Used for dynamic tool discovery.
103 | """
104 | try:
105 | resp = requests.get(f"{mcp_server}/tools")
106 | resp.raise_for_status()
107 | return resp.json()
108 | except ConnectionError:
109 | raise ConnectionError("Jadx MCP server is not running. Please start Jadx and try again.")
110 | except Exception as e:
111 | raise RuntimeError(f"Unexpected error: {str(e)}")
112 |
113 | if __name__ == "__main__":
114 | mcp.run(transport="stdio")
115 | print("Adapter started", file=sys.stderr)
116 |
```
--------------------------------------------------------------------------------
/plugin/src/main/java/com/mobilehackinglab/jadxplugin/McpPlugin.java:
--------------------------------------------------------------------------------
```java
1 | package com.mobilehackinglab.jadxplugin;
2 |
3 | import jadx.api.JavaClass;
4 | import jadx.api.JavaField;
5 | import jadx.api.JavaMethod;
6 | import jadx.api.plugins.JadxPlugin;
7 | import jadx.api.plugins.JadxPluginContext;
8 | import jadx.api.plugins.JadxPluginInfo;
9 | import jadx.api.ResourceFile;
10 | import jadx.api.ResourceType;
11 | import jadx.core.xmlgen.ResContainer;
12 |
13 | import java.io.IOException;
14 | import java.io.BufferedReader;
15 | import java.io.InputStreamReader;
16 | import java.io.OutputStream;
17 | import java.io.PrintWriter;
18 | import java.net.*;
19 | import java.nio.charset.StandardCharsets;
20 | import java.util.List;
21 | import java.util.concurrent.ExecutorService;
22 | import java.util.concurrent.Executors;
23 |
24 | import org.json.JSONArray;
25 | import org.json.JSONException;
26 | import org.json.JSONObject;
27 |
28 | public class McpPlugin implements JadxPlugin {
29 | public static final String PLUGIN_ID="jadx-mcp";
30 |
31 | private ServerSocket serverSocket;
32 | private ExecutorService executor;
33 | private JadxPluginContext context;
34 | private McpPluginOptions pluginOptions;
35 | private boolean running = false;
36 |
37 | public McpPlugin() {
38 | }
39 |
40 | /**
41 | * Called by Jadx to initialize the plugin.
42 | */
43 | @Override
44 | public void init(JadxPluginContext context) {
45 | this.context = context;
46 |
47 | this.pluginOptions = new McpPluginOptions();
48 | this.context.registerOptions(this.pluginOptions);
49 |
50 | new Thread(this::safePluginStartup).start();
51 | }
52 |
53 | /**
54 | * Starts the HTTP server if Jadx is ready.
55 | */
56 | private void safePluginStartup() {
57 | if (!waitForJadxLoad()) {
58 | System.err.println("[MCP] Jadx initialization failed. Not starting server.");
59 | return;
60 | }
61 |
62 | try {
63 | URL httpInterface = parseHttpInterface(pluginOptions.getHttpInterface());
64 | startServer(httpInterface);
65 | System.out.println("[MCP] Server started successfully at " + httpInterface.getProtocol() + "://" + httpInterface.getHost() + ":" + httpInterface.getPort());
66 | } catch (IOException | IllegalArgumentException e) {
67 | System.err.println("[MCP] Failed to start server: " + e.getMessage());
68 | }
69 | }
70 |
71 | /**
72 | * Waits for the Jadx decompiler to finish loading classes.
73 | */
74 | private boolean waitForJadxLoad() {
75 | int retries = 0;
76 | while (retries < 30) {
77 | if (isDecompilerValid()) {
78 | int count = context.getDecompiler().getClassesWithInners().size();
79 | System.out.println("[MCP] Jadx fully loaded. Classes found: " + count);
80 | return true;
81 | }
82 |
83 | System.out.println("[MCP] Waiting for Jadx to finish loading classes...");
84 | try {
85 | Thread.sleep(1000);
86 | } catch (InterruptedException ignored) {
87 | }
88 |
89 | retries++;
90 | }
91 |
92 | System.err.println("[MCP] Jadx failed to load classes within expected time.");
93 | return false;
94 | }
95 |
96 | /**
97 | * Provides metadata for the plugin to Jadx.
98 | */
99 | @Override
100 | public JadxPluginInfo getPluginInfo() {
101 | return new JadxPluginInfo(
102 | PLUGIN_ID,
103 | "JADX MCP Plugin",
104 | "Exposes Jadx info over HTTP",
105 | "https://github.com/mobilehackinglab/jadx-mcp-plugin",
106 | "1.0.0");
107 | }
108 |
109 | /**
110 | * Cleanly shuts down the server and executor.
111 | */
112 | public void destroy() {
113 | running = false;
114 | if (serverSocket != null && !serverSocket.isClosed()) {
115 | try {
116 | serverSocket.close();
117 | } catch (IOException e) {
118 | // Ignore
119 | }
120 | }
121 |
122 | if (executor != null) {
123 | executor.shutdown();
124 | }
125 | }
126 |
127 | /**
128 | * Starts the TCP server and accepts incoming connections.
129 | */
130 | private void startServer(URL httpInterface) throws IOException {
131 |
132 | String host = httpInterface.getHost();
133 | int port = httpInterface.getPort();
134 | InetAddress bindAddr = InetAddress.getByName(host);
135 |
136 | serverSocket = new ServerSocket(port, 50, bindAddr);
137 | executor = Executors.newFixedThreadPool(5);
138 | running = true;
139 | new Thread(() -> {
140 | while (running) {
141 | try {
142 | Socket clientSocket = serverSocket.accept();
143 | executor.submit(() -> handleConnection(clientSocket));
144 | } catch (IOException e) {
145 | if (running)
146 | System.err.println("[MCP] Error accepting connection: " + e.getMessage());
147 | }
148 | }
149 | }).start();
150 | }
151 |
152 | /**
153 | * Handles incoming HTTP requests to the plugin.
154 | */
155 | private void handleConnection(Socket socket) {
156 | try (socket;
157 | BufferedReader in = new BufferedReader(
158 | new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
159 | OutputStream outStream = socket.getOutputStream()) {
160 |
161 | String requestLine = in.readLine();
162 | if (requestLine == null)
163 | return;
164 |
165 | String method = requestLine.split(" ")[0];
166 | String path = requestLine.split(" ")[1];
167 |
168 | int contentLength = 0;
169 | String header;
170 | while (!(header = in.readLine()).isEmpty()) {
171 | if (header.toLowerCase().startsWith("content-length:")) {
172 | contentLength = Integer.parseInt(header.substring("content-length:".length()).trim());
173 | }
174 | }
175 |
176 | char[] buffer = new char[contentLength];
177 | int bytesRead = in.read(buffer);
178 | String body = new String(buffer, 0, bytesRead);
179 |
180 | JSONObject responseJson;
181 |
182 | if ("/invoke".equals(path) && "POST".equalsIgnoreCase(method)) {
183 | try {
184 | String result = processInvokeRequest(body);
185 | if (result.trim().startsWith("{")) {
186 | responseJson = new JSONObject(result);
187 | } else {
188 | responseJson = new JSONObject().put("result", result);
189 | }
190 | } catch (Exception e) {
191 | responseJson = new JSONObject().put("error", "Failed to process tool: " + e.getMessage());
192 | }
193 | } else if ("/tools".equals(path)) {
194 | responseJson = new JSONObject(getToolsJson());
195 | } else {
196 | responseJson = new JSONObject().put("error", "Not found");
197 | }
198 |
199 | byte[] respBytes = responseJson.toString(2).getBytes(StandardCharsets.UTF_8);
200 |
201 | PrintWriter out = new PrintWriter(outStream, true);
202 | out.printf(
203 | "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
204 | respBytes.length);
205 | out.flush();
206 |
207 | outStream.write(respBytes);
208 | outStream.flush();
209 |
210 | } catch (Exception e) {
211 | System.err.println("[MCP] Error handling connection: " + e.getMessage());
212 | }
213 | }
214 |
215 | /**
216 | * Return available tools for MCP server in JSON
217 | */
218 | private String getToolsJson() {
219 | return """
220 | {
221 | "tools": [
222 | { "name": "get_class_source", "description": "Returns the decompiled source of a class.", "parameters": { "class_name": "string" } },
223 | { "name": "search_method_by_name", "description": "Search methods by name.", "parameters": { "method_name": "string" } },
224 | { "name": "search_class_by_name", "description": "Search class names containing a keyword.", "parameters": { "query": "string" } },
225 | { "name": "get_android_manifest", "description": "Returns the content of AndroidManifest.xml if available.", "parameters": {} },
226 | { "name": "list_all_classes", "description": "Returns a list of all class names.", "parameters": { "offset": "int", "limit": "int" } },
227 | { "name": "get_methods_of_class", "description": "Returns all method names of a class.", "parameters": { "class_name": "string" } },
228 | { "name": "get_fields_of_class", "description": "Returns all field names of a class.", "parameters": { "class_name": "string" } },
229 | { "name": "get_method_code", "description": "Returns the code for a specific method.", "parameters": { "class_name": "string", "method_name": "string" } }
230 | ]
231 | }
232 | """;
233 | }
234 |
235 | /**
236 | * Handles tool invocation from the client, routing to the correct handler.
237 | *
238 | * @param requestBody JSON request with tool and parameters
239 | * @return JSON response string
240 | * @throws JSONException if the input JSON is malformed
241 | */
242 | private String processInvokeRequest(String requestBody) throws JSONException {
243 | JSONObject requestJson = new JSONObject(requestBody);
244 | String toolName = requestJson.getString("tool");
245 | JSONObject params = requestJson.optJSONObject("parameters");
246 | if (params == null)
247 | params = new JSONObject();
248 |
249 | return switch (toolName) {
250 | case "get_class_source" -> handleGetClassSource(params);
251 | case "search_method_by_name" -> handleSearchMethodByName(params);
252 | case "search_class_by_name" -> handleSearchClassByName(params);
253 | case "get_android_manifest" -> handleGetAndroidManifest();
254 | case "list_all_classes" -> handleListAllClasses(params);
255 | case "get_methods_of_class" -> handleGetMethodsOfClass(params);
256 | case "get_fields_of_class" -> handleGetFieldsOfClass(params);
257 | case "get_method_code" -> handleGetMethodCode(params);
258 | default -> new JSONObject().put("error", "Unknown tool: " + toolName).toString();
259 | };
260 | }
261 |
262 | /**
263 | * Search class names based on a partial query string and return matches.
264 | *
265 | * @param params JSON object with key "query"
266 | * @return JSON object with array of matched class names under "results"
267 | */
268 | private String handleSearchClassByName(JSONObject params) {
269 | String query = params.optString("query", "").toLowerCase();
270 | JSONArray array = new JSONArray();
271 | for (JavaClass cls : context.getDecompiler().getClassesWithInners()) {
272 | String fullName = cls.getFullName();
273 | if (fullName.toLowerCase().contains(query)) {
274 | array.put(fullName);
275 | }
276 | }
277 | return new JSONObject().put("results", array).toString();
278 | }
279 |
280 | /**
281 | * Retrieves the content of AndroidManifest.xml
282 | *
283 | * This extracts the decoded XML from the ResContainer returned by loadContent().
284 | *
285 | * @return The manifest XML as a string or an error message.
286 | */
287 | private String handleGetAndroidManifest() {
288 | try {
289 | for (ResourceFile resFile : context.getDecompiler().getResources()) {
290 | if (resFile.getType() == ResourceType.MANIFEST) {
291 | ResContainer container = resFile.loadContent();
292 | if (container.getText() != null) {
293 | return container.getText().getCodeStr(); // decoded manifest
294 | } else {
295 | return "Manifest content is empty or could not be decoded.";
296 | }
297 | }
298 | }
299 | return "AndroidManifest.xml not found.";
300 | } catch (Exception e) {
301 | return "Error retrieving AndroidManifest.xml: " + e.getMessage();
302 | }
303 | }
304 |
305 | /**
306 | * Retrieves the full decompiled source code of a specific Java class.
307 | *
308 | * @param params A JSON object containing the required parameter:
309 | * - "class_name": The fully qualified name of the class to
310 | * retrieve.
311 | * @return The decompiled source code of the class, or an error message if not
312 | * found.
313 | */
314 | private String handleGetClassSource(JSONObject params) {
315 | try {
316 | String className = params.getString("class_name");
317 | for (JavaClass cls : context.getDecompiler().getClasses()) {
318 | if (cls.getFullName().equals(className)) {
319 | return cls.getCode();
320 | }
321 | }
322 | return "Class not found: " + className;
323 | } catch (Exception e) {
324 | return "Error fetching class: " + e.getMessage();
325 | }
326 | }
327 |
328 | /**
329 | * Searches all decompiled classes for methods whose names match or contain the
330 | * provided string.
331 | *
332 | * @param params A JSON object containing the required parameter:
333 | * - "method_name": A case-insensitive string to match method
334 | * names.
335 | * @return A newline-separated list of matched methods with their class names,
336 | * or a message if no match is found.
337 | */
338 | private String handleSearchMethodByName(JSONObject params) {
339 | try {
340 | String methodName = params.getString("method_name");
341 | StringBuilder result = new StringBuilder();
342 | for (JavaClass cls : context.getDecompiler().getClasses()) {
343 | cls.decompile();
344 | for (JavaMethod method : cls.getMethods()) {
345 | if (method.getName().toLowerCase().contains(methodName.toLowerCase())) {
346 | result.append(cls.getFullName()).append(" -> ").append(method.getName()).append("\n");
347 | }
348 | }
349 | }
350 | return result.length() > 0 ? result.toString() : "No methods found for: " + methodName;
351 | } catch (Exception e) {
352 | return "Error searching methods: " + e.getMessage();
353 | }
354 | }
355 |
356 | /**
357 | * Lists all classes with optional pagination.
358 | *
359 | * @param params JSON with optional offset and limit
360 | * @return JSON response with class list and metadata
361 | */
362 | private String handleListAllClasses(JSONObject params) {
363 | int offset = params.optInt("offset", 0);
364 | int limit = params.optInt("limit", 250);
365 | int maxLimit = 500;
366 | if (limit > maxLimit)
367 | limit = maxLimit;
368 |
369 | List<JavaClass> allClasses = context.getDecompiler().getClassesWithInners();
370 | int total = allClasses.size();
371 |
372 | JSONArray array = new JSONArray();
373 | for (int i = offset; i < Math.min(offset + limit, total); i++) {
374 | JavaClass cls = allClasses.get(i);
375 | array.put(cls.getFullName());
376 | }
377 |
378 | JSONObject response = new JSONObject();
379 | response.put("total", total);
380 | response.put("offset", offset);
381 | response.put("limit", limit);
382 | response.put("classes", array);
383 |
384 | return response.toString();
385 | }
386 |
387 | /**
388 | * Retrieves a list of all method names declared in the specified Java class.
389 | *
390 | * @param params A JSON object containing the required parameter:
391 | * - "class_name": The fully qualified name of the class.
392 | * @return A formatted JSON array of method names, or an error message if the
393 | * class is not found.
394 | */
395 | private String handleGetMethodsOfClass(JSONObject params) {
396 | try {
397 | String className = params.getString("class_name");
398 | for (JavaClass cls : context.getDecompiler().getClasses()) {
399 | if (cls.getFullName().equals(className)) {
400 | JSONArray array = new JSONArray();
401 | for (JavaMethod method : cls.getMethods()) {
402 | array.put(method.getName());
403 | }
404 | return array.toString(2);
405 | }
406 | }
407 | return "Class not found: " + className;
408 | } catch (Exception e) {
409 | return "Error fetching methods: " + e.getMessage();
410 | }
411 | }
412 |
413 | /**
414 | * Retrieves all field names defined in the specified Java class.
415 | *
416 | * @param params A JSON object containing the required parameter:
417 | * - "class_name": The fully qualified name of the class.
418 | * @return A formatted JSON array of field names, or an error message if the
419 | * class is not found.
420 | */
421 | private String handleGetFieldsOfClass(JSONObject params) {
422 | try {
423 | String className = params.getString("class_name");
424 | for (JavaClass cls : context.getDecompiler().getClasses()) {
425 | if (cls.getFullName().equals(className)) {
426 | JSONArray array = new JSONArray();
427 | for (JavaField field : cls.getFields()) {
428 | array.put(field.getName());
429 | }
430 | return array.toString(2);
431 | }
432 | }
433 | return "Class not found: " + className;
434 | } catch (Exception e) {
435 | return "Error fetching fields: " + e.getMessage();
436 | }
437 | }
438 |
439 | /**
440 | * Extracts the decompiled source code of a specific method within a given
441 | * class.
442 | *
443 | * @param params A JSON object containing the required parameters:
444 | * - "class_name": The fully qualified name of the class.
445 | * - "method_name": The name of the method to extract.
446 | * @return A string containing the method's source code block (if found),
447 | * or a descriptive error message if the method or class is not found.
448 | */
449 | private String handleGetMethodCode(JSONObject params) {
450 | try {
451 | String className = params.getString("class_name");
452 | String methodName = params.getString("method_name");
453 | for (JavaClass cls : context.getDecompiler().getClasses()) {
454 | if (cls.getFullName().equals(className)) {
455 | cls.decompile();
456 | for (JavaMethod method : cls.getMethods()) {
457 | if (method.getName().equals(methodName)) {
458 | String classCode = cls.getCode();
459 | int methodIndex = classCode.indexOf(method.getName());
460 | if (methodIndex != -1) {
461 | int openBracket = classCode.indexOf('{', methodIndex);
462 | if (openBracket != -1) {
463 | int closeBracket = findMatchingBracket(classCode, openBracket);
464 | if (closeBracket != -1) {
465 | String methodCode = classCode.substring(openBracket, closeBracket + 1);
466 | return methodCode;
467 | }
468 | }
469 | }
470 |
471 | return "Could not extract method code from class source.";
472 | }
473 | }
474 | return "Method '" + methodName + "' not found in class '" + className + "'";
475 | }
476 | }
477 | return "Class '" + className + "' not found";
478 | } catch (Exception e) {
479 | return "Error fetching method code: " + e.getMessage();
480 | }
481 | }
482 |
483 | // Helper method to find matching closing bracket
484 | private int findMatchingBracket(String code, int openPos) {
485 | int depth = 1;
486 | for (int i = openPos + 1; i < code.length(); i++) {
487 | char c = code.charAt(i);
488 | if (c == '{') {
489 | depth++;
490 | } else if (c == '}') {
491 | depth--;
492 | if (depth == 0) {
493 | return i;
494 | }
495 | }
496 | }
497 | return -1; // No matching bracket found
498 | }
499 |
500 | /**
501 | * Validates that the decompiler is loaded and usable.
502 | * This is needed because: When you use "File → Open" to load a new file,
503 | * Jadx replaces the internal decompiler instance, but your plugin still holds a
504 | * stale reference to the old one.
505 | */
506 | private boolean isDecompilerValid() {
507 | try {
508 | return context != null
509 | && context.getDecompiler() != null
510 | && context.getDecompiler().getRoot() != null
511 | && !context.getDecompiler().getClassesWithInners().isEmpty();
512 | } catch (Exception e) {
513 | return false;
514 | }
515 | }
516 |
517 | /**
518 | * Parses and validates the given HTTP interface string.
519 | *
520 | * <p>This method strictly enforces that the input must be a complete HTTP URL
521 | * with a protocol of {@code http}, a non-empty host, and an explicit port.</p>
522 | *
523 | * <p>Examples of valid input: {@code http://127.0.0.1:8080}, {@code http://localhost:3000}</p>
524 | *
525 | * @param httpInterface A string representing the HTTP interface.
526 | * @return A {@link URL} object representing the parsed interface.
527 | * @throws IllegalArgumentException if the URL is malformed or missing required components.
528 | */
529 | private URL parseHttpInterface(String httpInterface) throws IllegalArgumentException {
530 | try {
531 | URL url = new URL(httpInterface);
532 |
533 | if (!"http".equalsIgnoreCase(url.getProtocol())) {
534 | throw new IllegalArgumentException("Invalid protocol: " + url.getProtocol() + ". Only 'http' is supported.");
535 | }
536 |
537 | if (url.getHost() == null || url.getHost().isEmpty()) {
538 | throw new IllegalArgumentException("Missing or invalid host in HTTP interface: " + httpInterface);
539 | }
540 |
541 | if (url.getPort() == -1) {
542 | throw new IllegalArgumentException("Port must be explicitly specified in HTTP interface: " + httpInterface);
543 | }
544 |
545 | if (url.getPath() != null && !url.getPath().isEmpty() && !url.getPath().equals("/")) {
546 | throw new IllegalArgumentException("Path is not allowed in HTTP interface: " + httpInterface);
547 | }
548 |
549 | if (url.getQuery() != null || url.getRef() != null || url.getUserInfo() != null) {
550 | throw new IllegalArgumentException("HTTP interface must not contain query, fragment, or user info: " + httpInterface);
551 | }
552 |
553 | return url;
554 | } catch (MalformedURLException e) {
555 | throw new IllegalArgumentException("Malformed HTTP interface URL: " + httpInterface, e);
556 | }
557 | }
558 | }
```