From ab6fc403e45bb596a8467e9af5b00c58c09e7dde Mon Sep 17 00:00:00 2001 From: Linus Henke Date: Mon, 2 Nov 2026 13:12:59 +0100 Subject: [PATCH] Install GhidraMCP 2.0 and connect it to Claude Desktop Switch Ghidra to 12.1.4 (with openjdk-21-jdk), the version the GhidraMCP 2.0 release is built for; Ghidra only loads extensions built for its exact version. From LaurieWired's GhidraMCP 2.0 release zip (SHA-256 checked): - unpack the Ghidra extension into /opt/ghidra/Ghidra/Extensions; the build fails if it doesn't target GHIDRA_VERSION. GhidraMCPPlugin is a RELEASED Developer plugin, so the CodeBrowser config loads it, and it serves its HTTP backend on 127.0.0.1:8080. - install the ghidra-mcp wheel into /opt/ghidra-mcp, with every dependency pinned to GhidraMCP's uv.lock at the release commit (ghidra/uvlock-to-constraints.py, honouring per-Python markers). claude-desktop/claude_desktop_config.json adds a stdio "ghidra" server running ghidra-mcp against http://127.0.0.1:8080/ and is copied into ~/.config/Claude. Verified with a test project: ghidra-mcp started from that config connected to Ghidra 12.1.4 in GUI mode and returned program info, segments and imports for the open program. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 21 ++++----- Dockerfile | 57 ++++++++++++++++++++--- claude-desktop/claude_desktop_config.json | 8 ++++ ghidra/FrontEndTool.xml | 2 +- ghidra/uvlock-to-constraints.py | 23 +++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 claude-desktop/claude_desktop_config.json create mode 100644 ghidra/uvlock-to-constraints.py diff --git a/CLAUDE.md b/CLAUDE.md index 6599774..38cbd2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ The goal is that `docker compose up` gives you a working setup where Claude Desk ## Status -Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop and Ghidra, started by `run.sh`; no compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs. +Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop, Ghidra and GhidraMCP, started by `run.sh`; Claude Desktop's MCP config is `claude-desktop/claude_desktop_config.json`; Ghidra config files are in `ghidra/`. No compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs. ## Intended layout @@ -37,18 +37,17 @@ Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with C ## Architecture -- **Claude Desktop and Ghidra share one container** (the root `Dockerfile`, started by `run.sh`), at the user's request. Ghidra is the official GitHub release (version, build date and SHA-256 are build args), runs on Ubuntu's `openjdk-25-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. The **Developer** plugin package is enabled in both the project window and the CodeBrowser. A bare `` only adds the package's RELEASED plugins, so the STABLE ones are listed with ``. CodeBrowser: the build inserts `ghidra/codebrowser-developer.xml` into `defaultTools/CodeBrowser.tool` inside `Public_Release.jar`. Project window: it has no default to patch, so `ghidra/FrontEndTool.xml` is copied into the user settings, along with `ghidra/preferences` (`USER_AGREEMENT=ACCEPT`, because a pre-created settings dir otherwise makes Ghidra ask for its user agreement). To check a config really loaded, close Ghidra cleanly and see whether the plugins survive in the file it rewrites; Ghidra drops what it didn't load. +- **Claude Desktop and Ghidra share one container** (the root `Dockerfile`, started by `run.sh`), at the user's request. Ghidra is the official GitHub release (version, build date and SHA-256 are build args), is pinned to **12.1.4** because that's what the GhidraMCP release is built for (Ghidra only loads extensions built for its exact version), runs on Ubuntu's `openjdk-21-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. The **Developer** plugin package is enabled in both the project window and the CodeBrowser. A bare `` only adds the package's RELEASED plugins, so the STABLE ones are listed with ``. CodeBrowser: the build inserts `ghidra/codebrowser-developer.xml` into `defaultTools/CodeBrowser.tool` inside `Public_Release.jar`. Project window: it has no default to patch, so `ghidra/FrontEndTool.xml` is copied into the user settings, along with `ghidra/preferences` (`USER_AGREEMENT=ACCEPT`, because a pre-created settings dir otherwise makes Ghidra ask for its user agreement). To check a config really loaded, close Ghidra cleanly and see whether the plugins survive in the file it rewrites; Ghidra drops what it didn't load. - **Shared workspace volume.** Every container mounts the workspace at `/workspace`, so a file path means the same thing to Claude, Ghidra and the RE MCP. `run.sh` asks for the host folder on each start in the desktop's folder picker (`zenity`, or `kdialog` on KDE; terminal prompt if neither exists), opening at the last folder used, else `./workspace`. Skip it with `--workspace DIR` or `WORKSPACE=DIR`. It refuses `/` and `$HOME`. The image symlinks `~/workspace` to `/workspace` for Claude Desktop's file picker. - **GUI forwarding.** Claude Desktop and Ghidra are GUI apps. Default to X11 forwarding (`/tmp/.X11-unix` + `DISPLAY`). Support Wayland through XWayland. Keep a noVNC/VNC fallback in mind for headless hosts. Never solve display access with a blanket `xhost +`. Scope it (`xhost +local:docker` or `+SI:localuser:...`). -- **GhidraMCP wiring.** GhidraMCP has two parts: - - a Ghidra plugin that runs an HTTP server inside Ghidra (default port `8080`), and - - a Python MCP bridge (`bridge_mcp_ghidra.py`) that speaks MCP to Claude and forwards calls to that HTTP server. +- **GhidraMCP wiring.** LaurieWired's GhidraMCP (https://github.com/LaurieWired/GhidraMCP), release 2.0, installed from the official release zip (SHA-256 checked). It has two parts: + - a Ghidra extension, unpacked into `/opt/ghidra/Ghidra/Extensions/GhidraMCP`. Its `GhidraMCPPlugin` is a RELEASED plugin in the Developer package, so the CodeBrowser config loads it. While a CodeBrowser is open it serves an HTTP backend on `127.0.0.1:8080` (port: *Edit → Tool Options → GhidraMCP HTTP Server*). + - the `ghidra-mcp` Python MCP server (2.0 replaced `bridge_mcp_ghidra.py`), installed from the release wheel into the venv `/opt/ghidra-mcp`. Its dependencies are pinned to GhidraMCP's `uv.lock` at the release commit via `ghidra/uvlock-to-constraints.py`. CLI: `ghidra-mcp --ghidra-server http://127.0.0.1:8080/` (stdio by default; also `--transport streamable-http`, `--headless`). - The bridge is configured with the Ghidra server URL (e.g. `http://ghidra:8080/`). The plugin must be enabled in the CodeBrowser and a program must be open, or the bridge has nothing to talk to. -- **Container lifetime follows the apps.** The entrypoint `launch-apps.sh` starts Claude Desktop and Ghidra and stops both, ending the container, when either is closed. Ghidra counts as closed when its process exits (closing the project window quits it; CodeBrowser or Help windows don't). Claude Desktop's window only hides the app on close (GNOME shows no tray icon), so its X windows are watched: none open or minimized for ~3 s counts as closed. `run.sh` uses `--init` so signals and zombie processes are handled. -- **MCP transport between containers.** Claude Desktop starts local MCP servers as stdio subprocesses. Because the servers live in other containers, pick one approach and apply it consistently: - - *Preferred:* run the MCP servers with an HTTP/SSE transport in their own containers. Bridge them into Claude Desktop's stdio config with a small proxy (e.g. `mcp-remote` or `mcp-proxy`) installed in the Claude Desktop image. - - *Alternative:* install the bridges inside the Claude Desktop image and point them at the other containers over the network. This works well for the GhidraMCP bridge, which is just Python + HTTP. + A program must be open in the CodeBrowser for the tools to return data. Claude Desktop only starts MCP servers after sign-in. +- **MCP transport.** Claude Desktop starts local MCP servers as stdio subprocesses. GhidraMCP runs in the same container, so `ghidra-mcp` is a plain stdio entry in `claude_desktop_config.json` that reaches Ghidra on `127.0.0.1`. For future servers in other containers, pick one approach and apply it consistently: + - *Preferred:* run them with an HTTP/SSE transport in their own containers and bridge them into Claude Desktop's stdio config with a small proxy (e.g. `mcp-remote` or `mcp-proxy`) installed in the Claude Desktop image. + - *Alternative:* install the client side inside the Claude Desktop image and point it at the other container over the network. - Avoid mounting the Docker socket into the Claude Desktop container just to `docker exec` MCP servers. ## Conventions @@ -78,7 +77,7 @@ docker compose down - **Claude Desktop on Linux.** Check whether an official Linux build is available at the time of work. If not, use a maintained community repackaging and document which one and why. Confirm the config path, usually `~/.config/Claude/claude_desktop_config.json`. - **Reverse Engineer Anything MCP.** Confirm the upstream repository, install method, runtime (Python/Node), required external tools and supported transports before writing its Dockerfile. Record the source URL in the README. -- **GhidraMCP.** Confirm the current release, its supported Ghidra version, the bridge's CLI flags and its default ports from the upstream README. +- **GhidraMCP.** Before bumping GhidraMCP or Ghidra, confirm the release's supported Ghidra version (`version=` in the extension's `extension.properties`; the build checks it), the `ghidra-mcp` CLI flags and its default ports from the upstream README. The Developer plugin lists in `ghidra/` were checked against 12.1.4 and 12.2; re-check them for other versions. ## When making changes diff --git a/Dockerfile b/Dockerfile index 8456e26..cfbe14f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,13 +35,15 @@ RUN apt-get update \ # Ghidra from the official GitHub release, checked against the SHA-256 in the # release notes: https://github.com/NationalSecurityAgency/ghidra/releases -# Ghidra 12.2 needs JDK 25 (application.java.min=25). The full JDK, not the +# 12.1.4 because that is the version the GhidraMCP release below is built +# for; Ghidra only loads extensions built for its exact version. +# Ghidra 12.1.4 needs JDK 21 (application.java.min=21). The full JDK, not the # headless one, because Ghidra's GUI needs the AWT/X11 libraries. -ARG GHIDRA_VERSION=12.2 -ARG GHIDRA_BUILD_DATE=20261016 -ARG GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 +ARG GHIDRA_VERSION=12.1.4 +ARG GHIDRA_BUILD_DATE=20260921 +ARG GHIDRA_SHA256=ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db RUN apt-get update \ - && apt-get install -y --no-install-recommends openjdk-25-jdk unzip \ + && apt-get install -y --no-install-recommends openjdk-21-jdk unzip \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSLo /tmp/ghidra.zip \ "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${GHIDRA_VERSION}_build/ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_BUILD_DATE}.zip" \ @@ -70,6 +72,43 @@ RUN set -e; cd /tmp \ && jar uf "$jar_file" defaultTools/CodeBrowser.tool \ && rm -r /tmp/defaultTools /tmp/codebrowser-developer.xml +# GhidraMCP (https://github.com/LaurieWired/GhidraMCP), official release zip +# checked against the SHA-256 GitHub lists for it. It holds: +# - the Ghidra extension, unpacked into Ghidra's own Extensions directory so +# it is installed for every user. Its GhidraMCPPlugin is a RELEASED plugin +# in the Developer package, so the CodeBrowser config above loads it. It +# serves an HTTP backend on 127.0.0.1:8080 while the CodeBrowser is open. +# - the ghidra-mcp Python MCP server (wheel), installed into its own venv. +# Claude Desktop starts it over stdio; it forwards calls to that backend. +# The wheel only gives version ranges, so every dependency is pinned to +# the version in GhidraMCP's uv.lock at the release commit. +ARG GHIDRAMCP_VERSION=2.0 +ARG GHIDRAMCP_SHA256=ebf904fd6782e429e95a60a24fe0724b20c0acb2dedb304135a5281f6b5f2e72 +ARG GHIDRAMCP_COMMIT=1e980252087b2479a21b9ca6d82d5829538651a7 +ARG GHIDRAMCP_UVLOCK_SHA256=f80ffe8df8167535f253976a1dfb702a9a7f8836d884084017dd7c04e1444837 +COPY ghidra/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py +RUN set -e \ + && apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-venv \ + && rm -rf /var/lib/apt/lists/* \ + && rel="GhidraMCP-release-$(echo "${GHIDRAMCP_VERSION}" | tr . -)" \ + && curl -fsSLo /tmp/ghidramcp.zip \ + "https://github.com/LaurieWired/GhidraMCP/releases/download/${GHIDRAMCP_VERSION}/${rel}.zip" \ + && echo "${GHIDRAMCP_SHA256} /tmp/ghidramcp.zip" | sha256sum -c - \ + && unzip -q /tmp/ghidramcp.zip -d /tmp/ghidramcp \ + && unzip -q /tmp/ghidramcp/${rel}/ghidra_${GHIDRA_VERSION}_PUBLIC_*_GhidraMCP.zip \ + -d /opt/ghidra/Ghidra/Extensions \ + && grep -qx "version=${GHIDRA_VERSION}" /opt/ghidra/Ghidra/Extensions/GhidraMCP/extension.properties \ + && curl -fsSLo /tmp/uv.lock \ + "https://raw.githubusercontent.com/LaurieWired/GhidraMCP/${GHIDRAMCP_COMMIT}/uv.lock" \ + && echo "${GHIDRAMCP_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \ + && python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \ + && python3 -m venv /opt/ghidra-mcp \ + && /opt/ghidra-mcp/bin/pip install --no-cache-dir --disable-pip-version-check \ + --constraint /tmp/constraints.txt /tmp/ghidramcp/${rel}/ghidra_mcp-*-py3-none-any.whl \ + && /opt/ghidra-mcp/bin/ghidra-mcp --help >/dev/null \ + && rm -rf /tmp/ghidramcp /tmp/ghidramcp.zip /tmp/uv.lock /tmp/constraints.txt /tmp/uvlock-to-constraints.py + # Ubuntu 24.04 ships a default "ubuntu" user on UID 1000; replace it. RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \ && groupadd -g "${USER_GID}" "${USER_NAME}" \ @@ -79,7 +118,8 @@ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \ && install -d -o "${USER_UID}" -g "${USER_GID}" \ "/home/${USER_NAME}/.config" \ "/home/${USER_NAME}/.config/ghidra" \ - "/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" + "/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" \ + "/home/${USER_NAME}/.config/Claude" # Ghidra's project window has no default config to patch; it builds one in # code and saves it on exit. So a starting FrontEndTool.xml with the Developer @@ -89,6 +129,11 @@ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \ COPY --chown=${USER_UID}:${USER_GID} ghidra/FrontEndTool.xml ghidra/preferences \ /home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC/ +# Claude Desktop's MCP servers. Only this file is put in ~/.config/Claude; +# sign-in data is still created at runtime and discarded with the container. +COPY --chown=${USER_UID}:${USER_GID} claude-desktop/claude_desktop_config.json \ + /home/${USER_NAME}/.config/Claude/claude_desktop_config.json + COPY --chmod=755 launch-apps.sh /usr/local/bin/launch-apps USER ${USER_NAME} diff --git a/claude-desktop/claude_desktop_config.json b/claude-desktop/claude_desktop_config.json new file mode 100644 index 0000000..d9c260d --- /dev/null +++ b/claude-desktop/claude_desktop_config.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "ghidra": { + "command": "/opt/ghidra-mcp/bin/ghidra-mcp", + "args": ["--ghidra-server", "http://127.0.0.1:8080/"] + } + } +} diff --git a/ghidra/FrontEndTool.xml b/ghidra/FrontEndTool.xml index 99e4f5c..5c848a1 100644 --- a/ghidra/FrontEndTool.xml +++ b/ghidra/FrontEndTool.xml @@ -3,7 +3,7 @@ it on exit; it is copied into the image so that every container start opens with the "Developer" plugin package enabled (File > Configure). --> - + diff --git a/ghidra/uvlock-to-constraints.py b/ghidra/uvlock-to-constraints.py new file mode 100644 index 0000000..9517b70 --- /dev/null +++ b/ghidra/uvlock-to-constraints.py @@ -0,0 +1,23 @@ +"""Print pip constraints (name==version) for every package in a uv.lock. + +Used at image build time to install the ghidra-mcp wheel with the exact +dependency versions its upstream lock file was tested with. A package that +uv resolved differently per Python version appears more than once; those +entries carry their resolution markers, so pip applies only the one that +matches the interpreter. +""" +import sys +import tomllib + +with open(sys.argv[1], "rb") as f: + lock = tomllib.load(f) + +for package in lock["package"]: + # The project itself is installed from the release wheel. + if package["name"] == "ghidra-mcp": + continue + line = f'{package["name"]}=={package["version"]}' + markers = package.get("resolution-markers") + if markers: + line += " ; " + " or ".join(f"({m})" for m in markers) + print(line)