A Model Context Protocol (MCP) extension for dnSpyEx that exposes .NET assembly analysis and IL-editing tools to AI assistants like Claude.
Chinese / 中文说明: see README.zh-CN.md.
From zero to "ask Claude about your assembly" in a few minutes:
-
Get it running. Download the all-in-one zip for your system from Releases (the MCP extension is already bundled inside), unzip anywhere, and run
dnSpy.exe. Already have dnSpy installed? Use the plugin-only DLL instead. -
Enable the server. In dnSpy: View → Options → MCP Server → tick Enable Server → OK. Note the Port shown on that page — and check the Server Log pane for the port it actually bound to (it falls back to the next free port if yours is taken). Call that
<port>below. Sanity check: openhttp://localhost:<port>/in a browser (you'll see a status page) or runcurl http://localhost:<port>/health. -
Load your target. Open the assembly you want to analyze (File → Open, or drag a DLL onto dnSpy) — e.g. a Unity game's
Assembly-CSharp.dll. The tools operate on whatever is loaded in the tree. (Or skip this and let the AI load it for you once connected — seeopen_files.) -
Connect your AI client. For Claude Code (replace
<port>with the one from step 2):claude mcp add --transport http dnspy http://localhost:<port>
Other clients (Claude Desktop, codex, MCP Inspector) — see Client configuration.
-
Ask. Just talk to it in natural language, e.g.:
"In Assembly-CSharp, find every method that uses the string
SAVEFILE, then show me the decompiledSaveGamemethod."Claude picks the right tools (
search_string_literals→find_references→decompile_method) on its own. See Features for everything it can do.
- open_files — load .NET assemblies/modules into dnSpy from disk (like File → Open, driven by the AI).
pathsaccepts files and/or directories — open several DLLs at once, or every*.dllin a folder (e.g. a Unity game'sManageddirectory;recursive/patternsupported). Reads metadata only, never executes. Returns per-fileloaded/already_loaded/failed
- list_assemblies — list all loaded assemblies with metadata (
name_filtersubstring/wildcard to cut through hundreds of Unity framework modules) - get_assembly_info — detailed info about a specific assembly (paginated namespaces)
- list_types — all types in an assembly or namespace; paginated (
page_sizeoverride,names_onlycompact mode). Includes nested + compiler-generated state machines by default (is_nested/is_compiler_generatedflags;include_nested=falsefor top-level only).base_typefilters to (transitive) subclasses, e.g.base_type='MonoBehaviour' - get_type_info — fields, properties, and paginated methods for a type (methods include
token/MDToken).compactdrops per-member detail;members_filterkeeps only matching member names (e.g.*Save*) - list_methods — methods of a type with
token+parameter_typesper entry, paginated - get_type_fields — filter fields by wildcard pattern (e.g.
*Bonus*) - get_type_property — detailed info about a property including getter/setter
- search_types — wildcard / substring type search;
assembly_nameto scope to one assembly,names_only/page_sizeto control output. Matches nested compiler-generated types too (e.g.*<Awake>d__*) - search_members — wildcard / substring search for members (methods / fields / properties / events) by name across all assemblies (or one via
assembly_name);kindsfilters by member kind. The member-level counterpart ofsearch_types(together they are dnSpy's Search Assemblies / Ctrl+Shift+K). Use it when you have a bare member name from decompiled code but don't know its declaring type: each hit carriesdeclaring_type,member_kind, fullsignature,token(MDToken),is_static/is_public— feedtokenstraight todecompile_by_token, ordeclaring_type+ name tofind_callers/find_references - find_path_to_type — BFS over fields/properties to connect two types
- decompile_method — decompile a method to C# (accepts
parameter_types/method_tokento disambiguate overloads). Nested types are addressable (Outer/Inner,./+//all accepted), so you can decompile a state machine'sMoveNextdirectly. For async/iterator kickoffs, when the decompiler can't inline the state machine back intoawait/yield(common on Unity output) the rawMoveNextbody is appended automatically (include_state_machine=falseto opt out) - decompile_type — decompile a whole type to C# (all members) by name — the "click the class and read its source" view, in one call. Nested types addressable. For very large types prefer
get_type_info(compact) ordecompile_method - decompile_by_token — decompile a method (or type) by
MDTokenalone, no type name needed — ideal for tokens straight from xref / string-search / member-search results (assembly_namerecommended; tokens are per-module). Same async/iterator rescue asdecompile_method
- find_callers — every method that calls a given method (call / callvirt / newobj / ldftn), across all assemblies. Each hit carries caller type/method,
MDToken, opcode, IL index/offset - find_callees — the inverse: what a single method uses (methods it calls, fields it reads/writes, types it touches), deduplicated per referenced member with opcodes + site count and a resolved
MDToken(dnSpy Analyze's "Uses") - find_references — every IL site referencing a
method/field/type/string(target_kindselects), across all assemblies - find_overrides — virtual / interface-method polymorphism (dnSpy Analyze's "Overridden By" / "Overrides"):
direction='overridden_by'lists every type that overrides a class virtual or implements an interface method (implicit + explicit;is_interface_implflags the latter) — the concrete bodies acallvirtcan dispatch to, whichfind_callerscan't surface;direction='overrides'walks the base chain for what a method overrides - find_unity_messages — list the Unity lifecycle / message methods (
Awake/Update/OnTriggerEnter/OnGUI/ …) on a type, or across an assembly. Unity invokes these by name with no IL call site, so xref can't find them — yet they're the entry points you hook in a MonoBehaviour. Each hit carriesparameter_types+MDToken - find_by_attribute — find types/members carrying a given custom attribute (
[SerializeField],[BepInPlugin],[CompilerGenerated], …) — "locate by convention". Suffix-tolerant name match;targetsrestricts kinds (type/method/field/property/event). Each hit carriestarget_kind,declaring_type,MDToken, and the attribute's FullName
- search_string_literals — reverse-lookup a string across assemblies: "which method emits this
ldstr?" (substring or*wildcard, optional single-assembly scope). Each hit carries declaring type, method,MDToken, signature, IL index/offset - list_string_constants — list every
ldstrin a type (incl. nested types) or a single method - search_constants — find where a numeric constant is used (
ldc.i4*/ldc.i8/ldc.r4/ldc.r8) — the number counterpart ofsearch_string_literals(magic numbers, item IDs, thresholds). Integer query matches integer constants; a decimal-point query matches floats. Scope withassembly_name
- get_method_il — instructions (index, offset, opcode, operand) + locals + exception handlers + body flags
- patch_method_il — ordered
replace/insert/delete/set_init_localsedits; snapshot-on-first-patch - force_return — replace a body with
return <value>(true/false, a number, null, ordefault) without hand-writing IL — the common "makeIsPremium()return true" patch. Void methods become a no-op - nop_method — empty a method out (void → bare
ret; value-returning → return default). For neutralizing a tick/telemetry/anti-cheat call - revert_method_il — restore the pre-patch body shape (also undoes force_return / nop_method)
- save_assembly — write the module to disk (timestamped backup on overwrite,
NativeWritepreserves native stubs / Win32 resources / delay-loaded imports, GAC refused)
- generate_bepinex_plugin — a full BepInEx plugin: the
BaseUnityPluginshell (Awake wiringHarmony.PatchAll, OnDestroy unpatch) plus a[HarmonyPatch]class per hook. Each hook is resolved against the target assembly so its patch is signature-aware (real__instance/ref __result/ named params), not an empty stub; unresolved hooks degrade to a comment. Per-hookpatch_type(postfix/prefix/transpiler) - generate_harmony_patch — a compile-ready HarmonyX patch class for a real method, with the right injected params read from its actual signature:
ref <ReturnType> __resultfor a postfix,__instancefor instance methods, the original parameters by name, and anew Type[]{...}disambiguator when the name is overloaded.patch_type= postfix / prefix (returns bool to skip the original) / transpiler
Embedded BepInEx documentation served over resources/list / resources/read:
- plugin-structure
- harmony-patching (Prefix / Postfix / Transpiler)
- configuration
- common-scenarios
- il2cpp-guide
- mono-vs-il2cpp
All docs ship inside the DLL — no network required.
See, patch, and save bytecode from an AI client. Mirrors the dnSpy Edit Method Body dialog.
Each instruction's operand is a single tagged string; the same grammar is used by get_method_il (read) and patch_method_il (write), so operands round-trip unchanged.
| Tag | Example | Opcodes |
|---|---|---|
int: / int8: / uint8: / long: |
int:42 |
ldc.i4, ldc.i4.s, ldc.i8 |
float: / double: |
double:3.14 |
ldc.r4, ldc.r8 |
str: (JSON-quoted) |
str:"hello\n" |
ldstr |
method: (dnlib FullName) |
method:System.Void Ns.T::M(System.Int32) |
call, callvirt, newobj, ldftn, ldvirtftn, jmp |
field: |
field:System.Int32 Ns.T::F |
ldfld, stfld, ldsfld, stsfld, ldflda, ldsflda |
type: |
type:System.String |
castclass, isinst, box, unbox, newarr, initobj, ldelem*, stelem*, … |
token:method:… / token:field:… / token:type:… |
token:type:System.String |
ldtoken |
label:<idx> |
label:7 |
br, brtrue.s, blt, … |
switch:[<i>,<i>,…] |
switch:[3,7,12] |
switch |
local:<idx> |
local:0 |
ldloc*, stloc* |
arg:<idx> |
arg:1 |
ldarg*, starg* |
| (empty) | "" |
no operand (ldarg.0, add, ret, …) |
calli / InlineSig is not supported.
Assume TestIL.dll contains public static int AddOne(int x) => x + 1;.
# 1. Find the method (parameter_types disambiguates overloads).
curl -s -X POST http://localhost:3000/ -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"list_methods",
"arguments":{"assembly_name":"TestIL","type_full_name":"TestIL.Simple"}}}'
# 2. Read the IL.
curl -s -X POST http://localhost:3000/ -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"get_method_il",
"arguments":{"assembly_name":"TestIL","type_full_name":"TestIL.Simple","method_name":"AddOne"}}}'
# Instructions include: {"index":1,"opcode":"ldc.i4.1","operand":""}
# 3. Replace the +1 with +41.
curl -s -X POST http://localhost:3000/ -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"patch_method_il",
"arguments":{"assembly_name":"TestIL","type_full_name":"TestIL.Simple","method_name":"AddOne",
"edits":[{"op":"replace","index":1,"opcode":"ldc.i4","operand":"int:41"}]}}}'
# 4. Save. Original file is backed up to <path>.<yyyyMMdd-HHmmss>.bak first.
curl -s -X POST http://localhost:3000/ -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"save_assembly",
"arguments":{"assembly_name":"TestIL"}}}'Reload the saved DLL in a fresh process and AddOne(10) returns 51 instead of 11.
- No Ctrl+Z.
patch_method_ildoes not route through dnSpy's undo stack. Userevert_method_il— the snapshot is taken the first time a given method is patched, and dropped after revert or after a successful save. - dnSpy's in-memory view is not refreshed after save. Reopen the assembly in dnSpy to see the saved state in the running instance.
- GAC paths are refused. Saving
mscorlibetc. returns a-32602error. - Instruction-level only. Adding / removing locals or exception handlers is out of scope;
get_method_ilexposes them read-only.
Head to Releases and download the bundle that matches your system — the extension is already placed inside, no paths to figure out:
| File | Contents | Runtime requirement |
|---|---|---|
dnSpy-MCP-win-x64.zip |
dnSpy .NET 10 self-contained x64 + MCP extension | None — runtime is bundled |
dnSpy-MCP-win-x86.zip |
dnSpy .NET 10 self-contained x86 + MCP extension | None — runtime is bundled |
dnSpy-MCP-net48.zip |
dnSpy .NET Framework 4.8 build + MCP extension | .NET Framework 4.8 (default on Windows 10+) |
- Download and unzip anywhere.
- Double-click
dnSpy.exe. - Open View → Options → MCP Server, tick Enable Server, click OK.
That's it. If you already use dnSpy and just want the plugin, see "Plugin-only" below.
- Download the DLL matching your dnSpy runtime:
dnSpy.Extension.MCP-net48.dll— .NET Framework 4.8 dnSpydnSpy.Extension.MCP-net10.0-windows.dll— .NET 10 dnSpy
- Rename to
dnSpy.Extension.MCP.x.dll(the.xsuffix is required by dnSpy's extension loader). - Create the folder
dnSpy.Extension.MCPunder<dnSpy-Install>\bin\Extensions\and put the DLL inside. - Restart dnSpy.
The final path must look exactly like this — same folder name as the DLL stem, .x.dll suffix present, one level deep under Extensions\:
<dnSpy-Install>\
└── bin\
└── Extensions\
└── dnSpy.Extension.MCP\ ← folder (create if missing)
└── dnSpy.Extension.MCP.x.dll ← DLL with the .x suffix
Concrete example if dnSpy is installed at C:\Tools\dnSpy:
C:\Tools\dnSpy\bin\Extensions\dnSpy.Extension.MCP\dnSpy.Extension.MCP.x.dll
If the DLL ends up directly under bin\Extensions\ (no subfolder), or without the .x suffix, dnSpy silently skips it and the MCP Server settings page will not appear.
# Clone dnSpyEx (submodules are required)
git clone --recursive https://github.com/dnSpyEx/dnSpy.git
cd dnSpy
# Clone this extension into the Extensions directory
git clone https://github.com/KernelErr/dnSpy.Extension.MCP.git Extensions/dnSpy.Extension.MCP
# Build (both TFMs)
cd Extensions/dnSpy.Extension.MCP
dotnet build -c Release
# Deploy
cp bin/Release/net10.0-windows/dnSpy.Extension.MCP.x.dll \
<dnSpy-Install>/bin/Extensions/dnSpy.Extension.MCP/Settings live under View → Options → MCP Server:
- Enable Server — starts/stops the HTTP server immediately when toggled and applied.
- Port — preferred TCP port (default
3000). If the port is already in use, the server automatically triesport + 1, up to 20 attempts, and logs which port it actually bound to. Check the Server Log pane for the resolved port. - Host — bind address (default
localhost).
All three transports run on the same HttpListener on the same port. The server picks the right one by inspecting the path, HTTP method, and Accept header of each request.
Single-endpoint transport used by codex and other modern MCP clients. The client POSTs JSON-RPC requests with Accept: application/json, text/event-stream; the server returns the JSON-RPC response inline as application/json and allocates a session on initialize via the Mcp-Session-Id response header. Subsequent POSTs must echo that header. The server also honours GET on the same endpoint for server-initiated SSE and DELETE for teardown.
Both / and /mcp are accepted as the endpoint path.
# 1. Initialize — server returns the session ID in the Mcp-Session-Id header.
curl -i -X POST http://localhost:3000/ \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# HTTP/1.1 200 OK
# Mcp-Session-Id: <sid>
# Content-Type: application/json
# {"jsonrpc":"2.0","id":1,"result":{...}}
# 2. Subsequent calls echo the session header.
curl -X POST http://localhost:3000/ \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <sid>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 3. Tear down explicitly (optional — the server also drops the session on shutdown).
curl -X DELETE http://localhost:3000/ -H "Mcp-Session-Id: <sid>"Codex ~/.codex/config.toml:
[mcp_servers.dnspy-mcp]
type = "streamable-http"
url = "http://localhost:3000"One-shot request/response — POST JSON-RPC to / without text/event-stream in Accept and read the response from the same HTTP response body. Useful for quick curl testing and for MCP clients that only speak plain HTTP.
The server binds all loopback identities, so localhost, 127.0.0.1, and [::1] all work. Opening http://localhost:<port>/ in a browser shows a small status page (the root only speaks JSON-RPC/SSE, so a browser GET returns that page rather than a 404).
curl -s http://localhost:3000/health
# {"status":"ok","service":"dnSpy MCP Server"}
curl -s http://127.0.0.1:3000/health # also works (not just localhost)
curl -s -X POST http://localhost:3000/ \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'Legacy two-endpoint transport kept for backwards compatibility with MCP Inspector and older clients: a long-lived SSE stream, plus a POST endpoint for client messages.
GET /sse— openstext/event-stream. The first event (event: endpoint) carries the URL the client should POST to (/message?sessionId=<id>).POST /message?sessionId=<id>— accepts a JSON-RPC request, returns202 Accepted, and writes the real JSON-RPC response onto the corresponding SSE stream as anevent: message.
# Terminal A: open the stream and keep it open
curl -N http://localhost:3000/sse
# event: endpoint
# data: /message?sessionId=<sessionId>
# ... (later, once POST arrives) ...
# event: message
# data: {"jsonrpc":"2.0","id":1,"result":...}
# Terminal B: send a request on that session
curl -X POST "http://localhost:3000/message?sessionId=<sessionId>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# HTTP 202 Accepted — the response appears on Terminal A's SSE streamUse the CLI to register the server once — it picks up the Streamable HTTP transport at /:
claude mcp add --transport http dnspy http://localhost:3000
# verify:
claude mcp listOr add it to a checked-in .mcp.json at your project root (scoped to the project):
{
"mcpServers": {
"dnspy": {
"type": "http",
"url": "http://localhost:3000"
}
}
}Run /mcp inside Claude Code to confirm dnspy is connected and list its tools.
{
"mcpServers": {
"dnspy": {
"command": "http",
"args": ["http://localhost:3000"]
}
}
}See the Streamable HTTP section above for the ~/.codex/config.toml snippet.
# Single-TFM builds for fast iteration
dotnet build -c Debug -f net48
dotnet build -c Debug -f net10.0-windowsdnSpy.Extension.MCP/
├── .github/workflows/ GitHub Actions (build, release)
├── McpServer.cs HttpListener HTTP + SSE + Streamable HTTP + port fallback
├── McpProtocol.cs JSON-RPC 2.0 / MCP DTOs
├── McpTools.cs Analysis tools + MEF export + dispatch (sealed partial)
├── McpTools.IL.cs IL view/patch/revert/save + operand renderer & parser
├── McpSettings.cs Settings view-model + persistence + log (disk log in Debug only)
├── McpSettingsPage.cs IAppSettingsPageProvider for dnSpy settings dialog
├── BepInExResources.cs Embedded BepInEx docs (6 resources)
├── TheExtension.cs IExtension entry point; starts server on Loaded
├── tests/fixtures/ TestIL.cs + build-fixture.ps1 + run-tests.ps1 (E2E harness)
└── dnSpy.Extension.MCP.csproj
- Targets:
net48andnet10.0-windows(inherited fromDnSpyCommon.props). - Transport: a single
HttpListenerserves the plain HTTP JSON-RPC, 2024-11-05 SSE, and 2025-03-26 Streamable HTTP paths on one port. Kestrel is intentionally not used — dnSpy's self-contained .NET bundle does not ship ASP.NET Core, so anyMicrosoft.AspNetCore.*reference would cause a silentTypeLoadExceptionduring MEF composition and the extension'sIExtensionpart would never instantiate. - MEF: services use
[Export(typeof(T))]+[ImportingConstructor]. Don'tnewupMcpServer/McpSettings/McpTools. - UI-thread marshalling: every tool handler in
ExecuteToolruns on the WPF dispatcher.IDocumentTreeViewnodes areDispatcherObjects and throw "calling thread cannot access this object" if read from an HTTP worker, so marshalling is mandatory; handlers that already take the dispatcher path (patch, revert, save) double-wrap harmlessly. - Error codes:
ArgumentExceptioninside a tool handler → JSON-RPC-32602(invalid params); any other exception →-32603(internal error). - Logging:
McpSettings.Log(...)writes to the in-UI log pane always, and toD:\dnspy-mcp.logonly in Debug builds. Release builds keep everything in-memory; no writableD:drive is required on end-user machines.
Implements MCP version 2024-11-05 over JSON-RPC 2.0.
Supported methods: initialize, ping, tools/list, tools/call, resources/list, resources/read, and notifications/*.
.github/workflows/build.yml— builds both TFMs on every push/PR..github/workflows/release.yml— builds release DLLs and attaches them to the GitHub release on tag push (v*.*.*).
git tag v1.0.0
git push origin v1.0.0- Dependencies:
dnSpy.Contracts.DnSpy,dnSpy.Contracts.Logic,dnlib,System.Text.Json(package onnet48, in-box onnet10.0-windows). - BFS path finding:
find_path_to_typedoes breadth-first search over each type's fields and properties. - Decompilation: uses dnSpy's default decompiler (usually C#) via
IDecompilerService. - IL writing:
save_assemblycalls((ModuleDefMD)module).NativeWrite(path, NativeModuleWriterOptions)for modules loaded from disk (preserves native stubs, Win32 resources, delay-loaded imports, mixed-mode code) andmodule.Write(path, ModuleWriterOptions)for freshly constructed modules. Memory-mapped I/O is disabled viapeImage as dnlib.PE.IInternalPEImagebefore the write — the internalIMmapDisablerindnSpy.AsmEditoris inlined to avoid depending on AsmEditor. - Cross-method references in
patch_method_iloperands (method:,field:,type:) are resolved by walking every loaded module for aFullNamematch and then imported into the destination module vianew Importer(module, ImporterOptions.TryToUseDefs).
Most commonly a MEF composition failure for the IExtension part while IAppSettingsPageProvider (the settings page) composes fine. Symptoms: the MCP Server page exists and lets you toggle Enable Server, but nothing happens on click and no log ever appears. Root cause is usually a missing runtime dependency — check the on-disk fallback log first, and make sure you deployed the DLL matching your dnSpy TFM.
The server automatically falls back to port + 1 (up to 20 tries). Look for Port N is in use; falling back to M in the log — clients should connect to the fallback port.
- Ensure you cloned dnSpyEx with
--recursive(submodules must be initialized). - Run
dotnet restorein the dnSpyEx repo root. - Requires .NET 10 SDK (
DnSpyCommon.propsis the source of truth).
Same as dnSpyEx — see the dnSpyEx repository.
- dnSpyEx — .NET debugger and assembly editor
- Model Context Protocol — Anthropic's MCP specification
- BepInEx — Unity game modding framework