Skip to content

Architecture

mcilspy is structured as a thin MCP layer over two independent backends: ilspycmd for full decompilation and dnfile for direct metadata parsing. The design keeps the FastMCP tool definitions separate from the backend logic, so either engine can be used (or replaced) independently.

flowchart LR
    Client["MCP Client"]
    Server["server.py\n(FastMCP tools)"]
    Wrapper["ilspy_wrapper.py\n(async subprocess)"]
    ILSpy["ilspycmd CLI"]
    MetaReader["metadata_reader.py\n(dnfile parser)"]
    PE["PE/.NET binary"]

    Client -->|"JSON-RPC"| Server
    Server -->|"decompilation tools"| Wrapper
    Wrapper -->|"create_subprocess_exec"| ILSpy
    ILSpy -->|"reads"| PE
    Server -->|"metadata tools"| MetaReader
    MetaReader -->|"dnfile.dnPE"| PE

Decompilation tools flow through the ILSpyWrapper, which manages ilspycmd as an async subprocess. Metadata tools bypass ilspycmd entirely and read PE metadata tables through dnfile. Both paths share the same Pydantic models for type-safe data exchange.

ModuleLinesResponsibility
server.py2,022FastMCP tool definitions for all 16 tools, 2 prompts, lifespan management, input validation, and error formatting
ilspy_wrapper.py767Async subprocess wrapper around ilspycmd — builds CLI arguments, runs the process, parses stdout/stderr, enforces timeouts
metadata_reader.py684dnfile-based PE metadata parsing — reads MethodDef, Field, Property, Event, ManifestResource, and Assembly tables directly
models.py263Pydantic BaseModel classes for all request/response types, plus LanguageVersion and EntityType enums
il_parser.py147Post-processing extraction of individual methods from type-level IL or C# output (used by decompile_method)
constants.py54Shared configuration values — timeouts, output limits, search limits, entity type lists
utils.py50Helper for locating the ilspycmd binary across platforms (PATH, ~/.dotnet/tools, Windows USERPROFILE)

The ILSpyWrapper singleton is not created at server startup. It is lazily initialized on the first call to a decompilation tool via get_wrapper(). This means the server starts successfully even if ilspycmd is not installed — the metadata tools work fine, and the diagnostics tools (check_ilspy_installation, install_ilspy) remain available.

_cached_wrapper: ILSpyWrapper | None = None
def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper:
global _cached_wrapper
if _cached_wrapper is None:
_cached_wrapper = ILSpyWrapper()
return _cached_wrapper

Every ilspycmd invocation is wrapped with asyncio.wait_for(..., timeout=300.0). This prevents runaway processes from corrupted or adversarial assemblies. The timeout constant lives in constants.py:

DECOMPILE_TIMEOUT_SECONDS: float = 300.0 # 5 minutes

If a process exceeds this limit, it is killed and a clear error message is returned to the MCP client.

MCP servers often run in restricted environments where ~/.dotnet/tools is not in PATH. The find_ilspycmd_path() utility in utils.py checks three locations:

  1. Standard PATH (via shutil.which)
  2. ~/.dotnet/tools/ilspycmd (default for dotnet tool install --global)
  3. Windows %USERPROFILE%\.dotnet\tools\ when it differs from ~

All subprocess calls use asyncio.create_subprocess_exec (not create_subprocess_shell). Assembly paths are passed as individual arguments, never interpolated into shell strings. This eliminates shell injection as an attack vector — important because assembly paths come from untrusted MCP client input.

The decompile_assembly tool accepts a max_output_chars parameter (default 100,000). When decompiled output exceeds this limit, the full text is written to a temp file and the tool returns a truncated preview with the file path. This prevents large assemblies from overwhelming the MCP client’s context window. Setting max_output_chars=0 disables truncation.

All tools use a consistent _format_error() helper that produces messages in the format:

**Error** (context): description

This makes errors easy to parse in both conversational and programmatic contexts.

The package defines two entry points:

  • CLI: mcilspy command (via [project.scripts] in pyproject.toml) calls server:main(), which starts the FastMCP server on stdio transport.
  • Module: python -m mcilspy triggers __main__.py, which also calls main().

Both paths print a startup banner to stderr (stdout is reserved for the MCP JSON-RPC protocol) and then enter the FastMCP event loop.