Ryan Malloy 643576111c docs: add Starlight docs site for mcilspy.warehack.ing
Follows the warehack.ing cookie-cutter pattern (Caddy + multi-stage Docker).
34 content pages covering Getting Started, 7 Guides, 16 Tool Reference pages,
MCP Prompts, Data Models, Changelog, and Development docs. Steel blue + amber
theme with Pagefind search and Mermaid diagram support.
2026-03-02 18:18:43 -07:00

110 lines
5.3 KiB
Plaintext

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