Version 9 — user guide
MCP server for inspecting .NET assemblies
An AI agent that needs to look inside a compiled .NET assembly usually shells out to
ildasm (an IL dump) or ilspycmd (a full C# decompile) through a
generic Bash tool. Both dump an entire file as text. That output does not
evaporate once it has been read: it stays in the agent's context as input tokens, re-sent on
every subsequent turn. Inspecting a single class can cost thousands of tokens, and the cost
recurs for the rest of the session.
The target measured below is ICSharpCode.Decompiler.dll 9.1.0.7988 — a 2.4 MB
assembly with 741 public types. A full decompile of it costs the same regardless of how narrow
the actual question is, which is why the baseline column repeats.
| Task | ilspycmd cost | ILens tool | ILens cost |
|---|---|---|---|
| List the public types in a namespace | 1,041,476 tokens | list_types | 1,196 tokens |
| See the API surface of one mid-sized class | 1,041,476 tokens | summarize_type | 895 tokens |
| Find which types expose a given method | 1,041,476 tokens | find_methods | 618 tokens |
Measured against ICSharpCode.Decompiler.dll 9.1.0.7988 on
2026-08-03; token figures are character counts divided by 4, not a real tokenizer.
ILens turns assembly inspection into bounded, targeted lookups instead of full-file dumps — which is what makes it cheap enough to leave registered as an always-available tool.
ILens is an MCP server that speaks JSON-RPC over stdio and exposes tools in four categories:
discovery (finding types and methods by name or signature), inspection (API surface and
cross-reference analysis), decompilation (C# source for a type, method, property, or event),
and comparison (what differs between two builds). It is read-only, restricted to the
directories named by --allow-root, makes no network calls, and ships as a single
self-contained Windows binary.
Scope. ILens is built for inspecting C# assemblies, and its output is C#.
The metadata-driven tools — list_types, search_types,
list_members, find_methods, analyze — read
language-agnostic metadata and work on an assembly produced by any .NET language. The
decompiling tools — decompile_type, decompile_method,
decompile_property, decompile_event, summarize_type —
always emit C#. An assembly compiled from F#, VB.NET, or C++/CLI still decompiles, but
constructs the source language has and C# does not may render unfaithfully.
ILens would be nothing without ILSpy. The decompiler, the analyzer infrastructure, and the
type system that lets ILens talk about a .NET assembly in C# terms at all are not ILens's
work — they belong to the ILSpy team. ILens links ICSharpCode.Decompiler for the
decompiler proper and ICSharpCode.ILSpyX for the analyzer infrastructure, and is,
candidly, a thin MCP shim on top of both.
That upstream work is MIT-licensed, which is the only reason ILens can exist at all. If ILens is useful to you, please consider giving ILSpy a star. They earned it.
To be clear: bug reports and feature requests about ILens belong on the ILens issue tracker, not ILSpy's. The upstream maintainers should not have to inherit downstream problems on top of their own.
System requirements: Windows 10/11, x64. The binary is self-contained — no separate .NET runtime install is required.
winget install Tadis.ILens
A standard winget portable install. It downloads the release ZIP, extracts it under
%LOCALAPPDATA%\Microsoft\WinGet\Packages\Tadis.ILens_*\, and registers
ilens as a command alias in %LOCALAPPDATA%\Microsoft\WinGet\Links\ —
a directory already on your user PATH if you have ever installed another winget portable
package. No admin elevation is needed.
For alternative installers see Appendix A; for updating and uninstalling see Appendix B; for installation troubleshooting see Appendix C.
Add an entry to .mcp.json in your project root. The command field
is the literal string "ilens" — the installer puts the binary on your PATH, so no
full path is needed.
{
"mcpServers": {
"ilens": {
"command": "ilens",
"args": [
"--allow-root", "C:\\path\\to\\dlls"
]
}
}
}
A first install needs a full restart, not just a new session. Two
independent gates have to clear before a freshly installed ilens actually launches
as an MCP server in a Claude app session.
The first is PATH propagation. Windows captures a process's
PATH from the registry when that process starts, and later registry changes do not
retroactively reach already-running processes. If ilens is a brand-new entry on
your user PATH — true for a first-ever winget portable install, which adds
%LOCALAPPDATA%\Microsoft\WinGet\Links\, and for any install.ps1 run,
which adds %LOCALAPPDATA%\Programs\ILens\ — an already-running Claude app holds a
stale environment, and every Claude Code session inside it inherits that stale environment from
the parent app. Subsequent winget updates of an already-installed package do not touch PATH and
do not trigger this gate.
The second is MCP-server registration. Claude Code reads
.mcp.json only at session start, so editing it inside a running session does not
register the new server in that session.
Recovering takes both: fully exit and relaunch Claude app, so the new app process gets the current registry PATH, and then start a new Claude Code session in the relaunched app. Existing Claude Code sessions survive a Claude app restart — the relaunched app reattaches to surviving session processes with their original environments intact — so a resumed session picks up neither change. Only a freshly spawned session inside the freshly launched app clears both gates.
The recipe for a first install via the Claude app: close Claude app entirely → install via
winget (or install.ps1) → relaunch Claude app → start a new Claude
Code session, not a resumed one.
Each --allow-root flag adds a directory tree from which assemblies may be
loaded. Without any --allow-root flags the server cannot load anything at all. Pass
the flag more than once to configure several roots.
For the equivalent claude mcp add CLI command see
Appendix D; for Claude Desktop see
Appendix E.
Registering the server makes the tools available. It does not make Claude
prefer them. Left alone, a model will still reach for ildasm through
Bash, try to read the assembly file directly, or web-search for source that may not
match your build. Closing that gap takes one paste-ready block in the consuming project's
CLAUDE.md.
Drop the following into your project's CLAUDE.md, replacing
<allow-root> with the directory you configured above:
## Inspecting .NET assemblies
Assemblies under `<allow-root>` are reachable through the `ilens` MCP server.
Prefer ILens tools over running `ildasm` / `ilspycmd` via Bash, reading assembly
files directly, or web-searching for source.
- Discovery: `search_types` (substring match), `list_types` (whole namespace),
`find_methods` (signature search).
- Reading: `summarize_type` (public surface, no bodies), `list_members`
(filtered surface), `decompile_type` (full C#), `decompile_method` (single
method body), `decompile_property` / `decompile_event` (full property or
event declaration with accessor bodies, by unprefixed name).
- Cross-references: `analyze` with `kind` set to one of `UsedBy`,
`InstantiatedBy`, `ExposedBy`, `ExtensionMethods`, `AppliedTo`,
`OverriddenBy`, `ImplementedBy`, `Uses`, `Implements`, `ReadBy`,
`AssignedBy`. Valid kinds depend on the symbol category.
- Comparing two builds: `list_changed_types` (what differs between two
assemblies), `compare_type` (per-type member and body diff), `compare_method`
(one method's body as C# or IL).
Bash.search_types, a known namespace to list_types, a method
shape to find_methods.summarize_type
first, list_members when only part of the surface is needed,
decompile_method for one method body (or decompile_property /
decompile_event for a whole property or event without having to know the IL
accessor prefix), and decompile_type only when full source is required.analyze bullet enumerates the kind enum so the model picks
values the schema accepts — using one that does not apply to the symbol category produces an
error like Analysis kind 'ReadBy' is not valid for Method.--allow-root flags are mandatory: with no roots configured, no assembly can be
loaded at all...) are rejected outright.--max-total-size <MB>): a load that would exceed the budget evicts
least-recently-used assemblies, and a single assembly larger than the whole budget is
refused.Fifteen tools, all read-only. Every tool except list_allowed_roots takes an
assembly path that must resolve inside a configured allow-root; the comparison
tools take two. Errors listed per tool are in addition to the path and budget errors shared by
every assembly-loading tool, which are collected under
Troubleshooting.
Any parameter typed as an array also accepts a lone value: kinds: "Field" means
["Field"], and parameterTypes: "string" means ["string"].
This is schema-driven, so it holds for every array parameter on every tool.
find_methods read-onlySearch a whole assembly for methods matching a signature, combining any of: name substring, return type, parameter types, parameter count, declaring namespace, declaring type, and accessibility.
assembly required stringnamePattern optional stringreturns optional stringbool, IEnumerable, String.parameterTypes optional string[]parameterCount optional intparameterTypes is also set, the two must agree.declaringNamespace optional stringSystem.IO.declaringType optional stringSystem.IO.File. Nested types take
+ or .. Mutually exclusive with declaringTypePattern.declaringTypePattern optional stringaccessibility optional Public | PublicProtected | AllPublicProtected.limit optional intA count line followed by one signature per line, sorted by declaring type then method name,
or No methods match .... Type patterns match by short or full name, generics are
erased at the top level, Nullable<T> is unwrapped, and arrays take a
[] suffix. Constructors and property/event accessors are excluded; operators
(op_*) are included.
declaringType and declaringTypePattern given — they are
mutually exclusive.declaringType names a type that does not exist. An exact name asserts the type
is real, so a typo is an error rather than an empty result.parameterCount contradicts parameterTypes.Length.search_types for finding the type first; decompile_method to read a
match's body.
list_allowed_roots read-onlyList the directories from which assemblies can be loaded. Every assembly
argument passed to another tool must point inside one of these.
None.
One configured root per line, or
No allowed roots configured. The server cannot load any assemblies.
None — this tool never fails.
Any tool taking an assembly parameter.
list_types read-onlyList every type declared in one namespace.
assembly required stringnamespaceName required stringSystem.IO.excludeCompilerGenerated optional booltrue.A count line followed by fully qualified type names sorted by short name, or
No types in namespace '...'.
Only the shared path and budget errors.
search_types when the namespace is unknown; summarize_type to read
one result's surface.
search_types read-onlyFind types whose short name contains a substring, case-insensitively. The namespace is not part of the match, but results come back fully qualified so they can be fed straight into another tool.
assembly required stringpattern required stringStream finds
MemoryStream, FileStream, BufferedStream, and so on.excludeCompilerGenerated optional booltrue.A count line followed by fully qualified names, capped at 50 with a truncation marker, or
No types match '...'.
Only the shared path and budget errors.
list_types once the namespace is known; find_methods to search by
signature instead of by name.
analyze read-onlyRun cross-reference analysis on a type or one of its members — who calls it, who overrides
it, who implements it, what it calls. Omit memberName to analyze the type
itself.
assembly required stringtypeName required stringSystem.String.kind required enumUsedBy, InstantiatedBy,
ExposedBy, ExtensionMethods, AppliedTo,
ImplementedByUsedBy, OverriddenBy,
ImplementedBy, Uses, ImplementsUsedBy, ReadBy,
AssignedBy, Uses, OverriddenBy,
ImplementedByReadBy, AssignedByUsedBy, OverriddenBy,
ImplementedBymemberName optional stringparameterCount optional intmemberName resolves to a method.parameterTypes optional string[]find_methods.limit optional intA header naming the analysis and the resolved symbol, then one fully qualified reference per
line, or (no results).
On a property or event the usage questions are answered through the accessors, because that
is what call sites actually reference: ReadBy consults the getter,
AssignedBy the setter, UsedBy both, and Uses both for the
outgoing direction. On an event, UsedBy covers the add, remove, and invoke
accessors and — for an ordinary field-like event — the field holding the subscriber list, so the
answer includes the code that raises the event and not just the code that subscribes.
The header names what was consulted, e.g. (via get_X, set_X).
kind does not apply to the symbol's category. The message lists
the kinds that do.AppliedTo on a type that does not derive from System.Attribute — an
empty result would be indistinguishable from an attribute applied nowhere.ImplementedBy on a type that is not an interface, for the same reason.ReadBy on a write-only property, or AssignedBy on a read-only one:
the accessor the question resolves to does not exist.parameterCount / parameterTypes;
the message lists the candidates found across the inheritance chain.decompile_method to read a caller's body; find_methods for
name-shaped rather than reference-shaped search.
find_harmony_dependencies read-onlyExtract the full reflective surface of a Harmony-patching assembly as JSON: every patch
target and every reflective field access. Built to answer "do my patches still bind on game
version X?" — pair it with compare_method against two versions of the host
assembly.
assembly required stringIndented JSON of the shape { patches: [...], fieldAccesses: [...] }. Each patch
carries targetType, targetMember, optional paramTypes and
methodType, patchType, resolutionKind,
patchClass, and patchSite. resolutionKind is one of
TypedAttribute, StringTargeted, Attribute,
TargetMethod, TargetMethods, or DynamicTargetMethod; each
field access carries an accessor of AccessToolsField,
FieldRefAccess, or TraverseField.
Telling TypedAttribute from StringTargeted requires probing the
target type for member visibility, so it only fires when the target lives in the same assembly
being scanned. Cross-assembly targets — the typical case for a mod patching a host game — fall
back to Attribute regardless.
Only the shared path and budget errors.
compare_method and list_changed_types for checking the host
assembly across versions.
list_members read-onlyList a type's members grouped by kind — methods, properties, fields, events — one signature
per line, no bodies. Cheaper than summarize_type when only part of the surface is
needed.
assembly required stringtypeName required stringkinds optional ("Method" | "Property" | "Field" | "Event")[]accessibility optional Public | PublicProtected | AllPublicProtected.namePattern optional stringincludeInherited optional boolSystem.Object. Default
false. Inherited entries are tagged with the type they came from.limit optional intThe type name, then a section per non-empty kind with its own count, then a truncation marker
if the limit cut the listing short. When nothing matches:
No members on ... match the filter.
kinds.summarize_type for the whole public surface in one call;
decompile_type when bodies are needed.
summarize_type read-onlySummarize the public and protected API surface of a type — signatures only, method bodies stripped. The cheapest way to see what a type offers.
assembly required stringtypeName required stringSystem.String.A C# type declaration with its public and protected members, bodies removed.
list_members for a filtered subset; decompile_type for full
source.
decompile_event read-onlyDecompile one event by its plain name and get back the full C# event declaration with its add and remove accessor bodies.
assembly required stringtypeName required stringSystem.IO.FileSystemWatcher.eventName required stringChanged.A comment header naming the event (and the base type it was inherited from, when it was not declared on the requested type), then the decompiled declaration.
decompile_method with add_X / remove_X for a single
accessor; analyze with UsedBy for subscribers and raise sites.
decompile_method read-onlyDecompile a single method to C#. Faster and far cheaper than decompile_type when
only one body is needed.
assembly required stringtypeName required stringSystem.IO.File.methodName required stringReadAllText. Property and event accessors are reachable by
their IL name — get_X, set_X, add_X,
remove_X — even though find_methods hides them from generic
browsing.parameterCount optional intparameterTypes is also
given, the two must agree.parameterTypes optional string[]['int','bool'].
Same loose matching as find_methods.A comment header carrying the resolved signature — parameter types and return type, plus an inherited or extension-method note where relevant — followed by the decompiled body. The header reports what resolution actually picked, so a mismatch against what was asked for is visible without reading IL.
parameterCount contradicts parameterTypes.Length.decompile_property / decompile_event for whole declarations;
analyze with UsedBy for callers.
decompile_property read-onlyDecompile one property by its plain name and get back the full C# property declaration with its accessor bodies.
assembly required stringtypeName required stringSystem.IO.FileInfo.propertyName required stringLength.A comment header naming the property (and the base type it was inherited from, when applicable), then the decompiled declaration.
decompile_method on
get_Item / set_Item with parameterTypes.decompile_method with get_X / set_X for one accessor;
analyze with ReadBy or AssignedBy for call sites.
decompile_type read-onlyDecompile a whole type to C# source, including every method body. The most expensive read in the tool set — reach for it only when the narrower tools will not do.
assembly required stringtypeName required stringSystem.IO.File.Full C# source for the type.
summarize_type for signatures only; decompile_method for one
body.
compare_method read-onlyCompare one method across two assemblies and emit each body labeled by source. When the two bodies are identical it emits a single copy with a note rather than duplicating the text.
assemblyA required stringassemblyB required stringtypeName required stringSystem.IO.File.methodName required stringReadAllText.format optional stringcsharp (default) for decompiled C#, or il for the normalized IL
disassembly — the same form list_changed_types and compare_type use to
decide body equality.parameterCount optional intparameterTypes optional string[]find_methods.Both bodies, each labeled with the assembly it came from — or one copy plus a note when they are identical.
format is neither csharp nor il.compare_type for the whole type; list_changed_types to find which
types moved at all.
compare_type read-onlyCompare one type across two assemblies: added and removed members, plus a body-changed flag per method. No C# decompilation is invoked — body equality uses normalized IL.
assemblyA required stringassemblyB required stringtypeName required stringSystem.String.A header stating whether the type is present in both, only in A, or only in B, then one line
per change: + for an added member, - for a removed one, and
~ for a method body that changed while keeping its signature. A type with no
differences reports ... is identical in both assemblies.
Only the shared path and budget errors — a type absent from both assemblies is reported, not thrown.
compare_method for a changed body; list_changed_types for the
assembly-wide sweep.
list_changed_types read-onlyEnumerate the types that differ between two assemblies — added, removed, or changed in metadata, members, or method bodies. A pure metadata and IL walk; the C# decompiler is never invoked.
assemblyA required stringassemblyB required stringnamespaceFilter optional stringSystem.IO. Omit to scan the whole
module.excludeCompilerGenerated optional booltrue.A count line followed by one line per type, prefixed Added:,
Removed:, or Changed [...]: with the change kinds named. When nothing
differs: No type changes detected.
Members are matched by name plus parameter and return types, so any signature change surfaces as a remove plus an add. Body equality is decided from IL disassembly with operand tokens resolved to symbolic names, so a body whose source did not change compares equal across rebuilds even when the metadata tokens around it shift.
Only the shared path and budget errors.
compare_type to drill into one result; find_harmony_dependencies
when the question is whether patches still bind.
Every tool error reaches the caller as
Error: <ExceptionType>: <message>. The messages below are shared by
every assembly-loading tool.
No allowed roots configured. The server cannot load any assemblies.The server was launched without --allow-root. Add one or more
--allow-root <directory> arguments to the args array in
.mcp.json and start a new session. list_allowed_roots reports the same
condition without throwing.
Path is not under any allowed root: ...The requested assembly lives outside every configured root. The message lists the roots that
are configured. Either point the call at a file inside one, or add the containing directory as
another --allow-root.
Path resolves (via a symbolic link or junction) to a location outside any allowed root: ...The path passed the lexical containment check but resolves to a real target outside the roots. This is the reparse-point check doing its job — a link planted inside a root cannot be used to reach outside it.
Path contains '..' which is not allowed: ...Parent-directory segments are rejected before normalization. Pass an absolute path with no
.. components.
Assembly file does not exist: ...The path is inside a root but names no file. Check the spelling and that the build actually produced the assembly.
Assembly is N MB, which exceeds the M MB total memory budget on its own: ...A single assembly larger than the whole budget can never fit, even with an empty cache. Raise
the budget with --max-total-size <MB>.
Type not found: ...The fully qualified name did not resolve. Nested types use + as the separator,
though a dotted form is tried as a fallback. Use search_types to find the exact
name.
Method '...' not found on ..., its base types, or as an extension method.The name does not exist anywhere on the inheritance chain. find_methods with
declaringType lists what the type actually has.
No overload of '...' matches ...A parameterCount or parameterTypes hint was given and nothing
satisfies it. The message lists every same-named overload found across the chain, so the right
hint is usually visible in the error itself.
Member '...' is ambiguous — it exists as more than one member kind ...The name is used by more than one kind of member on the type — obfuscated or non-C# metadata.
Use a kind-specific tool (decompile_property, decompile_event) or pass
an overload hint if the method is meant. An ordinary C# event does not trigger this: its
compiler-generated backing field is recognized as part of the event.
Unknown argument '...' for tool '...'. Valid arguments: ...An argument name the tool does not declare. Most ILens filters are optional, so a silently dropped filter would return an unfiltered result that looks like an answer — the call is rejected instead. The message lists the valid names.
Analysis kind '...' is not valid for ...The kind does not apply to what analyze resolved the symbol to. The
message lists the kinds that do apply to that category.
Almost always the two gates described under
Integrating with Claude Code: an already-running Claude app holding a
stale PATH, or a session that started before .mcp.json existed. Close Claude app
entirely, relaunch it, and start a new session.
For machines where winget is unavailable or locked down by policy, or when you would rather inspect the binary before granting it execution.
irm https://raw.githubusercontent.com/tadis174/ILens/main/install.ps1 | iex
Installs to %LOCALAPPDATA%\Programs\ILens\ and adds that directory to your user
PATH. No admin elevation is needed.
Download ILens-windows-x64.zip from the
Releases page and extract it
anywhere. Then either add the directory to PATH yourself, or give the binary's full path in
.mcp.json's command field.
winget. winget upgrade Tadis.ILens to update;
winget uninstall Tadis.ILens to remove.
PowerShell one-liner. Re-run
irm https://raw.githubusercontent.com/tadis174/ILens/main/install.ps1 | iex to
update — the script overwrites the install directory in place and stops any running ILens
processes first. Uninstall with
irm https://raw.githubusercontent.com/tadis174/ILens/main/uninstall.ps1 | iex.
Manual ZIP. No automatic update mechanism — re-download
ILens-windows-x64.zip and re-extract on each new release. To uninstall, delete the
extracted folder and remove the PATH entry if you added one.
ILens.exeSelf-contained .NET binaries are sometimes false-positive-flagged on first sight. Check the
quarantine and restore the file, or add an exception for the install directory —
%LOCALAPPDATA%\Microsoft\WinGet\Packages\Tadis.ILens_*\ for winget,
%LOCALAPPDATA%\Programs\ILens\ for the PowerShell script — before re-running the
installer.
This affects browser-downloaded ZIP installs, which carry Mark-of-the-Web. The winget and PowerShell paths do not. For a manual browser download, click "More info → Run anyway".
winget upgrade Tadis.ILens fails with remove: Access is denied: "...\ILens.exe"A running ILens MCP server — a Claude Code session, Claude Desktop, or another AI client that
registered ILens — holds an exclusive lock on the binary winget is trying to replace. Close every
AI client with ILens registered and re-run the upgrade. The PowerShell install.ps1
path handles this automatically by stopping running ILens processes before extraction, so the
same upgrade via irm ... | iex needs no manual cleanup. winget's
portable installer type has no pre-install hook where an equivalent step could
run.
claude mcp add CLIA programmatic alternative for anyone who would rather not edit .mcp.json by
hand:
claude mcp add ilens ilens -- --allow-root "C:\path\to\dlls"
The doubled ilens is intentional: the first is the MCP server alias — the same
string that becomes the JSON key in .mcp.json — and the second is the binary on
PATH after install. The -- separator before --allow-root is required so
claude mcp add does not consume it as one of its own options.
Claude Desktop reads %APPDATA%\Claude\claude_desktop_config.json. The entry is
equivalent to the Claude Code one:
{
"mcpServers": {
"ilens": {
"command": "ilens",
"args": [
"--allow-root", "C:\\path\\to\\dlls"
]
}
}
}
Claude Desktop has no per-project CLAUDE.md, so paste the snippet from
Project-level guidance into your Claude Desktop Project's custom
instructions instead.
This guide documents ILens 9. Source and issue tracker: github.com/tadis174/ILens.
ILens is released under the MIT License; the full text ships as LICENSE beside
this guide in the release ZIP. Every third-party dependency and bundled runtime component is
mapped to its upstream license in third-party-licenses/INDEX.md, included in the
same ZIP alongside the per-source license texts.