feat: add output truncation guard and method-level decompilation

Large types (e.g., 15.5M chars of IL from CyBGElaborator) crashed MCP
clients. Two fixes:

1. decompile_assembly now truncates output exceeding max_output_chars
   (default 100K), saves the full output to a temp file, and returns
   a preview with recovery instructions. Set max_output_chars=0 to
   disable.

2. New decompile_method tool extracts individual methods from a type.
   IL mode uses ECMA-335 .method/end-of-method delimiters. C# mode
   uses signature matching with brace-depth counting. Handles
   overloads automatically.

New module: il_parser.py with extract_il_method() and
extract_csharp_method() functions, keeping parsing logic separate
from server.py.
This commit is contained in:
Ryan Malloy 2026-03-02 17:19:42 -07:00
parent ad72b013ca
commit f15905b350
7 changed files with 929 additions and 20 deletions

View File

@ -79,6 +79,7 @@ Learn from decompiled implementations when documentation falls short.
| Tool | What It Does | Requires ilspycmd? | | Tool | What It Does | Requires ilspycmd? |
|------|--------------|:------------------:| |------|--------------|:------------------:|
| `decompile_assembly` | Full C# source recovery (with PDB generation) | Yes | | `decompile_assembly` | Full C# source recovery (with PDB generation) | Yes |
| `decompile_method` | Extract a specific method from a type | Yes |
| `list_types` | Enumerate classes, interfaces, enums | Yes | | `list_types` | Enumerate classes, interfaces, enums | Yes |
| `search_types` | Find types by name pattern | Yes | | `search_types` | Find types by name pattern | Yes |
| `search_strings` | Find hardcoded strings (URLs, keys) | No | | `search_strings` | Find hardcoded strings (URLs, keys) | No |
@ -95,7 +96,7 @@ Learn from decompiled implementations when documentation falls short.
| `install_ilspy` | Auto-install .NET SDK + ilspycmd | No | | `install_ilspy` | Auto-install .NET SDK + ilspycmd | No |
**7 tools work immediately** (via [dnfile](https://github.com/malwarefrank/dnfile)) — no .NET SDK required. **7 tools work immediately** (via [dnfile](https://github.com/malwarefrank/dnfile)) — no .NET SDK required.
**8 more tools** unlock with `ilspycmd` for full decompilation power. **9 more tools** unlock with `ilspycmd` for full decompilation power.
--- ---
@ -139,7 +140,7 @@ dotnet tool install --global ilspycmd
- [Features at a Glance](#features-at-a-glance) - [Features at a Glance](#features-at-a-glance)
- [Installation Options](#installation-options) - [Installation Options](#installation-options)
- [Tool Reference](#tool-reference) - [Tool Reference](#tool-reference)
- [Decompilation Tools](#decompilation-tools-requires-ilspycmd) - [Decompilation & Extraction Tools](#decompilation-tools-requires-ilspycmd)
- [Metadata Tools](#metadata-tools-no-ilspycmd-required) - [Metadata Tools](#metadata-tools-no-ilspycmd-required)
- [Installation Tools](#installation--diagnostics) - [Installation Tools](#installation--diagnostics)
- [Configuration](#configuration) - [Configuration](#configuration)
@ -177,6 +178,30 @@ The primary reverse-engineering tool. Decompiles .NET assemblies to readable C#.
``` ```
</details> </details>
<details>
<summary><strong>decompile_method</strong> — Extract a specific method from a type</summary>
When a type is too large to return in full, use this to extract just the method you need.
Decompiles the containing type, then extracts the named method. IL mode (default) is most reliable.
| Parameter | Required | Description |
|-----------|:--------:|-------------|
| `assembly_path` | Yes | Path to .dll or .exe |
| `type_name` | Yes | Fully qualified type (e.g., `MyNamespace.MyClass`) |
| `method_name` | Yes | Method to extract (all overloads returned) |
| `show_il_code` | No | Output IL instead of C# (default: true) |
| `language_version` | No | C# version (default: Latest) |
| `use_pdb_variable_names` | No | Use original variable names from PDB |
```json
{
"assembly_path": "/path/to/MyApp.dll",
"type_name": "MyApp.Core.CyBGElaborator",
"method_name": "DoElaborate"
}
```
</details>
<details> <details>
<summary><strong>list_types</strong> — Enumerate types in an assembly</summary> <summary><strong>list_types</strong> — Enumerate types in an assembly</summary>

View File

@ -4,7 +4,7 @@ This document provides detailed API documentation for mcilspy.
## Overview ## Overview
mcilspy provides a Model Context Protocol (MCP) interface to the ILSpy .NET decompiler. It exposes **15 tools** and two prompts for interacting with .NET assemblies. mcilspy provides a Model Context Protocol (MCP) interface to the ILSpy .NET decompiler. It exposes **16 tools** and two prompts for interacting with .NET assemblies.
### Tool Categories ### Tool Categories
@ -46,6 +46,7 @@ Decompiles a .NET assembly to C# source code. This is the primary tool for rever
| `nested_directories` | boolean | ✗ | false | Use nested directories for namespaces | | `nested_directories` | boolean | ✗ | false | Use nested directories for namespaces |
| `generate_pdb` | boolean | ✗ | false | Generate portable PDB file (requires `output_dir`) | | `generate_pdb` | boolean | ✗ | false | Generate portable PDB file (requires `output_dir`) |
| `use_pdb_variable_names` | boolean | ✗ | false | Use original variable names from existing PDB | | `use_pdb_variable_names` | boolean | ✗ | false | Use original variable names from existing PDB |
| `max_output_chars` | integer | ✗ | 100000 | Max characters to return inline. Output exceeding this is saved to a temp file with a truncated preview. Set to 0 to disable. |
**Language Versions:** **Language Versions:**
- `CSharp1` through `CSharp12_0` - `CSharp1` through `CSharp12_0`
@ -68,10 +69,52 @@ Decompiles a .NET assembly to C# source code. This is the primary tool for rever
**Response:** **Response:**
Returns decompiled C# source code as text, or information about saved files if `output_dir` is specified. Returns decompiled C# source code as text, or information about saved files if `output_dir` is specified.
### 2. list_types **Output Truncation:** When output exceeds `max_output_chars`, the full output is saved to a temporary file and the response includes:
- A truncation notice with character counts
- The path to the full output file
- Recovery options (read file, use `decompile_method`, adjust limit)
- A preview of the first portion of the output
### 2. decompile_method
Extracts a specific method from a .NET type. Decompiles the containing type, then uses pattern matching to extract just the method(s) you need. Handles overloads automatically.
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `assembly_path` | string | ✓ | - | Path to the .NET assembly file (.dll or .exe) |
| `type_name` | string | ✓ | - | Fully qualified type name (e.g., "MyNamespace.MyClass") |
| `method_name` | string | ✓ | - | Method name to extract (all overloads returned) |
| `show_il_code` | boolean | ✗ | true | Output IL bytecode (default, more reliable for extraction) |
| `language_version` | string | ✗ | "Latest" | C# language version to use |
| `use_pdb_variable_names` | boolean | ✗ | false | Use original variable names from existing PDB |
**IL Extraction** (default, `show_il_code=true`): Uses ECMA-335 standard delimiters — `.method` directives and `} // end of method` comments — for reliable extraction.
**C# Extraction** (`show_il_code=false`): Uses method signature matching with brace-depth counting. Works well but may miss edge cases with unconventional formatting.
**Example:**
```json
{
"name": "decompile_method",
"arguments": {
"assembly_path": "/path/to/MyAssembly.dll",
"type_name": "MyNamespace.CyBGElaborator",
"method_name": "DoElaborate"
}
}
```
**Response:**
Returns the extracted method body with code fencing. If multiple overloads match, all are returned with overload labels.
### 3. list_types
Lists types (classes, interfaces, structs, etc.) in a .NET assembly. Typically the **first tool to use** when analyzing an unknown assembly. Lists types (classes, interfaces, structs, etc.) in a .NET assembly. Typically the **first tool to use** when analyzing an unknown assembly.
> **Note:** Tool numbering in this document reflects the addition of `decompile_method` at position 2.
**Parameters:** **Parameters:**
| Parameter | Type | Required | Default | Description | | Parameter | Type | Required | Default | Description |
@ -104,7 +147,7 @@ Returns a formatted list of types organized by namespace, including:
- Type kind (Class, Interface, etc.) - Type kind (Class, Interface, etc.)
- Namespace - Namespace
### 3. search_types ### 4. search_types
Search for types by name pattern. Essential for finding specific classes in large assemblies. Search for types by name pattern. Essential for finding specific classes in large assemblies.
@ -142,7 +185,7 @@ Search for types by name pattern. Essential for finding specific classes in larg
**Response:** **Response:**
Returns matching types grouped by namespace with full names for use with `decompile_assembly`. Returns matching types grouped by namespace with full names for use with `decompile_assembly`.
### 4. search_strings ### 5. search_strings
Search for string literals in assembly code. Crucial for reverse engineering - finds hardcoded strings. Search for string literals in assembly code. Crucial for reverse engineering - finds hardcoded strings.
@ -179,7 +222,7 @@ Search for string literals in assembly code. Crucial for reverse engineering - f
**Response:** **Response:**
Returns matching code lines grouped by type, with context about the containing method. Returns matching code lines grouped by type, with context about the containing method.
### 5. generate_diagrammer ### 6. generate_diagrammer
Generates an interactive HTML diagram showing assembly type relationships. Generates an interactive HTML diagram showing assembly type relationships.
@ -208,7 +251,7 @@ Generates an interactive HTML diagram showing assembly type relationships.
**Response:** **Response:**
Returns success status and output directory path. The HTML file can be opened in a web browser to view the interactive diagram. Returns success status and output directory path. The HTML file can be opened in a web browser to view the interactive diagram.
### 6. dump_package ### 7. dump_package
Dump package assemblies into a folder. Extracts all assemblies from a NuGet package folder structure into a flat directory for easier analysis. Dump package assemblies into a folder. Extracts all assemblies from a NuGet package folder structure into a flat directory for easier analysis.
@ -238,7 +281,7 @@ Dump package assemblies into a folder. Extracts all assemblies from a NuGet pack
**Response:** **Response:**
Returns a summary including output directory path and list of extracted assemblies. Returns a summary including output directory path and list of extracted assemblies.
### 7. get_assembly_info ### 8. get_assembly_info
Gets metadata and version information about a .NET assembly. Uses ilspycmd to extract detailed assembly attributes. Gets metadata and version information about a .NET assembly. Uses ilspycmd to extract detailed assembly attributes.
@ -273,7 +316,7 @@ Returns assembly metadata including:
These tools use [dnfile](https://github.com/malwarefrank/dnfile) for direct PE/metadata parsing. They do **not require ilspycmd** to be installed. These tools use [dnfile](https://github.com/malwarefrank/dnfile) for direct PE/metadata parsing. They do **not require ilspycmd** to be installed.
### 8. search_methods ### 9. search_methods
Search for methods in an assembly by name pattern. Uses direct metadata parsing of the MethodDef table. Search for methods in an assembly by name pattern. Uses direct metadata parsing of the MethodDef table.
@ -310,7 +353,7 @@ Search for methods in an assembly by name pattern. Uses direct metadata parsing
**Response:** **Response:**
Returns matching methods grouped by declaring type, showing visibility modifiers (public, static, virtual, abstract). Returns matching methods grouped by declaring type, showing visibility modifiers (public, static, virtual, abstract).
### 9. search_fields ### 10. search_fields
Search for fields and constants in an assembly. Uses direct metadata parsing of the Field table. Search for fields and constants in an assembly. Uses direct metadata parsing of the Field table.
@ -344,7 +387,7 @@ Search for fields and constants in an assembly. Uses direct metadata parsing of
} }
``` ```
### 10. search_properties ### 11. search_properties
Search for properties in an assembly by name pattern. Uses direct metadata parsing of the Property table. Search for properties in an assembly by name pattern. Uses direct metadata parsing of the Property table.
@ -364,7 +407,7 @@ Search for properties in an assembly by name pattern. Uses direct metadata parsi
- Locate data model fields - Locate data model fields
- Discover API response/request properties - Discover API response/request properties
### 11. list_events ### 12. list_events
List all events defined in an assembly. Uses direct metadata parsing of the Event table. List all events defined in an assembly. Uses direct metadata parsing of the Event table.
@ -381,7 +424,7 @@ List all events defined in an assembly. Uses direct metadata parsing of the Even
- Discover observer patterns - Discover observer patterns
- Analyze UI event handlers - Analyze UI event handlers
### 12. list_resources ### 13. list_resources
List all embedded resources in an assembly. Uses direct metadata parsing of the ManifestResource table. List all embedded resources in an assembly. Uses direct metadata parsing of the ManifestResource table.
@ -396,7 +439,7 @@ List all embedded resources in an assembly. Uses direct metadata parsing of the
- Discover localization resources - Discover localization resources
- Locate embedded assemblies - Locate embedded assemblies
### 13. get_metadata_summary ### 14. get_metadata_summary
Get a comprehensive metadata summary with accurate statistics. Uses dnfile for direct metadata counts. Get a comprehensive metadata summary with accurate statistics. Uses dnfile for direct metadata counts.
@ -427,7 +470,7 @@ Returns comprehensive assembly information including:
These tools help manage ilspycmd installation and diagnose issues. These tools help manage ilspycmd installation and diagnose issues.
### 14. check_ilspy_installation ### 15. check_ilspy_installation
Check if ilspycmd and dotnet CLI are installed and working. Use this to diagnose issues with decompilation tools. Check if ilspycmd and dotnet CLI are installed and working. Use this to diagnose issues with decompilation tools.
@ -447,7 +490,7 @@ Returns installation status including:
} }
``` ```
### 15. install_ilspy ### 16. install_ilspy
Install or update ilspycmd, the ILSpy command-line decompiler. Automatically detects your platform and package manager to provide optimal installation instructions. Install or update ilspycmd, the ILSpy command-line decompiler. Automatically detects your platform and package manager to provide optimal installation instructions.

View File

@ -28,6 +28,11 @@ MAX_UNPARSED_LOG_LINES: int = 3
# Preview length for unparsed line debug messages # Preview length for unparsed line debug messages
UNPARSED_LINE_PREVIEW_LENGTH: int = 100 UNPARSED_LINE_PREVIEW_LENGTH: int = 100
# Maximum characters to return inline from decompile_assembly before
# truncating and saving the full output to a temp file.
# Set to 0 in a tool call to disable truncation entirely.
DEFAULT_MAX_OUTPUT_CHARS: int = 100_000
# ============================================================================= # =============================================================================
# Search Limits # Search Limits
# ============================================================================= # =============================================================================

147
src/mcilspy/il_parser.py Normal file
View File

@ -0,0 +1,147 @@
"""Method-level extraction from decompiled IL and C# output.
ilspycmd only supports type-level filtering (-t flag). When a type is
very large, clients need to extract individual methods from the full
type output. This module provides that post-processing step.
IL extraction relies on ECMA-335 standard delimiters:
- Start: `.method` directive
- End: `} // end of method TypeName::MethodName`
C# extraction uses signature matching with brace-depth counting.
"""
import re
def extract_il_method(il_source: str, method_name: str) -> list[str]:
"""Extract method(s) by name from IL output.
Splits on `.method` directives and matches against
``} // end of method ...::MethodName`` markers.
Returns list of matching method blocks (handles overloads).
Args:
il_source: Full IL source code from ilspycmd
method_name: Method name to extract (matched against end-of-method comment)
Returns:
List of IL method blocks as strings. Empty list if no matches.
"""
if not il_source or not method_name:
return []
results: list[str] = []
lines = il_source.split("\n")
in_method = False
current_block: list[str] = []
brace_depth = 0
found_opening = False
# Pattern for end-of-method comment: } // end of method TypeName::MethodName
end_pattern = re.compile(
r"^\s*\}\s*//\s*end of method\s+\S+::" + re.escape(method_name) + r"\s*$"
)
# Pattern for .method directive start
method_start = re.compile(r"^\s*\.method\s+")
for line in lines:
if not in_method:
if method_start.match(line):
in_method = True
current_block = [line]
brace_depth = line.count("{") - line.count("}")
found_opening = brace_depth > 0
else:
current_block.append(line)
brace_depth += line.count("{") - line.count("}")
if not found_opening and brace_depth > 0:
found_opening = True
if end_pattern.match(line):
results.append("\n".join(current_block))
in_method = False
current_block = []
brace_depth = 0
elif found_opening and brace_depth <= 0:
# Reached closing brace without matching our method name —
# this was a different method, discard it
in_method = False
current_block = []
brace_depth = 0
return results
def extract_csharp_method(cs_source: str, method_name: str) -> list[str]:
"""Extract method(s) by name from C# output.
Finds method signatures matching the name, then uses
brace-depth counting to find the closing brace.
Returns list of matching method blocks (handles overloads).
Args:
cs_source: Full C# source code from ilspycmd
method_name: Method name to extract
Returns:
List of C# method blocks as strings. Empty list if no matches.
"""
if not cs_source or not method_name:
return []
results: list[str] = []
lines = cs_source.split("\n")
# Match method signatures — handles modifiers, return types, generics
# Examples:
# public void DoElaborate(...)
# private static async Task<int> DoElaborate<T>(...)
# internal override bool DoElaborate(
sig_pattern = re.compile(
r"^(\s*)" # leading whitespace (capture for indent level)
r"(?:(?:public|private|protected|internal|static|virtual|override|"
r"abstract|async|sealed|new|extern|unsafe|partial|readonly)\s+)*"
r".+\s+" # return type (greedy — handles generics with spaces like List<Dictionary<string, int>>)
+ re.escape(method_name)
+ r"\s*(?:<[^>]*>)?" # optional generic type parameters
r"\s*\(", # opening paren
)
i = 0
while i < len(lines):
match = sig_pattern.match(lines[i])
if match:
# Found a method signature — collect until braces balance
block: list[str] = [lines[i]]
brace_depth = lines[i].count("{") - lines[i].count("}")
j = i + 1
# If the signature hasn't opened a brace yet, keep scanning
# for the opening brace (multi-line signatures)
found_opening = brace_depth > 0
while j < len(lines):
block.append(lines[j])
brace_depth += lines[j].count("{") - lines[j].count("}")
if not found_opening and brace_depth > 0:
found_opening = True
if found_opening and brace_depth <= 0:
# Balanced — we have the full method body
results.append("\n".join(block))
i = j + 1
break
j += 1
else:
# Reached end of file without balancing — include what we have
if block:
results.append("\n".join(block))
i = j
else:
i += 1
return results

View File

@ -4,17 +4,18 @@ import os
import platform import platform
import re import re
import shutil import shutil
import tempfile
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from .constants import ( from .constants import (
ALL_ENTITY_TYPES, ALL_ENTITY_TYPES,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_MAX_SEARCH_RESULTS,
MAX_ERROR_OUTPUT_CHARS, MAX_ERROR_OUTPUT_CHARS,
MAX_LINE_LENGTH,
MAX_MATCHES_PER_TYPE,
) )
from .il_parser import extract_csharp_method, extract_il_method
from .ilspy_wrapper import ILSpyWrapper from .ilspy_wrapper import ILSpyWrapper
from .models import EntityType, LanguageVersion from .models import EntityType, LanguageVersion
from .utils import find_ilspycmd_path from .utils import find_ilspycmd_path
@ -584,6 +585,7 @@ async def decompile_assembly(
nested_directories: bool = False, nested_directories: bool = False,
generate_pdb: bool = False, generate_pdb: bool = False,
use_pdb_variable_names: bool = False, use_pdb_variable_names: bool = False,
max_output_chars: int = DEFAULT_MAX_OUTPUT_CHARS,
ctx: Context | None = None, ctx: Context | None = None,
) -> str: ) -> str:
"""Decompile a .NET assembly to readable C# source code. """Decompile a .NET assembly to readable C# source code.
@ -597,6 +599,7 @@ async def decompile_assembly(
WORKFLOW TIP: Start with `list_types` to discover available types, then use WORKFLOW TIP: Start with `list_types` to discover available types, then use
this tool with `type_name` to decompile specific classes of interest. this tool with `type_name` to decompile specific classes of interest.
If a type is very large, use `decompile_method` to extract individual methods.
Args: Args:
assembly_path: Full path to the .NET assembly file (.dll or .exe) assembly_path: Full path to the .NET assembly file (.dll or .exe)
@ -611,6 +614,7 @@ async def decompile_assembly(
nested_directories: Organize output files in namespace-based directory hierarchy nested_directories: Organize output files in namespace-based directory hierarchy
generate_pdb: Generate a portable PDB file (requires output_dir). Enables debugging of decompiled code generate_pdb: Generate a portable PDB file (requires output_dir). Enables debugging of decompiled code
use_pdb_variable_names: Use original variable names from existing PDB if available. Improves readability use_pdb_variable_names: Use original variable names from existing PDB if available. Improves readability
max_output_chars: Maximum characters to return inline (default: 100000). Output exceeding this limit is saved to a temp file and a truncated preview is returned. Set to 0 to disable truncation.
""" """
# Validate assembly path before any processing # Validate assembly path before any processing
try: try:
@ -656,10 +660,57 @@ async def decompile_assembly(
if response.success: if response.success:
if response.source_code: if response.source_code:
code_fence = "il" if (show_il_code or show_il_sequence_points) else "csharp"
source = response.source_code
source_len = len(source)
# Check if truncation is needed
if max_output_chars > 0 and source_len > max_output_chars:
# Save full output to a temp file so the client can access it
suffix = ".il" if code_fence == "il" else ".cs"
tmp = tempfile.NamedTemporaryFile( # noqa: SIM115
delete=False,
prefix="mcilspy_full_output_",
suffix=suffix,
mode="w",
encoding="utf-8",
)
tmp.write(source)
tmp.close()
saved_path = tmp.name
logger.info(
f"Output truncated: {source_len} chars -> {max_output_chars} chars. "
f"Full output saved to {saved_path}"
)
# Build truncated response with recovery info
preview_chars = max_output_chars - 1000 # Reserve space for header
if preview_chars < 0:
preview_chars = max_output_chars
content = f"# Decompilation result: {response.assembly_name}"
if response.type_name:
content += f" - {response.type_name}"
content += (
f"\n\n**Output truncated** ({source_len:,} chars exceeded "
f"{max_output_chars:,} char limit)\n\n"
f"Full output saved to: `{saved_path}`\n\n"
"**Recovery options:**\n"
"- Read the full file directly from the path above\n"
"- Use `decompile_method` to extract specific methods\n"
"- Call again with `max_output_chars=0` to disable truncation\n"
f"- Call again with a larger `max_output_chars` value\n\n"
f"**Preview** (first {preview_chars:,} chars):\n\n"
f"```{code_fence}\n{source[:preview_chars]}\n```"
)
return content
# Normal case: output fits within limit
content = f"# Decompilation result: {response.assembly_name}" content = f"# Decompilation result: {response.assembly_name}"
if response.type_name: if response.type_name:
content += f" - {response.type_name}" content += f" - {response.type_name}"
content += f"\n\n```csharp\n{response.source_code}\n```" content += f"\n\n```{code_fence}\n{source}\n```"
return content return content
else: else:
return f"Decompilation successful! Files saved to: {response.output_path}" return f"Decompilation successful! Files saved to: {response.output_path}"
@ -671,6 +722,129 @@ async def decompile_assembly(
return _format_error(e) return _format_error(e)
@mcp.tool()
async def decompile_method(
assembly_path: str,
type_name: str,
method_name: str,
show_il_code: bool = True,
language_version: str = "Latest",
use_pdb_variable_names: bool = False,
ctx: Context | None = None,
) -> str:
"""Decompile a specific method from a .NET type.
When a type is too large to return in full, use this tool to extract just
the method you need. It decompiles the containing type, then extracts the
named method from the output.
IL mode (default) is most reliable because IL output has standard ECMA-335
delimiters. C# mode uses signature matching with brace-depth counting.
If multiple overloads exist, all matching methods are returned.
WORKFLOW TIP: Use `search_methods` to find method names, then this tool
to get the full implementation. If decompile_assembly hit the output limit,
this tool extracts just what you need.
Args:
assembly_path: Full path to the .NET assembly file (.dll or .exe)
type_name: Fully qualified type name (e.g., "MyNamespace.MyClass"). Required to scope the decompilation
method_name: Name of the method to extract (e.g., "DoElaborate"). All overloads with this name are returned
show_il_code: Output IL bytecode instead of C# (default: True, more reliable for extraction)
language_version: C# version for output syntax. Options: CSharp1-CSharp12_0, Preview, Latest (default)
use_pdb_variable_names: Use original variable names from existing PDB if available
"""
# Validate assembly path
try:
validated_path = _validate_assembly_path(assembly_path)
except AssemblyPathError as e:
return _format_error(e, "path validation")
if not type_name or not type_name.strip():
return _format_error(ValueError("type_name is required"), "validation")
if not method_name or not method_name.strip():
return _format_error(ValueError("method_name is required"), "validation")
if ctx:
await ctx.info(
f"Extracting method '{method_name}' from type '{type_name}' "
f"in: {validated_path}"
)
try:
wrapper = get_wrapper(ctx)
# Validate language version
try:
lang_ver = LanguageVersion(language_version)
except ValueError:
valid_versions = [v.value for v in LanguageVersion]
return (
f"Invalid language version: '{language_version}'\n\n"
f"Valid options: {', '.join(valid_versions)}"
)
from .models import DecompileRequest
request = DecompileRequest(
assembly_path=validated_path,
type_name=type_name.strip(),
language_version=lang_ver,
show_il_code=show_il_code,
use_pdb_variable_names=use_pdb_variable_names,
)
response = await wrapper.decompile(request)
if not response.success:
return f"Decompilation failed: {response.error_message}"
if not response.source_code:
return f"No source code returned for type '{type_name}'"
# Extract the method from the full type output
clean_method_name = method_name.strip()
if show_il_code:
methods = extract_il_method(response.source_code, clean_method_name)
code_fence = "il"
else:
methods = extract_csharp_method(response.source_code, clean_method_name)
code_fence = "csharp"
if not methods:
return (
f"Method '{clean_method_name}' not found in type '{type_name}'.\n\n"
"Possible causes:\n"
"- Method name is misspelled (matching is exact)\n"
"- Method is inherited from a base type (try decompiling the base type)\n"
"- Method is a compiler-generated accessor (try the property name)\n\n"
"**TIP**: Use `search_methods` with `type_filter` to find available methods."
)
# Format output
overload_note = ""
if len(methods) > 1:
overload_note = f" ({len(methods)} overloads)"
content = (
f"# Method: {clean_method_name}{overload_note}\n"
f"**Type**: `{type_name}` | **Assembly**: `{response.assembly_name}`\n\n"
)
for i, method_block in enumerate(methods):
if len(methods) > 1:
content += f"### Overload {i + 1}\n\n"
content += f"```{code_fence}\n{method_block}\n```\n\n"
return content.rstrip()
except Exception as e:
logger.error(f"Method extraction error: {e}")
return _format_error(e)
@mcp.tool() @mcp.tool()
async def list_types( async def list_types(
assembly_path: str, assembly_path: str,

231
tests/test_il_parser.py Normal file
View File

@ -0,0 +1,231 @@
"""Tests for IL and C# method extraction from decompiled output."""
from mcilspy.il_parser import extract_csharp_method, extract_il_method
# ── IL extraction fixtures ────────────────────────────────────────────
SAMPLE_IL = """\
.class public auto ansi beforefieldinit MyNamespace.MyClass
extends [mscorlib]System.Object
{
.method public hidebysig
instance void DoElaborate (
int32 x
) cil managed
{
.maxstack 8
IL_0000: nop
IL_0001: ldarg.1
IL_0002: call instance void MyNamespace.MyClass::Helper(int32)
IL_0007: nop
IL_0008: ret
} // end of method MyClass::DoElaborate
.method public hidebysig
instance void DoElaborate (
int32 x,
string y
) cil managed
{
.maxstack 8
IL_0000: nop
IL_0001: ret
} // end of method MyClass::DoElaborate
.method private hidebysig
instance void Helper (
int32 val
) cil managed
{
.maxstack 8
IL_0000: nop
IL_0001: ret
} // end of method MyClass::Helper
} // end of class MyNamespace.MyClass
"""
SAMPLE_CSHARP = """\
namespace MyNamespace
{
public class MyClass
{
public void DoElaborate(int x)
{
Helper(x);
}
public void DoElaborate(int x, string y)
{
Console.WriteLine(y);
}
private void Helper(int val)
{
// internal helper
}
public static async Task<bool> ProcessAsync(
string input,
CancellationToken ct)
{
await Task.Delay(100, ct);
return true;
}
}
}
"""
# ── IL extraction tests ──────────────────────────────────────────────
class TestExtractILMethod:
"""Tests for extract_il_method()."""
def test_extracts_single_method(self):
"""Should extract the Helper method."""
results = extract_il_method(SAMPLE_IL, "Helper")
assert len(results) == 1
assert ".method private hidebysig" in results[0]
assert "end of method MyClass::Helper" in results[0]
def test_extracts_overloaded_methods(self):
"""Should extract both DoElaborate overloads."""
results = extract_il_method(SAMPLE_IL, "DoElaborate")
assert len(results) == 2
# First overload has one param
assert "int32 x" in results[0]
# Second overload has two params
assert "string y" in results[1]
def test_returns_empty_for_nonexistent_method(self):
"""Should return empty list when method doesn't exist."""
results = extract_il_method(SAMPLE_IL, "NonExistent")
assert results == []
def test_returns_empty_for_empty_input(self):
"""Should handle empty inputs gracefully."""
assert extract_il_method("", "Foo") == []
assert extract_il_method(SAMPLE_IL, "") == []
assert extract_il_method("", "") == []
def test_method_name_is_exact_match(self):
"""Should not match partial method names."""
results = extract_il_method(SAMPLE_IL, "Do")
assert results == []
def test_method_name_is_exact_match_helper(self):
"""Should not match 'Help' when looking for 'Helper'."""
results = extract_il_method(SAMPLE_IL, "Help")
assert results == []
def test_preserves_method_body(self):
"""Extracted method should include the full body."""
results = extract_il_method(SAMPLE_IL, "Helper")
assert len(results) == 1
assert ".maxstack 8" in results[0]
assert "IL_0000: nop" in results[0]
assert "IL_0001: ret" in results[0]
# ── C# extraction tests ──────────────────────────────────────────────
class TestExtractCSharpMethod:
"""Tests for extract_csharp_method()."""
def test_extracts_single_method(self):
"""Should extract the Helper method."""
results = extract_csharp_method(SAMPLE_CSHARP, "Helper")
assert len(results) == 1
assert "private void Helper" in results[0]
assert "// internal helper" in results[0]
def test_extracts_overloaded_methods(self):
"""Should extract both DoElaborate overloads."""
results = extract_csharp_method(SAMPLE_CSHARP, "DoElaborate")
assert len(results) == 2
def test_returns_empty_for_nonexistent_method(self):
"""Should return empty list when method doesn't exist."""
results = extract_csharp_method(SAMPLE_CSHARP, "NonExistent")
assert results == []
def test_returns_empty_for_empty_input(self):
"""Should handle empty inputs gracefully."""
assert extract_csharp_method("", "Foo") == []
assert extract_csharp_method(SAMPLE_CSHARP, "") == []
def test_extracts_multiline_signature(self):
"""Should handle method signatures that span multiple lines."""
results = extract_csharp_method(SAMPLE_CSHARP, "ProcessAsync")
assert len(results) == 1
assert "async Task<bool>" in results[0]
assert "CancellationToken ct" in results[0]
assert "return true;" in results[0]
def test_preserves_method_body(self):
"""Extracted method should include the full body."""
results = extract_csharp_method(SAMPLE_CSHARP, "Helper")
assert len(results) == 1
assert "// internal helper" in results[0]
def test_method_name_is_exact_match(self):
"""Should not match partial method names via regex anchoring."""
# "Do" should not match "DoElaborate" because the pattern requires
# the name followed by optional generics and opening paren
results = extract_csharp_method(SAMPLE_CSHARP, "Do")
assert results == []
# ── Edge case tests ───────────────────────────────────────────────────
class TestEdgeCases:
"""Tests for edge cases in both extractors."""
def test_il_method_with_special_chars_in_name(self):
"""Methods with dots or special chars in type name should work."""
il = """\
.method public hidebysig
instance void Process () cil managed
{
.maxstack 8
IL_0000: ret
} // end of method Outer+Nested::Process
"""
results = extract_il_method(il, "Process")
assert len(results) == 1
def test_csharp_method_with_generic_return(self):
"""Should handle generic return types."""
cs = """\
public class Foo
{
public List<Dictionary<string, int>> Transform(IEnumerable<string> input)
{
return new();
}
}
"""
results = extract_csharp_method(cs, "Transform")
assert len(results) == 1
assert "return new();" in results[0]
def test_csharp_method_expression_body(self):
"""Expression-bodied methods should be extracted (they use => not {})."""
cs = """\
public class Foo
{
public int GetValue()
{
return 42;
}
}
"""
results = extract_csharp_method(cs, "GetValue")
assert len(results) == 1
assert "return 42;" in results[0]

View File

@ -4,6 +4,7 @@ These tests exercise the @mcp.tool() decorated functions in server.py.
We mock the ILSpyWrapper to test the tool logic independently of ilspycmd. We mock the ILSpyWrapper to test the tool logic independently of ilspycmd.
""" """
import os
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -595,3 +596,286 @@ class TestHelperFunctions:
result = server._detect_platform() result = server._detect_platform()
assert result["system"] == "windows" assert result["system"] == "windows"
@pytest.mark.usefixtures("bypass_path_validation")
class TestDecompileOutputTruncation:
"""Tests for decompile_assembly output truncation guard."""
@pytest.mark.asyncio
async def test_small_output_not_truncated(self):
"""Output within the limit should be returned as-is."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code="public class Small { }",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/test.dll")
assert "Output truncated" not in result
assert "public class Small { }" in result
@pytest.mark.asyncio
async def test_large_output_truncated(self):
"""Output exceeding the limit should be truncated with file path."""
large_code = "x" * 200_000 # 200K chars, exceeds 100K default
mock_response = DecompileResponse(
success=True,
assembly_name="LargeAssembly",
source_code=large_code,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/test.dll")
assert "Output truncated" in result
assert "200,000 chars" in result
assert "mcilspy_full_output_" in result
assert "decompile_method" in result
assert "max_output_chars=0" in result
@pytest.mark.asyncio
async def test_truncation_saves_full_file(self):
"""The full output should be saved to a readable temp file."""
large_code = "public class Big { " + ("int field; " * 20_000) + "}"
mock_response = DecompileResponse(
success=True,
assembly_name="BigAssembly",
source_code=large_code,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/test.dll")
# Extract file path from result
assert "mcilspy_full_output_" in result
# Find the path in the result
for line in result.split("\n"):
if "mcilspy_full_output_" in line:
# Extract path from markdown backticks
path = line.split("`")[1] if "`" in line else ""
if path and os.path.exists(path):
with open(path) as f:
saved = f.read()
assert saved == large_code
os.unlink(path) # cleanup
break
@pytest.mark.asyncio
async def test_truncation_disabled_with_zero(self):
"""Setting max_output_chars=0 should disable truncation."""
large_code = "x" * 200_000
mock_response = DecompileResponse(
success=True,
assembly_name="LargeAssembly",
source_code=large_code,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly(
"/path/to/test.dll", max_output_chars=0
)
assert "Output truncated" not in result
assert large_code in result
@pytest.mark.asyncio
async def test_il_output_uses_il_fence(self):
"""IL output should use ```il code fences."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code=".method public void Main() {}",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly(
"/path/to/test.dll", show_il_code=True
)
assert "```il" in result
@pytest.mark.usefixtures("bypass_path_validation")
class TestDecompileMethod:
"""Tests for decompile_method tool."""
@pytest.mark.asyncio
async def test_extracts_il_method(self):
"""Should extract a method from IL output."""
il_source = """\
.method public hidebysig instance void DoWork () cil managed
{
.maxstack 8
IL_0000: ret
} // end of method MyClass::DoWork
"""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code=il_source,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.MyClass",
method_name="DoWork",
)
assert "Method: DoWork" in result
assert "IL_0000: ret" in result
assert "```il" in result
@pytest.mark.asyncio
async def test_extracts_csharp_method(self):
"""Should extract a method from C# output."""
cs_source = """\
public class MyClass
{
public void DoWork()
{
Console.WriteLine("hello");
}
}
"""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code=cs_source,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.MyClass",
method_name="DoWork",
show_il_code=False,
)
assert "Method: DoWork" in result
assert "Console.WriteLine" in result
assert "```csharp" in result
@pytest.mark.asyncio
async def test_method_not_found(self):
"""Should return helpful message when method not found."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code=".method void Other() {} // end of method X::Other",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.MyClass",
method_name="NonExistent",
)
assert "not found" in result
assert "search_methods" in result
@pytest.mark.asyncio
async def test_decompile_failure_propagated(self):
"""Should propagate decompilation failures."""
mock_response = DecompileResponse(
success=False,
assembly_name="TestAssembly",
error_message="Type not found",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.Missing",
method_name="Foo",
)
assert "failed" in result.lower()
assert "Type not found" in result
@pytest.mark.asyncio
async def test_empty_type_name_rejected(self):
"""Should reject empty type_name."""
result = await server.decompile_method(
"/path/to/test.dll",
type_name="",
method_name="Foo",
)
assert "Error" in result
@pytest.mark.asyncio
async def test_empty_method_name_rejected(self):
"""Should reject empty method_name."""
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.MyClass",
method_name="",
)
assert "Error" in result
@pytest.mark.asyncio
async def test_multiple_overloads_labeled(self):
"""Multiple overloads should be labeled with overload numbers."""
il_source = """\
.method public hidebysig instance void Work (int32 x) cil managed
{
.maxstack 8
IL_0000: ret
} // end of method MyClass::Work
.method public hidebysig instance void Work (string s) cil managed
{
.maxstack 8
IL_0000: ret
} // end of method MyClass::Work
"""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
source_code=il_source,
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_method(
"/path/to/test.dll",
type_name="NS.MyClass",
method_name="Work",
)
assert "2 overloads" in result
assert "Overload 1" in result
assert "Overload 2" in result