From b6a09eabfe2b0f7e0ba21b85c4daaaaec16256ab Mon Sep 17 00:00:00 2001 From: Borealin Date: Sun, 3 Aug 2025 23:31:39 +0800 Subject: [PATCH] init: init version --- .gitignore | 31 ++ LICENSE | 21 ++ MANIFEST.in | 12 + README.md | 241 +++++++++++++++ RELEASE.md | 118 ++++++++ claude_desktop_config.json | 14 + docs/API.md | 327 ++++++++++++++++++++ mcp_config_example.json | 47 +++ publish.py | 63 ++++ pyproject.toml | 51 ++++ requirements.txt | 2 + src/ilspy_mcp_server/__init__.py | 3 + src/ilspy_mcp_server/ilspy_wrapper.py | 389 ++++++++++++++++++++++++ src/ilspy_mcp_server/models.py | 211 +++++++++++++ src/ilspy_mcp_server/server.py | 416 ++++++++++++++++++++++++++ 15 files changed, 1946 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 RELEASE.md create mode 100644 claude_desktop_config.json create mode 100644 docs/API.md create mode 100644 mcp_config_example.json create mode 100644 publish.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/ilspy_mcp_server/__init__.py create mode 100644 src/ilspy_mcp_server/ilspy_wrapper.py create mode 100644 src/ilspy_mcp_server/models.py create mode 100644 src/ilspy_mcp_server/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1284311 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so + +# Distribution / packaging +build/ +dist/ +*.egg-info/ + +# Virtual environments +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment variables +.env \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b9be8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Borealin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..783acfa --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +# Include important files in the source distribution +include README.md +include LICENSE +include requirements.txt +include mcp_config_example.json + +# Exclude unnecessary files +global-exclude *.pyc +global-exclude __pycache__ +global-exclude .git* +global-exclude .DS_Store +prune docs \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..34e46d2 --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# ILSpy MCP Server + +A Model Context Protocol (MCP) server that provides .NET assembly decompilation capabilities using ILSpy. + +## Features + +- **Decompile Assemblies**: Convert .NET assemblies back to readable C# source code +- **List Types**: Enumerate classes, interfaces, structs, delegates, and enums in assemblies +- **Generate Diagrammer**: Create interactive HTML visualizations of assembly structure +- **Assembly Information**: Get metadata about .NET assemblies + +## Prerequisites + +1. **ILSpy Command Line Tool**: Install the global dotnet tool: + ```bash + dotnet tool install --global ilspycmd + ``` + +2. **Python 3.8+**: Required for running the MCP server + +## Installation + +Install from PyPI: + +```bash +pip install ilspy-mcp-server +``` + +Or for development: + +```bash +git clone https://github.com/Borealin/ilspy-mcp-server.git +cd ilspy-mcp-server +pip install -e . +``` + +## Usage + +### MCP Client Configuration + +Configure your MCP client (e.g., Claude Desktop) to use the server: + +```json +{ + "mcpServers": { + "ilspy": { + "command": "python", + "args": ["-m", "ilspy_mcp_server.server"] + } + } +} +``` + +### Available Tools + +#### 1. `decompile_assembly` +Decompile a .NET assembly to C# source code. + +**Parameters:** +- `assembly_path` (required): Path to the .NET assembly file +- `output_dir` (optional): Output directory for decompiled files +- `type_name` (optional): Specific type to decompile +- `language_version` (optional): C# language version (default: "Latest") +- `create_project` (optional): Create a compilable project structure +- `show_il_code` (optional): Show IL code instead of C# +- `remove_dead_code` (optional): Remove dead code during decompilation +- `nested_directories` (optional): Use nested directories for namespaces + +**Example:** +```json +{ + "name": "decompile_assembly", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "type_name": "MyNamespace.MyClass", + "language_version": "CSharp10_0" + } +} +``` + +#### 2. `list_types` +List types in a .NET assembly. + +**Parameters:** +- `assembly_path` (required): Path to the .NET assembly file +- `entity_types` (optional): Array of entity types to list ("c", "i", "s", "d", "e") + +**Example:** +```json +{ + "name": "list_types", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "entity_types": ["c", "i"] + } +} +``` + +#### 3. `generate_diagrammer` +Generate an interactive HTML diagrammer. + +**Parameters:** +- `assembly_path` (required): Path to the .NET assembly file +- `output_dir` (optional): Output directory for the diagrammer +- `include_pattern` (optional): Regex pattern for types to include +- `exclude_pattern` (optional): Regex pattern for types to exclude + +#### 4. `get_assembly_info` +Get basic information about an assembly. + +**Parameters:** +- `assembly_path` (required): Path to the .NET assembly file + +### Available Prompts + +#### 1. `analyze_assembly` +Analyze a .NET assembly and provide insights about its structure. + +#### 2. `decompile_and_explain` +Decompile a specific type and provide explanation of its functionality. + +## Supported Assembly Types + +- .NET Framework assemblies (.dll, .exe) +- .NET Core/.NET 5+ assemblies +- Portable Executable (PE) files with .NET metadata + +## Supported C# Language Versions + +- CSharp1 through CSharp12_0 +- Preview +- Latest (default) + +## Quick Start + +1. **Install the package**: + ```bash + pip install ilspy-mcp-server + ``` + +2. **Configure your MCP client** (Claude Desktop example): + ```json + { + "mcpServers": { + "ilspy": { + "command": "python", + "args": ["-m", "ilspy_mcp_server.server"] + } + } + } + ``` + +3. **Use the tools** in your MCP client: + - Ask to decompile a .NET assembly + - List types in an assembly + - Generate interactive diagrams + - Get assembly information + +## Error Handling + +The server provides detailed error messages for common issues: +- Assembly file not found +- Invalid assembly format +- ILSpyCmd not installed or not in PATH +- Permission issues +- Decompilation failures + +## Configuration + +### Environment Variables + +- `LOGLEVEL`: Set logging level (DEBUG, INFO, WARNING, ERROR). Default: INFO + +### MCP Client Examples + +**Claude Desktop** (`config.json`): +```json +{ + "mcpServers": { + "ilspy": { + "command": "python", + "args": ["-m", "ilspy_mcp_server.server"], + "env": { + "LOGLEVEL": "INFO" + } + } + } +} +``` + +**Development/Testing**: +```json +{ + "mcpServers": { + "ilspy": { + "command": "python", + "args": ["-m", "ilspy_mcp_server.server"], + "env": { + "LOGLEVEL": "DEBUG" + } + } + } +} +``` + +## Troubleshooting + +### Common Issues + +1. **"ILSpyCmd not found"**: + ```bash + dotnet tool install --global ilspycmd + ``` + +2. **"Assembly file not found"**: + - Check the file path is correct + - Ensure the file has .dll or .exe extension + +3. **Permission errors**: + - Ensure read access to assembly files + - Check output directory permissions + +### Debug Mode + +Enable debug logging to see detailed operation info: +```json +{ + "env": { + "LOGLEVEL": "DEBUG" + } +} +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Acknowledgments + +- Built on top of the excellent [ILSpy](https://github.com/icsharpcode/ILSpy) decompiler +- Uses the [Model Context Protocol](https://modelcontextprotocol.io/) for integration \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..f6024a1 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,118 @@ +# Release Guide + +This document describes how to release the package to PyPI. + +## Prerequisites + +1. Install build tools: + ```bash + pip install build twine + ``` + +2. Make sure you have PyPI credentials configured: + ```bash + # Create ~/.pypirc or use environment variables + # TWINE_USERNAME and TWINE_PASSWORD + ``` + +## Release Process + +### 1. Update Version + +Update the version in `pyproject.toml`: +```toml +version = "0.1.1" # Increment as needed +``` + +### 2. Build the Package + +```bash +# Clean previous builds +rm -rf dist/ build/ + +# Build the package +python -m build +``` + +This creates: +- `dist/ilspy_mcp_server-X.X.X.tar.gz` (source distribution) +- `dist/ilspy_mcp_server-X.X.X-py3-none-any.whl` (wheel) + +### 3. Test the Build + +Test the package locally: +```bash +pip install dist/ilspy_mcp_server-*.whl +``` + +### 4. Check the Package + +```bash +twine check dist/* +``` + +### 5. Upload to Test PyPI (Optional) + +```bash +twine upload --repository testpypi dist/* +``` + +Test installation from Test PyPI: +```bash +pip install --index-url https://test.pypi.org/simple/ ilspy-mcp-server +``` + +### 6. Upload to PyPI + +```bash +twine upload dist/* +``` + +### 7. Verify Installation + +```bash +pip install ilspy-mcp-server +``` + +## Version Numbering + +Follow [Semantic Versioning](https://semver.org/): +- `MAJOR.MINOR.PATCH` (e.g., 1.0.0) +- Increment MAJOR for breaking changes +- Increment MINOR for new features +- Increment PATCH for bug fixes + +## Automation (Optional) + +You can automate releases using GitHub Actions. Create `.github/workflows/release.yml`: + +```yaml +name: Release to PyPI + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Build package + run: python -m build + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* +``` + +Then add your PyPI API token as a GitHub secret named `PYPI_API_TOKEN`. \ No newline at end of file diff --git a/claude_desktop_config.json b/claude_desktop_config.json new file mode 100644 index 0000000..563abbf --- /dev/null +++ b/claude_desktop_config.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "ilspy": { + "command": "python", + "args": [ + "-m", + "ilspy_mcp_server.server" + ], + "env": { + "LOGLEVEL": "INFO" + } + } + } +} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6ccd4e1 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,327 @@ +# API Documentation + +This document provides detailed API documentation for the ILSpy MCP Server. + +## Overview + +The ILSpy MCP Server provides a Model Context Protocol (MCP) interface to the ILSpy .NET decompiler. It exposes four main tools and two prompts for interacting with .NET assemblies. + +## Tools + +### 1. decompile_assembly + +Decompiles a .NET assembly to C# source code. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `assembly_path` | string | ✓ | - | Path to the .NET assembly file (.dll or .exe) | +| `output_dir` | string | ✗ | null | Output directory for decompiled files | +| `type_name` | string | ✗ | null | Fully qualified name of specific type to decompile | +| `language_version` | string | ✗ | "Latest" | C# language version to use | +| `create_project` | boolean | ✗ | false | Create a compilable project with multiple files | +| `show_il_code` | boolean | ✗ | false | Show IL code instead of C# | +| `remove_dead_code` | boolean | ✗ | false | Remove dead code during decompilation | +| `nested_directories` | boolean | ✗ | false | Use nested directories for namespaces | + +**Language Versions:** +- `CSharp1` through `CSharp12_0` +- `Preview` (latest preview features) +- `Latest` (default, most recent stable) + +**Example:** +```json +{ + "name": "decompile_assembly", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "type_name": "MyNamespace.MyClass", + "language_version": "CSharp10_0", + "remove_dead_code": true + } +} +``` + +**Response:** +Returns decompiled C# source code as text, or information about saved files if `output_dir` is specified. + +### 2. list_types + +Lists types (classes, interfaces, structs, etc.) in a .NET assembly. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `assembly_path` | string | ✓ | - | Path to the .NET assembly file (.dll or .exe) | +| `entity_types` | array[string] | ✗ | ["c"] | Types of entities to list | + +**Entity Types:** +- `c` - Classes +- `i` - Interfaces +- `s` - Structs +- `d` - Delegates +- `e` - Enums + +**Example:** +```json +{ + "name": "list_types", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "entity_types": ["c", "i", "s"] + } +} +``` + +**Response:** +Returns a formatted list of types organized by namespace, including: +- Type name +- Full qualified name +- Type kind (Class, Interface, etc.) +- Namespace + +### 3. generate_diagrammer + +Generates an interactive HTML diagrammer for visualizing assembly structure. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `assembly_path` | string | ✓ | - | Path to the .NET assembly file (.dll or .exe) | +| `output_dir` | string | ✗ | null | Output directory for the diagrammer | +| `include_pattern` | string | ✗ | null | Regex pattern for types to include | +| `exclude_pattern` | string | ✗ | null | Regex pattern for types to exclude | + +**Example:** +```json +{ + "name": "generate_diagrammer", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "output_dir": "./diagrams", + "include_pattern": "MyNamespace\\..+" + } +} +``` + +**Response:** +Returns success status and output directory path. The HTML file can be opened in a web browser to view the interactive diagram. + +### 4. get_assembly_info + +Gets basic information about a .NET assembly. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `assembly_path` | string | ✓ | - | Path to the .NET assembly file (.dll or .exe) | + +**Example:** +```json +{ + "name": "get_assembly_info", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll" + } +} +``` + +**Response:** +Returns assembly metadata including: +- Assembly name +- Version +- Full name +- Location +- Target framework (if available) +- Runtime version (if available) +- Whether the assembly is signed +- Whether debug information is available + +## Prompts + +### 1. analyze_assembly + +Provides a structured prompt for analyzing a .NET assembly and understanding its structure. + +**Arguments:** + +| Argument | Type | Required | Description | +|----------|------|----------|-------------| +| `assembly_path` | string | ✓ | Path to the .NET assembly file | +| `focus_area` | string | ✗ | Specific area to focus on (types, namespaces, dependencies) | + +**Example:** +```json +{ + "name": "analyze_assembly", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "focus_area": "types" + } +} +``` + +### 2. decompile_and_explain + +Provides a structured prompt for decompiling a specific type and explaining its functionality. + +**Arguments:** + +| Argument | Type | Required | Description | +|----------|------|----------|-------------| +| `assembly_path` | string | ✓ | Path to the .NET assembly file | +| `type_name` | string | ✓ | Fully qualified name of the type to analyze | + +**Example:** +```json +{ + "name": "decompile_and_explain", + "arguments": { + "assembly_path": "/path/to/MyAssembly.dll", + "type_name": "MyNamespace.ImportantClass" + } +} +``` + +## Error Handling + +The server provides detailed error messages for common issues: + +### Validation Errors +- Empty or invalid assembly paths +- Non-existent files +- Invalid file extensions (must be .dll or .exe) +- Invalid reference paths + +### Runtime Errors +- ILSpyCmd not found or not installed +- Permission issues accessing files +- Decompilation failures +- Invalid assembly format + +### Error Response Format +```json +{ + "content": [ + { + "type": "text", + "text": "Validation Error: Assembly file not found: /invalid/path.dll" + } + ] +} +``` + +## Data Models + +### DecompileRequest +```python +class DecompileRequest(BaseModel): + 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 + remove_dead_code: bool = False + nested_directories: bool = False + # ... additional fields +``` + +### TypeInfo +```python +class TypeInfo(BaseModel): + name: str + full_name: str + kind: str + namespace: Optional[str] = None +``` + +### AssemblyInfo +```python +class AssemblyInfo(BaseModel): + name: str + version: str + full_name: str + location: str + target_framework: Optional[str] = None + runtime_version: Optional[str] = None + is_signed: bool = False + has_debug_info: bool = False +``` + +## Usage Examples + +### Basic Decompilation +```python +# Using the MCP client +result = await session.call_tool( + "decompile_assembly", + {"assembly_path": "MyApp.dll"} +) +``` + +### Filtered Type Listing +```python +# List only classes and interfaces +result = await session.call_tool( + "list_types", + { + "assembly_path": "MyApp.dll", + "entity_types": ["c", "i"] + } +) +``` + +### Targeted Decompilation +```python +# Decompile specific type with optimizations +result = await session.call_tool( + "decompile_assembly", + { + "assembly_path": "MyApp.dll", + "type_name": "MyApp.Core.Engine", + "language_version": "CSharp11_0", + "remove_dead_code": true + } +) +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `LOGLEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | INFO | + +### MCP Client Configuration + +For Claude Desktop: +```json +{ + "mcpServers": { + "ilspy": { + "command": "ilspy-mcp-server" + } + } +} +``` + +For development: +```json +{ + "mcpServers": { + "ilspy": { + "command": "python", + "args": ["-m", "ilspy_mcp_server.server"], + "env": { + "LOGLEVEL": "DEBUG" + } + } + } +} +``` \ No newline at end of file diff --git a/mcp_config_example.json b/mcp_config_example.json new file mode 100644 index 0000000..1067cbc --- /dev/null +++ b/mcp_config_example.json @@ -0,0 +1,47 @@ +{ + "description": "Configuration example for Claude Desktop or other MCP clients", + "mcpServers": { + "ilspy": { + "command": "python", + "args": [ + "-m", + "ilspy_mcp_server.server" + ], + "env": { + "LOGLEVEL": "INFO" + } + } + }, + "instructions": [ + "1. Install the package: pip install ilspy-mcp-server", + "2. Make sure ilspycmd is installed: dotnet tool install --global ilspycmd", + "3. Add this configuration to your MCP client's config file", + "4. Set LOGLEVEL to DEBUG for more verbose output if needed" + ], + "example_usage": { + "decompile_assembly": { + "description": "Decompile a .NET assembly", + "parameters": { + "assembly_path": "C:\\path\\to\\your\\assembly.dll", + "type_name": "MyNamespace.MyClass", + "language_version": "Latest", + "remove_dead_code": true + } + }, + "list_types": { + "description": "List types in an assembly", + "parameters": { + "assembly_path": "C:\\path\\to\\your\\assembly.dll", + "entity_types": ["c", "i", "s"] + } + }, + "generate_diagrammer": { + "description": "Generate HTML diagrammer", + "parameters": { + "assembly_path": "C:\\path\\to\\your\\assembly.dll", + "output_dir": "C:\\path\\to\\output", + "include_pattern": "MyNamespace\\..+" + } + } + } +} \ No newline at end of file diff --git a/publish.py b/publish.py new file mode 100644 index 0000000..937f751 --- /dev/null +++ b/publish.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Simple script to build and publish the package to PyPI. +""" + +import subprocess +import sys +from pathlib import Path + + +def run_command(cmd: str) -> bool: + """Run a command and return success status.""" + print(f"🔧 Running: {cmd}") + result = subprocess.run(cmd, shell=True) + return result.returncode == 0 + + +def main(): + """Main function to build and publish package.""" + + # Check if we're in the right directory + if not Path("pyproject.toml").exists(): + print("❌ pyproject.toml not found. Run this script from the project root.") + sys.exit(1) + + print("📦 Building and publishing ilspy-mcp-server...") + + # Clean previous builds + print("\n1. Cleaning previous builds...") + run_command("rm -rf dist/ build/") + + # Build the package + print("\n2. Building package...") + if not run_command("python -m build"): + print("❌ Build failed!") + sys.exit(1) + + # Check the package + print("\n3. Checking package...") + if not run_command("twine check dist/*"): + print("❌ Package check failed!") + sys.exit(1) + + # Ask for confirmation + print("\n4. Ready to upload to PyPI") + response = input("Do you want to upload to PyPI? (y/N): ") + + if response.lower() != 'y': + print("Upload cancelled.") + sys.exit(0) + + # Upload to PyPI + print("\n5. Uploading to PyPI...") + if not run_command("twine upload dist/*"): + print("❌ Upload failed!") + sys.exit(1) + + print("\n✅ Package successfully published to PyPI!") + print("📦 Install with: pip install ilspy-mcp-server") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e44031 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "ilspy-mcp-server" +version = "0.1.0" +description = "MCP Server for ILSpy .NET Decompiler" +authors = [ + {name = "Borealin", email = "me@borealin.cn"} +] +dependencies = [ + "mcp>=0.1.0", + "pydantic>=2.0.0", +] +requires-python = ">=3.8" +readme = "README.md" +license = {text = "MIT"} +keywords = ["mcp", "ilspy", "decompiler", "dotnet", "csharp", "reverse-engineering"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Debuggers", + "Topic :: System :: Software Distribution", +] + +[project.urls] +Homepage = "https://github.com/Borealin/ilspy-mcp-server" +Repository = "https://github.com/Borealin/ilspy-mcp-server.git" +Issues = "https://github.com/Borealin/ilspy-mcp-server/issues" + +[project.scripts] +ilspy-mcp-server = "ilspy_mcp_server.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ilspy_mcp_server"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/README.md", + "/LICENSE", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e4c2a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp>=0.1.0 +pydantic>=2.0.0 \ No newline at end of file diff --git a/src/ilspy_mcp_server/__init__.py b/src/ilspy_mcp_server/__init__.py new file mode 100644 index 0000000..97118fe --- /dev/null +++ b/src/ilspy_mcp_server/__init__.py @@ -0,0 +1,3 @@ +"""ILSpy MCP Server - Model Context Protocol server for .NET decompilation.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/src/ilspy_mcp_server/ilspy_wrapper.py b/src/ilspy_mcp_server/ilspy_wrapper.py new file mode 100644 index 0000000..5fd9407 --- /dev/null +++ b/src/ilspy_mcp_server/ilspy_wrapper.py @@ -0,0 +1,389 @@ +"""Wrapper for ICSharpCode.ILSpyCmd command line tool.""" + +import asyncio +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple, Dict, Any +import re +import logging + +from .models import ( + DecompileRequest, DecompileResponse, + ListTypesRequest, ListTypesResponse, TypeInfo, + GenerateDiagrammerRequest, AssemblyInfoRequest, AssemblyInfo, + EntityType +) + +logger = logging.getLogger(__name__) + + +class ILSpyWrapper: + """Wrapper class for ILSpy command line tool.""" + + def __init__(self, ilspycmd_path: Optional[str] = None): + """Initialize the wrapper. + + Args: + ilspycmd_path: Path to ilspycmd executable. If None, will try to find it in PATH. + """ + self.ilspycmd_path = ilspycmd_path or self._find_ilspycmd() + if not self.ilspycmd_path: + raise RuntimeError("ILSpyCmd not found. Please install it with: dotnet tool install --global ilspycmd") + + def _find_ilspycmd(self) -> Optional[str]: + """Find ilspycmd executable in PATH.""" + # Try common names + for cmd_name in ["ilspycmd", "ilspycmd.exe"]: + path = shutil.which(cmd_name) + if path: + return path + return None + + async def _run_command(self, args: List[str], input_data: Optional[str] = None) -> Tuple[int, str, str]: + """Run ilspycmd with given arguments. + + Args: + args: Command line arguments + input_data: Optional input data to pass to stdin + + Returns: + Tuple of (return_code, stdout, stderr) + """ + cmd = [self.ilspycmd_path] + args + logger.debug(f"Running command: {' '.join(cmd)}") + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE if input_data else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + input_bytes = input_data.encode('utf-8') if input_data else None + stdout_bytes, stderr_bytes = await process.communicate(input=input_bytes) + + stdout = stdout_bytes.decode('utf-8', errors='replace') if stdout_bytes else "" + stderr = stderr_bytes.decode('utf-8', errors='replace') if stderr_bytes else "" + + return process.returncode, stdout, stderr + + except Exception as e: + logger.error(f"Error running command: {e}") + return -1, "", str(e) + + async def decompile(self, request: DecompileRequest) -> DecompileResponse: + """Decompile a .NET assembly. + + Args: + request: Decompilation request + + Returns: + Decompilation response + """ + if not os.path.exists(request.assembly_path): + return DecompileResponse( + success=False, + error_message=f"Assembly file not found: {request.assembly_path}", + assembly_name=os.path.basename(request.assembly_path) + ) + + args = [request.assembly_path] + + # Add language version + args.extend(["-lv", request.language_version.value]) + + # Add type filter if specified + if request.type_name: + args.extend(["-t", request.type_name]) + + # Add output directory if specified + temp_dir = None + output_dir = request.output_dir + if not output_dir: + temp_dir = tempfile.mkdtemp() + output_dir = temp_dir + + args.extend(["-o", output_dir]) + + # Add project creation flag + 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: + 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 + 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: + args.append("--nested-directories") + + # Disable update check for automation + args.append("--disable-updatecheck") + + try: + return_code, stdout, stderr = await self._run_command(args) + + assembly_name = os.path.splitext(os.path.basename(request.assembly_path))[0] + + if return_code == 0: + # If no output directory was specified, return stdout as source code + source_code = None + output_path = None + + if request.output_dir is None: + source_code = stdout + else: + output_path = output_dir + # Try to read the main generated file if it exists + if request.type_name: + # Single type decompilation + type_file = os.path.join(output_dir, f"{request.type_name.split('.')[-1]}.cs") + if os.path.exists(type_file): + with open(type_file, 'r', encoding='utf-8') as f: + source_code = f.read() + elif not request.create_project: + # Single file decompilation + cs_file = os.path.join(output_dir, f"{assembly_name}.cs") + if os.path.exists(cs_file): + with open(cs_file, 'r', encoding='utf-8') as f: + source_code = f.read() + + return DecompileResponse( + success=True, + source_code=source_code, + output_path=output_path, + assembly_name=assembly_name, + type_name=request.type_name + ) + else: + error_msg = stderr or stdout or "Unknown error occurred" + return DecompileResponse( + success=False, + error_message=error_msg, + assembly_name=assembly_name, + type_name=request.type_name + ) + + except Exception as e: + return DecompileResponse( + success=False, + error_message=str(e), + assembly_name=os.path.basename(request.assembly_path), + type_name=request.type_name + ) + finally: + # Clean up temporary directory + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + + async def list_types(self, request: ListTypesRequest) -> ListTypesResponse: + """List types in a .NET assembly. + + Args: + request: List types request + + Returns: + List types response + """ + if not os.path.exists(request.assembly_path): + return ListTypesResponse( + success=False, + error_message=f"Assembly file not found: {request.assembly_path}" + ) + + args = [request.assembly_path] + + # Add entity types to list + entity_types_str = "".join([et.value for et in request.entity_types]) + args.extend(["-l", entity_types_str]) + + # Add reference paths + for ref_path in request.reference_paths: + args.extend(["-r", ref_path]) + + # Disable update check + args.append("--disable-updatecheck") + + try: + return_code, stdout, stderr = await self._run_command(args) + + if return_code == 0: + types = self._parse_types_output(stdout) + return ListTypesResponse( + success=True, + types=types, + total_count=len(types) + ) + else: + error_msg = stderr or stdout or "Unknown error occurred" + return ListTypesResponse( + success=False, + error_message=error_msg + ) + + except Exception as e: + return ListTypesResponse( + success=False, + error_message=str(e) + ) + + def _parse_types_output(self, output: str) -> List[TypeInfo]: + """Parse the output from list types command. + + Args: + output: Raw output from ilspycmd + + Returns: + List of TypeInfo objects + """ + types = [] + lines = output.strip().split('\n') + + for line in lines: + line = line.strip() + if not line: + continue + + # Parse the line format: "TypeKind: FullTypeName" + match = re.match(r'^(\w+):\s*(.+)$', line) + if match: + kind = match.group(1) + full_name = match.group(2) + + # Extract namespace and name + parts = full_name.split('.') + if len(parts) > 1: + namespace = '.'.join(parts[:-1]) + name = parts[-1] + else: + namespace = None + name = full_name + + types.append(TypeInfo( + name=name, + full_name=full_name, + kind=kind, + namespace=namespace + )) + + return types + + async def generate_diagrammer(self, request: GenerateDiagrammerRequest) -> Dict[str, Any]: + """Generate HTML diagrammer for an assembly. + + Args: + request: Generate diagrammer request + + Returns: + Dictionary with success status and details + """ + if not os.path.exists(request.assembly_path): + return { + "success": False, + "error_message": f"Assembly file not found: {request.assembly_path}" + } + + args = [request.assembly_path, "--generate-diagrammer"] + + # Add output directory + output_dir = request.output_dir + if not output_dir: + # Generate next to assembly + assembly_dir = os.path.dirname(request.assembly_path) + output_dir = os.path.join(assembly_dir, "diagrammer") + + args.extend(["-o", output_dir]) + + # Add include/exclude patterns + if request.include_pattern: + args.extend(["--generate-diagrammer-include", request.include_pattern]) + if request.exclude_pattern: + args.extend(["--generate-diagrammer-exclude", request.exclude_pattern]) + + # Add documentation file + if request.docs_path: + args.extend(["--generate-diagrammer-docs", request.docs_path]) + + # Add namespace stripping + if request.strip_namespaces: + args.extend(["--generate-diagrammer-strip-namespaces"] + request.strip_namespaces) + + # Add report excluded flag + if request.report_excluded: + args.append("--generate-diagrammer-report-excluded") + + # Disable update check + args.append("--disable-updatecheck") + + try: + return_code, stdout, stderr = await self._run_command(args) + + if return_code == 0: + return { + "success": True, + "output_directory": output_dir, + "message": "HTML diagrammer generated successfully" + } + else: + error_msg = stderr or stdout or "Unknown error occurred" + return { + "success": False, + "error_message": error_msg + } + + except Exception as e: + return { + "success": False, + "error_message": str(e) + } + + async def get_assembly_info(self, request: AssemblyInfoRequest) -> AssemblyInfo: + """Get basic information about an assembly. + + Args: + request: Assembly info request + + Returns: + Assembly information + """ + if not os.path.exists(request.assembly_path): + raise FileNotFoundError(f"Assembly file not found: {request.assembly_path}") + + # For now, we'll extract basic info from the file path + # In a more complete implementation, we could use reflection or metadata reading + assembly_path = Path(request.assembly_path) + + return AssemblyInfo( + name=assembly_path.stem, + version="Unknown", + full_name=assembly_path.name, + location=str(assembly_path.absolute()), + target_framework=None, + runtime_version=None, + is_signed=False, + has_debug_info=os.path.exists(assembly_path.with_suffix('.pdb')) + ) \ No newline at end of file diff --git a/src/ilspy_mcp_server/models.py b/src/ilspy_mcp_server/models.py new file mode 100644 index 0000000..1dc3389 --- /dev/null +++ b/src/ilspy_mcp_server/models.py @@ -0,0 +1,211 @@ +"""Data models for ILSpy MCP Server.""" + +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, validator +from enum import Enum +import os + + +class LanguageVersion(str, Enum): + """C# Language versions supported by ILSpy.""" + CSHARP1 = "CSharp1" + CSHARP2 = "CSharp2" + CSHARP3 = "CSharp3" + CSHARP4 = "CSharp4" + CSHARP5 = "CSharp5" + CSHARP6 = "CSharp6" + CSHARP7 = "CSharp7" + CSHARP7_1 = "CSharp7_1" + CSHARP7_2 = "CSharp7_2" + CSHARP7_3 = "CSharp7_3" + CSHARP8_0 = "CSharp8_0" + CSHARP9_0 = "CSharp9_0" + CSHARP10_0 = "CSharp10_0" + CSHARP11_0 = "CSharp11_0" + CSHARP12_0 = "CSharp12_0" + PREVIEW = "Preview" + LATEST = "Latest" + + +class EntityType(str, Enum): + """Entity types that can be listed.""" + CLASS = "c" + INTERFACE = "i" + 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 + + +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 + + +class TypeInfo(BaseModel): + """Information about a type in an assembly.""" + name: str + full_name: str + kind: str + namespace: Optional[str] = None + + +class DecompileResponse(BaseModel): + """Response from decompilation operation.""" + success: bool + source_code: Optional[str] = None + output_path: Optional[str] = None + error_message: Optional[str] = None + assembly_name: str + type_name: Optional[str] = None + + +class ListTypesResponse(BaseModel): + """Response from list types operation.""" + success: bool + types: List[TypeInfo] = Field(default_factory=list) + 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 + + +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 + + +class AssemblyInfo(BaseModel): + """Information about an assembly.""" + name: str + version: str + full_name: str + location: str + target_framework: Optional[str] = None + runtime_version: Optional[str] = None + is_signed: bool = False + has_debug_info: bool = False \ No newline at end of file diff --git a/src/ilspy_mcp_server/server.py b/src/ilspy_mcp_server/server.py new file mode 100644 index 0000000..71475bf --- /dev/null +++ b/src/ilspy_mcp_server/server.py @@ -0,0 +1,416 @@ +"""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 .ilspy_wrapper import ILSpyWrapper +from .models import ( + DecompileRequest, ListTypesRequest, GenerateDiagrammerRequest, + AssemblyInfoRequest, LanguageVersion, EntityType +) + +# Set up logging +log_level = os.getenv('LOGLEVEL', 'INFO').upper() +numeric_level = getattr(logging, log_level, logging.INFO) +logging.basicConfig( + level=numeric_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Create the MCP server +server = Server("ilspy-mcp-server") + +# Global ILSpy wrapper instance +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.""" + 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)}")] + ) + + try: + if name == "decompile_assembly": + request = DecompileRequest(**arguments) + response = await ilspy_wrapper.decompile(request) + + if response.success: + if response.source_code: + content = f"# Decompiled: {response.assembly_name}" + if response.type_name: + content += f" - {response.type_name}" + content += "\n\n```csharp\n" + response.source_code + "\n```" + else: + content = f"Decompilation successful. Files saved to: {response.output_path}" + else: + content = f"Decompilation failed: {response.error_message}" + + return CallToolResult( + content=[TextContent(type="text", text=content)] + ) + + elif name == "list_types": + request = ListTypesRequest(**arguments) + response = await ilspy_wrapper.list_types(request) + + if response.success: + if response.types: + content = f"# Types in {arguments['assembly_path']}\n\n" + content += f"Found {response.total_count} types:\n\n" + + # Group by namespace + by_namespace = {} + for type_info in response.types: + ns = type_info.namespace or "(Global)" + if ns not in by_namespace: + by_namespace[ns] = [] + by_namespace[ns].append(type_info) + + for namespace, types in sorted(by_namespace.items()): + content += f"## {namespace}\n\n" + for type_info in sorted(types, key=lambda t: t.name): + 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)] + ) + + elif name == "generate_diagrammer": + request = GenerateDiagrammerRequest(**arguments) + response = await ilspy_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." + else: + content = f"Failed to generate diagrammer: {response['error_message']}" + + return CallToolResult( + content=[TextContent(type="text", text=content)] + ) + + elif name == "get_assembly_info": + request = AssemblyInfoRequest(**arguments) + info = await ilspy_wrapper.get_assembly_info(request) + + content = f"# Assembly Information\n\n" + content += f"- **Name**: {info.name}\n" + content += f"- **Full Name**: {info.full_name}\n" + content += f"- **Location**: {info.location}\n" + content += f"- **Version**: {info.version}\n" + if info.target_framework: + content += f"- **Target Framework**: {info.target_framework}\n" + if info.runtime_version: + content += f"- **Runtime Version**: {info.runtime_version}\n" + 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)] + ) + + 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.")] + ) + + +@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}". + +Please help me understand: +1. The overall structure and organization of the assembly +2. Key types and their relationships +3. Main namespaces and their purposes +4. Any notable patterns or architectural decisions + +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}". + +Please: +1. Decompile this specific type +2. Explain what this type does and its purpose +3. Highlight any interesting patterns, design decisions, or potential issues +4. Suggest how this type fits into the overall architecture + +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()) \ No newline at end of file