feat: add PDB generation, PDB variable names, and dump_package tools

New features exposing remaining ilspycmd capabilities:

1. PDB Generation (generate_pdb flag in decompile_assembly)
   - Generates portable PDB files for debugging decompiled code
   - Requires output_dir to be specified

2. Use PDB Variable Names (use_pdb_variable_names flag)
   - Reads original variable names from existing PDB files
   - Greatly improves readability of decompiled output

3. New dump_package tool
   - Extracts assemblies from NuGet package structures
   - Useful for bulk analysis of package dependencies

Version bump: 0.3.0 -> 0.4.0
This commit is contained in:
Ryan Malloy 2026-02-10 23:42:04 -07:00
parent 32df2b0d24
commit 8776c9cf74
5 changed files with 166 additions and 3 deletions

View File

@ -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"}

View File

@ -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.

View File

@ -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."""

View File

@ -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.

2
uv.lock generated
View File

@ -332,7 +332,7 @@ wheels = [
[[package]]
name = "mcilspy"
version = "0.2.0"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "dnfile" },