version: 0.1.1 use fastmcp

This commit is contained in:
Borealin 2025-08-04 11:09:26 +08:00
parent b6a09eabfe
commit 57472070e2
4 changed files with 250 additions and 525 deletions

View File

@ -1,6 +1,6 @@
[project]
name = "ilspy-mcp-server"
version = "0.1.0"
version = "0.1.1"
description = "MCP Server for ILSpy .NET Decompiler"
authors = [
{name = "Borealin", email = "me@borealin.cn"}

View File

@ -113,29 +113,17 @@ class ILSpyWrapper:
if request.create_project:
args.append("-p")
# Add IL code flags
if request.show_il_sequence_points:
args.append("--il-sequence-points")
elif request.show_il_code:
# Add IL code flag
if request.show_il_code:
args.append("-il")
# Add PDB generation
if request.generate_pdb:
args.append("-genpdb")
# Add PDB usage
if request.use_pdb:
args.extend(["-usepdb", request.use_pdb])
# Add reference paths
for ref_path in request.reference_paths:
args.extend(["-r", ref_path])
# Add optimization flags
# Add optimization flag
if request.remove_dead_code:
args.append("--no-dead-code")
if request.remove_dead_stores:
args.append("--no-dead-stores")
# Add directory structure flag
if request.nested_directories:

View File

@ -1,10 +1,6 @@
"""Data models for ILSpy MCP Server."""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from pydantic import BaseModel, Field
from enum import Enum
import os
class LanguageVersion(str, Enum):
"""C# Language versions supported by ILSpy."""
@ -26,7 +22,6 @@ class LanguageVersion(str, Enum):
PREVIEW = "Preview"
LATEST = "Latest"
class EntityType(str, Enum):
"""Entity types that can be listed."""
CLASS = "c"
@ -34,91 +29,23 @@ class EntityType(str, Enum):
STRUCT = "s"
DELEGATE = "d"
ENUM = "e"
class DecompileRequest(BaseModel):
"""Request to decompile a .NET assembly."""
assembly_path: str = Field(..., description="Path to the .NET assembly file")
output_dir: Optional[str] = Field(None, description="Output directory for decompiled files")
type_name: Optional[str] = Field(None, description="Fully qualified name of the type to decompile")
language_version: LanguageVersion = Field(LanguageVersion.LATEST, description="C# language version")
create_project: bool = Field(False, description="Create a compilable project")
show_il_code: bool = Field(False, description="Show IL code")
show_il_sequence_points: bool = Field(False, description="Show IL with sequence points")
generate_pdb: bool = Field(False, description="Generate PDB file")
use_pdb: Optional[str] = Field(None, description="Path to PDB file for variable names")
reference_paths: List[str] = Field(default_factory=list, description="Reference assembly paths")
remove_dead_code: bool = Field(False, description="Remove dead code")
remove_dead_stores: bool = Field(False, description="Remove dead stores")
nested_directories: bool = Field(False, description="Use nested directories for namespaces")
@validator('assembly_path')
def validate_assembly_path(cls, v):
"""Validate that the assembly path exists and has a valid extension."""
if not v:
raise ValueError("Assembly path cannot be empty")
if not os.path.exists(v):
raise ValueError(f"Assembly file not found: {v}")
valid_extensions = ['.dll', '.exe']
if not any(v.lower().endswith(ext) for ext in valid_extensions):
raise ValueError(f"Invalid assembly file extension. Expected: {', '.join(valid_extensions)}")
return v
@validator('output_dir')
def validate_output_dir(cls, v):
"""Validate output directory if specified."""
if v and not os.path.isdir(os.path.dirname(v) if os.path.dirname(v) else '.'):
raise ValueError(f"Output directory parent does not exist: {v}")
return v
@validator('use_pdb')
def validate_pdb_path(cls, v):
"""Validate PDB file path if specified."""
if v and not os.path.exists(v):
raise ValueError(f"PDB file not found: {v}")
return v
@validator('reference_paths')
def validate_reference_paths(cls, v):
"""Validate reference assembly paths."""
for ref_path in v:
if not os.path.exists(ref_path):
raise ValueError(f"Reference assembly not found: {ref_path}")
return v
assembly_path: str
output_dir: Optional[str] = None
type_name: Optional[str] = None
language_version: LanguageVersion = LanguageVersion.LATEST
create_project: bool = False
show_il_code: bool = False
reference_paths: List[str] = Field(default_factory=list)
remove_dead_code: bool = False
nested_directories: bool = False
class ListTypesRequest(BaseModel):
"""Request to list types in an assembly."""
assembly_path: str = Field(..., description="Path to the .NET assembly file")
entity_types: List[EntityType] = Field(default_factory=lambda: [EntityType.CLASS], description="Types of entities to list")
reference_paths: List[str] = Field(default_factory=list, description="Reference assembly paths")
@validator('assembly_path')
def validate_assembly_path(cls, v):
"""Validate that the assembly path exists and has a valid extension."""
if not v:
raise ValueError("Assembly path cannot be empty")
if not os.path.exists(v):
raise ValueError(f"Assembly file not found: {v}")
valid_extensions = ['.dll', '.exe']
if not any(v.lower().endswith(ext) for ext in valid_extensions):
raise ValueError(f"Invalid assembly file extension. Expected: {', '.join(valid_extensions)}")
return v
@validator('reference_paths')
def validate_reference_paths(cls, v):
"""Validate reference assembly paths."""
for ref_path in v:
if not os.path.exists(ref_path):
raise ValueError(f"Reference assembly not found: {ref_path}")
return v
assembly_path: str
entity_types: List[EntityType] = Field(default_factory=lambda: [EntityType.CLASS])
reference_paths: List[str] = Field(default_factory=list)
class TypeInfo(BaseModel):
"""Information about a type in an assembly."""
@ -127,7 +54,6 @@ class TypeInfo(BaseModel):
kind: str
namespace: Optional[str] = None
class DecompileResponse(BaseModel):
"""Response from decompilation operation."""
success: bool
@ -137,7 +63,6 @@ class DecompileResponse(BaseModel):
assembly_name: str
type_name: Optional[str] = None
class ListTypesResponse(BaseModel):
"""Response from list types operation."""
success: bool
@ -145,59 +70,19 @@ class ListTypesResponse(BaseModel):
total_count: int = 0
error_message: Optional[str] = None
class GenerateDiagrammerRequest(BaseModel):
"""Request to generate HTML diagrammer."""
assembly_path: str = Field(..., description="Path to the .NET assembly file")
output_dir: Optional[str] = Field(None, description="Output directory for diagrammer")
include_pattern: Optional[str] = Field(None, description="Regex pattern for types to include")
exclude_pattern: Optional[str] = Field(None, description="Regex pattern for types to exclude")
docs_path: Optional[str] = Field(None, description="Path to XML documentation file")
strip_namespaces: List[str] = Field(default_factory=list, description="Namespaces to strip from docs")
report_excluded: bool = Field(False, description="Generate report of excluded types")
@validator('assembly_path')
def validate_assembly_path(cls, v):
"""Validate that the assembly path exists and has a valid extension."""
if not v:
raise ValueError("Assembly path cannot be empty")
if not os.path.exists(v):
raise ValueError(f"Assembly file not found: {v}")
valid_extensions = ['.dll', '.exe']
if not any(v.lower().endswith(ext) for ext in valid_extensions):
raise ValueError(f"Invalid assembly file extension. Expected: {', '.join(valid_extensions)}")
return v
@validator('docs_path')
def validate_docs_path(cls, v):
"""Validate XML documentation file path if specified."""
if v and not os.path.exists(v):
raise ValueError(f"Documentation file not found: {v}")
return v
assembly_path: str
output_dir: Optional[str] = None
include_pattern: Optional[str] = None
exclude_pattern: Optional[str] = None
docs_path: Optional[str] = None
strip_namespaces: List[str] = Field(default_factory=list)
report_excluded: bool = False
class AssemblyInfoRequest(BaseModel):
"""Request to get assembly information."""
assembly_path: str = Field(..., description="Path to the .NET assembly file")
@validator('assembly_path')
def validate_assembly_path(cls, v):
"""Validate that the assembly path exists and has a valid extension."""
if not v:
raise ValueError("Assembly path cannot be empty")
if not os.path.exists(v):
raise ValueError(f"Assembly file not found: {v}")
valid_extensions = ['.dll', '.exe']
if not any(v.lower().endswith(ext) for ext in valid_extensions):
raise ValueError(f"Invalid assembly file extension. Expected: {', '.join(valid_extensions)}")
return v
assembly_path: str
class AssemblyInfo(BaseModel):
"""Information about an assembly."""

View File

@ -1,213 +1,133 @@
"""MCP Server for ILSpy .NET Decompiler."""
import asyncio
import json
import logging
import os
import sys
from typing import Any, Dict, List, Optional
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import (
CallToolRequest,
CallToolResult,
ListToolsRequest,
ListToolsResult,
Tool,
TextContent,
GetPromptRequest,
GetPromptResult,
ListPromptsRequest,
ListPromptsResult,
Prompt,
PromptMessage,
PromptArgument,
)
from typing import Optional
from mcp.server.fastmcp import FastMCP, Context
from .ilspy_wrapper import ILSpyWrapper
from .models import (
DecompileRequest, ListTypesRequest, GenerateDiagrammerRequest,
AssemblyInfoRequest, LanguageVersion, EntityType
)
from .models import LanguageVersion, EntityType
# Set up logging
# Setup logging
log_level = os.getenv('LOGLEVEL', 'INFO').upper()
numeric_level = getattr(logging, log_level, logging.INFO)
logging.basicConfig(
level=numeric_level,
level=getattr(logging, log_level, logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create the MCP server
server = Server("ilspy-mcp-server")
# Create FastMCP server - much simpler than before!
mcp = FastMCP("ilspy-mcp-server")
# Global ILSpy wrapper instance
# Global ILSpy wrapper
ilspy_wrapper: Optional[ILSpyWrapper] = None
@server.list_tools()
async def handle_list_tools() -> ListToolsResult:
"""List available tools."""
return ListToolsResult(
tools=[
Tool(
name="decompile_assembly",
description="Decompile a .NET assembly to C# source code",
inputSchema={
"type": "object",
"properties": {
"assembly_path": {
"type": "string",
"description": "Path to the .NET assembly file (.dll or .exe)"
},
"output_dir": {
"type": "string",
"description": "Output directory for decompiled files (optional)"
},
"type_name": {
"type": "string",
"description": "Fully qualified name of specific type to decompile (optional)"
},
"language_version": {
"type": "string",
"enum": [lv.value for lv in LanguageVersion],
"description": "C# language version to use",
"default": "Latest"
},
"create_project": {
"type": "boolean",
"description": "Create a compilable project with multiple files",
"default": False
},
"show_il_code": {
"type": "boolean",
"description": "Show IL code instead of C#",
"default": False
},
"remove_dead_code": {
"type": "boolean",
"description": "Remove dead code during decompilation",
"default": False
},
"nested_directories": {
"type": "boolean",
"description": "Use nested directories for namespaces",
"default": False
}
},
"required": ["assembly_path"]
}
),
Tool(
name="list_types",
description="List types (classes, interfaces, structs, etc.) in a .NET assembly",
inputSchema={
"type": "object",
"properties": {
"assembly_path": {
"type": "string",
"description": "Path to the .NET assembly file (.dll or .exe)"
},
"entity_types": {
"type": "array",
"items": {
"type": "string",
"enum": [et.value for et in EntityType]
},
"description": "Types of entities to list (c=class, i=interface, s=struct, d=delegate, e=enum)",
"default": ["c"]
}
},
"required": ["assembly_path"]
}
),
Tool(
name="generate_diagrammer",
description="Generate an interactive HTML diagrammer for visualizing assembly structure",
inputSchema={
"type": "object",
"properties": {
"assembly_path": {
"type": "string",
"description": "Path to the .NET assembly file (.dll or .exe)"
},
"output_dir": {
"type": "string",
"description": "Output directory for the diagrammer (optional)"
},
"include_pattern": {
"type": "string",
"description": "Regex pattern for types to include (optional)"
},
"exclude_pattern": {
"type": "string",
"description": "Regex pattern for types to exclude (optional)"
}
},
"required": ["assembly_path"]
}
),
Tool(
name="get_assembly_info",
description="Get basic information about a .NET assembly",
inputSchema={
"type": "object",
"properties": {
"assembly_path": {
"type": "string",
"description": "Path to the .NET assembly file (.dll or .exe)"
}
},
"required": ["assembly_path"]
}
)
]
)
@server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
"""Handle tool calls."""
def get_wrapper() -> ILSpyWrapper:
"""Get ILSpy wrapper instance"""
global ilspy_wrapper
if ilspy_wrapper is None:
try:
ilspy_wrapper = ILSpyWrapper()
except RuntimeError as e:
return CallToolResult(
content=[TextContent(type="text", text=f"Error: {str(e)}")]
return ilspy_wrapper
@mcp.tool()
async def decompile_assembly(
assembly_path: str,
output_dir: str = None,
type_name: str = None,
language_version: str = "Latest",
create_project: bool = False,
show_il_code: bool = False,
remove_dead_code: bool = False,
nested_directories: bool = False,
ctx: Context = None
) -> str:
"""Decompile a .NET assembly to C# source code
Args:
assembly_path: Path to the .NET assembly file (.dll or .exe)
output_dir: Output directory for decompiled files (optional)
type_name: Fully qualified name of specific type to decompile (optional)
language_version: C# language version to use (default: Latest)
create_project: Create a compilable project with multiple files
show_il_code: Show IL code instead of C#
remove_dead_code: Remove dead code during decompilation
nested_directories: Use nested directories for namespaces
"""
if ctx:
await ctx.info(f"Starting decompilation of assembly: {assembly_path}")
try:
wrapper = get_wrapper()
# Use simplified request object (no complex pydantic validation needed)
from .models import DecompileRequest
request = DecompileRequest(
assembly_path=assembly_path,
output_dir=output_dir,
type_name=type_name,
language_version=LanguageVersion(language_version),
create_project=create_project,
show_il_code=show_il_code,
remove_dead_code=remove_dead_code,
nested_directories=nested_directories
)
try:
if name == "decompile_assembly":
request = DecompileRequest(**arguments)
response = await ilspy_wrapper.decompile(request)
response = await wrapper.decompile(request)
if response.success:
if response.source_code:
content = f"# Decompiled: {response.assembly_name}"
content = f"# Decompilation result: {response.assembly_name}"
if response.type_name:
content += f" - {response.type_name}"
content += "\n\n```csharp\n" + response.source_code + "\n```"
content += f"\n\n```csharp\n{response.source_code}\n```"
return content
else:
content = f"Decompilation successful. Files saved to: {response.output_path}"
return f"Decompilation successful! Files saved to: {response.output_path}"
else:
content = f"Decompilation failed: {response.error_message}"
return f"Decompilation failed: {response.error_message}"
return CallToolResult(
content=[TextContent(type="text", text=content)]
except Exception as e:
logger.error(f"Decompilation error: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def list_types(
assembly_path: str,
entity_types: list[str] = None,
ctx: Context = None
) -> str:
"""List types (classes, interfaces, structs, etc.) in a .NET assembly
Args:
assembly_path: Path to the .NET assembly file (.dll or .exe)
entity_types: Types of entities to list (c=class, i=interface, s=struct, d=delegate, e=enum)
"""
if ctx:
await ctx.info(f"Listing types in assembly: {assembly_path}")
try:
wrapper = get_wrapper()
# Default to list only classes
if entity_types is None:
entity_types = ["c"]
# Convert to EntityType enums
entity_type_enums = []
for et in entity_types:
try:
entity_type_enums.append(EntityType(et))
except ValueError:
continue
from .models import ListTypesRequest
request = ListTypesRequest(
assembly_path=assembly_path,
entity_types=entity_type_enums
)
elif name == "list_types":
request = ListTypesRequest(**arguments)
response = await ilspy_wrapper.list_types(request)
response = await wrapper.list_types(request)
if response.success:
if response.types:
content = f"# Types in {arguments['assembly_path']}\n\n"
if response.success and response.types:
content = f"# Types in {assembly_path}\n\n"
content += f"Found {response.total_count} types:\n\n"
# Group by namespace
@ -224,33 +144,76 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResu
content += f"- **{type_info.name}** ({type_info.kind})\n"
content += f" - Full name: `{type_info.full_name}`\n"
content += "\n"
else:
content = "No types found in the assembly."
else:
content = f"Failed to list types: {response.error_message}"
return CallToolResult(
content=[TextContent(type="text", text=content)]
return content
else:
return response.error_message or "No types found in assembly"
except Exception as e:
logger.error(f"Error listing types: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def generate_diagrammer(
assembly_path: str,
output_dir: str = None,
include_pattern: str = None,
exclude_pattern: str = None,
ctx: Context = None
) -> str:
"""Generate an interactive HTML diagrammer for visualizing assembly structure
Args:
assembly_path: Path to the .NET assembly file (.dll or .exe)
output_dir: Output directory for the diagrammer (optional)
include_pattern: Regex pattern for types to include (optional)
exclude_pattern: Regex pattern for types to exclude (optional)
"""
if ctx:
await ctx.info(f"Generating assembly diagram: {assembly_path}")
try:
wrapper = get_wrapper()
from .models import GenerateDiagrammerRequest
request = GenerateDiagrammerRequest(
assembly_path=assembly_path,
output_dir=output_dir,
include_pattern=include_pattern,
exclude_pattern=exclude_pattern
)
elif name == "generate_diagrammer":
request = GenerateDiagrammerRequest(**arguments)
response = await ilspy_wrapper.generate_diagrammer(request)
response = await wrapper.generate_diagrammer(request)
if response["success"]:
content = f"HTML diagrammer generated successfully!\n"
content += f"Output directory: {response['output_directory']}\n"
content += f"Open the HTML file in a web browser to view the interactive diagram."
return f"HTML diagram generated successfully!\nOutput directory: {response['output_directory']}\nOpen the HTML file in a web browser to view the interactive diagram."
else:
content = f"Failed to generate diagrammer: {response['error_message']}"
return f"Failed to generate diagram: {response['error_message']}"
return CallToolResult(
content=[TextContent(type="text", text=content)]
)
except Exception as e:
logger.error(f"Error generating diagram: {e}")
return f"Error: {str(e)}"
elif name == "get_assembly_info":
request = AssemblyInfoRequest(**arguments)
info = await ilspy_wrapper.get_assembly_info(request)
@mcp.tool()
async def get_assembly_info(
assembly_path: str,
ctx: Context = None
) -> str:
"""Get basic information about a .NET assembly
Args:
assembly_path: Path to the .NET assembly file (.dll or .exe)
"""
if ctx:
await ctx.info(f"Getting assembly info: {assembly_path}")
try:
wrapper = get_wrapper()
from .models import AssemblyInfoRequest
request = AssemblyInfoRequest(assembly_path=assembly_path)
info = await wrapper.get_assembly_info(request)
content = f"# Assembly Information\n\n"
content += f"- **Name**: {info.name}\n"
@ -264,87 +227,17 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResu
content += f"- **Is Signed**: {info.is_signed}\n"
content += f"- **Has Debug Info**: {info.has_debug_info}\n"
return CallToolResult(
content=[TextContent(type="text", text=content)]
)
return content
else:
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: {name}")]
)
except ValueError as e:
# Handle validation errors with user-friendly messages
logger.warning(f"Validation error in tool {name}: {e}")
return CallToolResult(
content=[TextContent(type="text", text=f"Validation Error: {str(e)}")]
)
except FileNotFoundError as e:
logger.warning(f"File not found in tool {name}: {e}")
return CallToolResult(
content=[TextContent(type="text", text=f"File Not Found: {str(e)}")]
)
except PermissionError as e:
logger.warning(f"Permission error in tool {name}: {e}")
return CallToolResult(
content=[TextContent(type="text", text=f"Permission Error: {str(e)}. Please check file permissions.")]
)
except Exception as e:
logger.error(f"Unexpected error in tool {name}: {e}")
return CallToolResult(
content=[TextContent(type="text", text=f"Unexpected Error: {str(e)}. Please check the logs for more details.")]
)
logger.error(f"Error getting assembly info: {e}")
return f"Error: {str(e)}"
@server.list_prompts()
async def handle_list_prompts() -> ListPromptsResult:
"""List available prompts."""
return ListPromptsResult(
prompts=[
Prompt(
name="analyze_assembly",
description="Analyze a .NET assembly and provide insights about its structure and types",
arguments=[
PromptArgument(
name="assembly_path",
description="Path to the .NET assembly file",
required=True
),
PromptArgument(
name="focus_area",
description="Specific area to focus on (types, namespaces, dependencies)",
required=False
)
]
),
Prompt(
name="decompile_and_explain",
description="Decompile a specific type and provide explanation of its functionality",
arguments=[
PromptArgument(
name="assembly_path",
description="Path to the .NET assembly file",
required=True
),
PromptArgument(
name="type_name",
description="Fully qualified name of the type to analyze",
required=True
)
]
)
]
)
@server.get_prompt()
async def handle_get_prompt(name: str, arguments: Dict[str, str]) -> GetPromptResult:
"""Handle prompt requests."""
if name == "analyze_assembly":
assembly_path = arguments.get("assembly_path", "")
focus_area = arguments.get("focus_area", "types")
prompt_text = f"""I need to analyze the .NET assembly at "{assembly_path}".
# FastMCP automatically handles prompts
@mcp.prompt()
def analyze_assembly_prompt(assembly_path: str, focus_area: str = "types") -> str:
"""Prompt template for analyzing .NET assemblies"""
return f"""I need to analyze the .NET assembly at "{assembly_path}".
Please help me understand:
1. The overall structure and organization of the assembly
@ -356,21 +249,10 @@ Focus area: {focus_area}
Start by listing the types in the assembly, then provide insights based on what you find."""
return GetPromptResult(
description=f"Analysis of .NET assembly: {assembly_path}",
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text=prompt_text)
)
]
)
elif name == "decompile_and_explain":
assembly_path = arguments.get("assembly_path", "")
type_name = arguments.get("type_name", "")
prompt_text = f"""I want to understand the type "{type_name}" from the assembly "{assembly_path}".
@mcp.prompt()
def decompile_and_explain_prompt(assembly_path: str, type_name: str) -> str:
"""Prompt template for decompiling and explaining specific types"""
return f"""I want to understand the type "{type_name}" from the assembly "{assembly_path}".
Please:
1. Decompile this specific type
@ -381,36 +263,6 @@ Please:
Type to analyze: {type_name}
Assembly: {assembly_path}"""
return GetPromptResult(
description=f"Decompilation and analysis of {type_name}",
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text=prompt_text)
)
]
)
else:
raise ValueError(f"Unknown prompt: {name}")
async def main():
"""Main entry point for the server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="ilspy-mcp-server",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=None,
experimental_capabilities={}
)
)
)
if __name__ == "__main__":
asyncio.run(main())
# FastMCP automatically handles running
mcp.run()