---
title: Architecture
description: Internal architecture of mcilspy -- modules, data flow, and key design patterns.
---

import { Aside } from "@astrojs/starlight/components";

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.

## Data Flow

```mermaid
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.

## Module Map

| Module | Lines | Responsibility |
|--------|------:|----------------|
| `server.py` | 2,022 | FastMCP tool definitions for all 16 tools, 2 prompts, lifespan management, input validation, and error formatting |
| `ilspy_wrapper.py` | 767 | Async subprocess wrapper around `ilspycmd` -- builds CLI arguments, runs the process, parses stdout/stderr, enforces timeouts |
| `metadata_reader.py` | 684 | dnfile-based PE metadata parsing -- reads MethodDef, Field, Property, Event, ManifestResource, and Assembly tables directly |
| `models.py` | 263 | Pydantic `BaseModel` classes for all request/response types, plus `LanguageVersion` and `EntityType` enums |
| `il_parser.py` | 147 | Post-processing extraction of individual methods from type-level IL or C# output (used by `decompile_method`) |
| `constants.py` | 54 | Shared configuration values -- timeouts, output limits, search limits, entity type lists |
| `utils.py` | 50 | Helper for locating the `ilspycmd` binary across platforms (PATH, `~/.dotnet/tools`, Windows USERPROFILE) |

## Key Design Patterns

### Lazy Initialization

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.

```python
_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
```

### 5-Minute Subprocess Timeout

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`:

```python
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.

### PATH Auto-Discovery

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 `~`

### Secure Subprocess Execution

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.

### Output Truncation Guard

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.

### Standardized Error Format

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.

## Entry Points

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.

<Aside type="note">
  The module-level `_cached_wrapper` global is intentionally used instead of
  FastMCP's lifespan context. This avoids complex context-threading through
  every tool function while still providing lazy initialization and clean
  shutdown (the lifespan clears the cache on exit).
</Aside>
