diff --git a/pyproject.toml b/pyproject.toml index 6781f51..4ec6bc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcilspy" -version = "0.3.0" +version = "0.4.0" description = "MCP Server for ILSpy .NET Decompiler" authors = [ {name = "Ryan Malloy", email = "ryan@supported.systems"} diff --git a/src/mcilspy/ilspy_wrapper.py b/src/mcilspy/ilspy_wrapper.py index 883f41d..f255bb3 100644 --- a/src/mcilspy/ilspy_wrapper.py +++ b/src/mcilspy/ilspy_wrapper.py @@ -19,6 +19,8 @@ from .models import ( AssemblyInfoRequest, DecompileRequest, DecompileResponse, + DumpPackageRequest, + DumpPackageResponse, GenerateDiagrammerRequest, ListTypesRequest, ListTypesResponse, @@ -257,6 +259,13 @@ class ILSpyWrapper: if request.nested_directories: args.append("--nested-directories") + # PDB generation options + if request.generate_pdb: + args.append("-genpdb") + + if request.use_pdb_variable_names: + args.append("-usepdb") + # Disable update check for automation args.append("--disable-updatecheck") @@ -354,6 +363,12 @@ class ILSpyWrapper: if request.show_il_sequence_points: args.append("--il-sequence-points") + # PDB options (use_pdb_variable_names works with stdout mode) + if request.use_pdb_variable_names: + args.append("-usepdb") + + # Note: generate_pdb requires output directory, so it's not used in stdout mode + # Disable update check for automation args.append("--disable-updatecheck") @@ -595,6 +610,59 @@ class ILSpyWrapper: logger.exception(f"Error generating diagrammer: {e}") return {"success": False, "error_message": str(e)} + async def dump_package(self, request: DumpPackageRequest) -> DumpPackageResponse: + """Dump package assemblies into a folder. + + Extracts all assemblies from a NuGet package structure into a flat + directory for easier analysis. + + Args: + request: Dump package request with assembly path and output directory + + Returns: + DumpPackageResponse with success status and list of dumped assemblies + """ + if not os.path.exists(request.assembly_path): + return DumpPackageResponse( + success=False, + error_message=f"Path not found: {request.assembly_path}", + ) + + # Validate PE signature if it's a single assembly file + if os.path.isfile(request.assembly_path): + is_valid, pe_error = _validate_pe_signature(request.assembly_path) + if not is_valid: + return DumpPackageResponse(success=False, error_message=pe_error) + + # Create output directory if it doesn't exist + os.makedirs(request.output_dir, exist_ok=True) + + args = [request.assembly_path, "-d", "-o", request.output_dir, "--disable-updatecheck"] + + try: + return_code, stdout, stderr = await self._run_command(args) + + if return_code == 0: + # List the dumped assemblies + dumped_files = [] + if os.path.exists(request.output_dir): + for f in os.listdir(request.output_dir): + if f.endswith((".dll", ".exe")): + dumped_files.append(f) + + return DumpPackageResponse( + success=True, + output_path=request.output_dir, + assemblies_dumped=sorted(dumped_files), + ) + else: + error_msg = stderr or stdout or "Unknown error occurred" + return DumpPackageResponse(success=False, error_message=error_msg) + + except OSError as e: + logger.exception(f"Error dumping package: {e}") + return DumpPackageResponse(success=False, error_message=str(e)) + async def get_assembly_info(self, request: AssemblyInfoRequest) -> AssemblyInfo: """Get detailed information about an assembly by decompiling assembly attributes. diff --git a/src/mcilspy/models.py b/src/mcilspy/models.py index f6df449..330ac30 100644 --- a/src/mcilspy/models.py +++ b/src/mcilspy/models.py @@ -93,6 +93,9 @@ class DecompileRequest(BaseModel): remove_dead_stores: bool = False show_il_sequence_points: bool = False nested_directories: bool = False + # PDB-related options + generate_pdb: bool = False # Generate portable PDB file + use_pdb_variable_names: bool = False # Use variable names from existing PDB class ListTypesRequest(BaseModel): @@ -144,6 +147,22 @@ class GenerateDiagrammerRequest(BaseModel): report_excluded: bool = False +class DumpPackageRequest(BaseModel): + """Request to dump package assemblies into a folder.""" + + assembly_path: str # Path to the assembly or NuGet package folder + output_dir: str # Required: directory to dump assemblies into + + +class DumpPackageResponse(BaseModel): + """Response from dump package operation.""" + + success: bool + output_path: str | None = None + assemblies_dumped: list[str] = Field(default_factory=list) + error_message: str | None = None + + class AssemblyInfoRequest(BaseModel): """Request to get assembly information.""" diff --git a/src/mcilspy/server.py b/src/mcilspy/server.py index 673dfb6..a5eface 100644 --- a/src/mcilspy/server.py +++ b/src/mcilspy/server.py @@ -537,11 +537,12 @@ async def install_ilspy( f"✅ **Success!** ilspycmd{version_info} has been {'updated' if update else 'installed'}.\n\n" f"Path: `{new_status['ilspycmd_path']}`\n\n" "All ILSpy-based decompilation tools are now available:\n" - "- `decompile_assembly`\n" + "- `decompile_assembly` (with PDB generation support)\n" "- `list_types`\n" "- `search_types`\n" "- `search_strings`\n" "- `generate_diagrammer`\n" + "- `dump_package`\n" "- `get_assembly_info`" ) else: @@ -581,6 +582,8 @@ async def decompile_assembly( remove_dead_stores: bool = False, show_il_sequence_points: bool = False, nested_directories: bool = False, + generate_pdb: bool = False, + use_pdb_variable_names: bool = False, ctx: Context | None = None, ) -> str: """Decompile a .NET assembly to readable C# source code. @@ -590,6 +593,7 @@ async def decompile_assembly( - Extract specific types by fully qualified name - Generate compilable project structures for analysis - View IL (Intermediate Language) code for low-level analysis + - Generate PDB files for debugging decompiled code WORKFLOW TIP: Start with `list_types` to discover available types, then use this tool with `type_name` to decompile specific classes of interest. @@ -605,6 +609,8 @@ async def decompile_assembly( remove_dead_stores: Strip unused variable assignments from output show_il_sequence_points: Include debugging sequence points in IL output (implies show_il_code) 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 """ # Validate assembly path before any processing try: @@ -642,6 +648,8 @@ async def decompile_assembly( remove_dead_stores=remove_dead_stores, show_il_sequence_points=show_il_sequence_points, nested_directories=nested_directories, + generate_pdb=generate_pdb, + use_pdb_variable_names=use_pdb_variable_names, ) response = await wrapper.decompile(request) @@ -832,6 +840,74 @@ async def generate_diagrammer( return _format_error(e) +@mcp.tool() +async def dump_package( + assembly_path: str, + output_dir: str, + ctx: Context | None = None, +) -> str: + """Dump package assemblies into a folder. + + Extracts all assemblies from a NuGet package folder structure into a flat + directory for easier analysis. This is useful for: + - Bulk decompilation of NuGet packages + - Analyzing package dependencies + - Extracting DLLs from complex package structures + + The output directory will contain all .dll and .exe files found in the + package structure, making them easier to analyze with other tools. + + Args: + assembly_path: Path to the assembly or NuGet package folder to extract from + output_dir: Directory to dump assemblies into (will be created if needed) + """ + # Basic path validation - dump_package can work with directories too + if not assembly_path or not assembly_path.strip(): + return _format_error(ValueError("Assembly path cannot be empty"), "path validation") + + resolved_path = os.path.realpath(os.path.expanduser(assembly_path.strip())) + if not os.path.exists(resolved_path): + return _format_error(FileNotFoundError(f"Path not found: {resolved_path}"), "path validation") + + if not output_dir or not output_dir.strip(): + return _format_error(ValueError("Output directory is required"), "validation") + + if ctx: + await ctx.info(f"Dumping package assemblies from: {resolved_path}") + + try: + wrapper = get_wrapper(ctx) + + from .models import DumpPackageRequest + + request = DumpPackageRequest( + assembly_path=resolved_path, + output_dir=output_dir.strip(), + ) + + response = await wrapper.dump_package(request) + + if response.success: + content = f"# Package Dump Complete\n\n" + content += f"**Output directory**: `{response.output_path}`\n\n" + + if response.assemblies_dumped: + content += f"**Assemblies extracted** ({len(response.assemblies_dumped)}):\n\n" + for asm in response.assemblies_dumped: + content += f"- `{asm}`\n" + else: + content += "No assemblies found in package.\n" + + content += "\n**TIP**: Use `list_types` or `decompile_assembly` on any of these assemblies for analysis." + return content + else: + return f"Failed to dump package: {response.error_message}" + + except Exception as e: + logger.error(f"Error dumping package: {e}") + return _format_error(e) + + @mcp.tool() async def get_assembly_info(assembly_path: str, ctx: Context | None = None) -> str: """Get metadata and version information about a .NET assembly. diff --git a/uv.lock b/uv.lock index c9e2f75..57affc4 100644 --- a/uv.lock +++ b/uv.lock @@ -332,7 +332,7 @@ wheels = [ [[package]] name = "mcilspy" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "dnfile" },