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