diff --git a/README.md b/README.md
index 6df2b7f..d4c1139 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,7 @@ Learn from decompiled implementations when documentation falls short.
| Tool | What It Does | Requires ilspycmd? |
|------|--------------|:------------------:|
| `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 |
| `search_types` | Find types by name pattern | Yes |
| `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 |
**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)
- [Installation Options](#installation-options)
- [Tool Reference](#tool-reference)
- - [Decompilation Tools](#decompilation-tools-requires-ilspycmd)
+ - [Decompilation & Extraction Tools](#decompilation-tools-requires-ilspycmd)
- [Metadata Tools](#metadata-tools-no-ilspycmd-required)
- [Installation Tools](#installation--diagnostics)
- [Configuration](#configuration)
@@ -177,6 +178,30 @@ The primary reverse-engineering tool. Decompiles .NET assemblies to readable C#.
```
+
+decompile_method — Extract a specific method from a type
+
+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"
+}
+```
+
+
list_types — Enumerate types in an assembly
diff --git a/docs/API.md b/docs/API.md
index c1f3dc4..4821fcc 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -4,7 +4,7 @@ This document provides detailed API documentation for mcilspy.
## 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
@@ -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 |
| `generate_pdb` | boolean | ✗ | false | Generate portable PDB file (requires `output_dir`) |
| `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:**
- `CSharp1` through `CSharp12_0`
@@ -68,10 +69,52 @@ Decompiles a .NET assembly to C# source code. This is the primary tool for rever
**Response:**
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.
+> **Note:** Tool numbering in this document reflects the addition of `decompile_method` at position 2.
+
**Parameters:**
| Parameter | Type | Required | Default | Description |
@@ -104,7 +147,7 @@ Returns a formatted list of types organized by namespace, including:
- Type kind (Class, Interface, etc.)
- Namespace
-### 3. search_types
+### 4. search_types
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:**
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.
@@ -179,7 +222,7 @@ Search for string literals in assembly code. Crucial for reverse engineering - f
**Response:**
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.
@@ -208,7 +251,7 @@ Generates an interactive HTML diagram showing assembly type relationships.
**Response:**
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.
@@ -238,7 +281,7 @@ Dump package assemblies into a folder. Extracts all assemblies from a NuGet pack
**Response:**
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.
@@ -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.
-### 8. search_methods
+### 9. search_methods
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:**
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.
@@ -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.
@@ -364,7 +407,7 @@ Search for properties in an assembly by name pattern. Uses direct metadata parsi
- Locate data model fields
- 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.
@@ -381,7 +424,7 @@ List all events defined in an assembly. Uses direct metadata parsing of the Even
- Discover observer patterns
- 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.
@@ -396,7 +439,7 @@ List all embedded resources in an assembly. Uses direct metadata parsing of the
- Discover localization resources
- 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.
@@ -427,7 +470,7 @@ Returns comprehensive assembly information including:
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.
@@ -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.
diff --git a/src/mcilspy/constants.py b/src/mcilspy/constants.py
index cae9632..321c926 100644
--- a/src/mcilspy/constants.py
+++ b/src/mcilspy/constants.py
@@ -28,6 +28,11 @@ MAX_UNPARSED_LOG_LINES: int = 3
# Preview length for unparsed line debug messages
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
# =============================================================================
diff --git a/src/mcilspy/il_parser.py b/src/mcilspy/il_parser.py
new file mode 100644
index 0000000..64c8efc
--- /dev/null
+++ b/src/mcilspy/il_parser.py
@@ -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 DoElaborate(...)
+ # 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>)
+ + 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
diff --git a/src/mcilspy/server.py b/src/mcilspy/server.py
index a5eface..d97936a 100644
--- a/src/mcilspy/server.py
+++ b/src/mcilspy/server.py
@@ -4,17 +4,18 @@ import os
import platform
import re
import shutil
+import tempfile
from contextlib import asynccontextmanager
from mcp.server.fastmcp import Context, FastMCP
from .constants import (
ALL_ENTITY_TYPES,
+ DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_MAX_SEARCH_RESULTS,
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 .models import EntityType, LanguageVersion
from .utils import find_ilspycmd_path
@@ -584,6 +585,7 @@ async def decompile_assembly(
nested_directories: bool = False,
generate_pdb: bool = False,
use_pdb_variable_names: bool = False,
+ max_output_chars: int = DEFAULT_MAX_OUTPUT_CHARS,
ctx: Context | None = None,
) -> str:
"""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
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:
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
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
+ 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
try:
@@ -656,10 +660,57 @@ async def decompile_assembly(
if response.success:
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}"
if 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
else:
return f"Decompilation successful! Files saved to: {response.output_path}"
@@ -671,6 +722,129 @@ async def decompile_assembly(
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()
async def list_types(
assembly_path: str,
diff --git a/tests/test_il_parser.py b/tests/test_il_parser.py
new file mode 100644
index 0000000..80ba70e
--- /dev/null
+++ b/tests/test_il_parser.py
@@ -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 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" 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> Transform(IEnumerable 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]
diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py
index 54794f1..dbc8dfe 100644
--- a/tests/test_server_tools.py
+++ b/tests/test_server_tools.py
@@ -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.
"""
+import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -595,3 +596,286 @@ class TestHelperFunctions:
result = server._detect_platform()
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