refactor: consolidate utils, constants, models (A1-A8)
This commit is contained in:
commit
4bd9ce19af
@ -2,8 +2,8 @@
|
|||||||
"project": "mcilspy-code-review-fixes",
|
"project": "mcilspy-code-review-fixes",
|
||||||
"created": "2025-02-08T00:00:00Z",
|
"created": "2025-02-08T00:00:00Z",
|
||||||
"domains": {
|
"domains": {
|
||||||
"security": { "status": "ready", "branch": "fix/security", "priority": 1 },
|
"security": { "status": "merged", "branch": "fix/security", "priority": 1 },
|
||||||
"architecture": { "status": "ready", "branch": "fix/architecture", "priority": 2 },
|
"architecture": { "status": "merged", "branch": "fix/architecture", "priority": 2 },
|
||||||
"performance": { "status": "ready", "branch": "fix/performance", "priority": 3 },
|
"performance": { "status": "ready", "branch": "fix/performance", "priority": 3 },
|
||||||
"testing": { "status": "ready", "branch": "fix/testing", "priority": 4 }
|
"testing": { "status": "ready", "branch": "fix/testing", "priority": 4 }
|
||||||
},
|
},
|
||||||
|
|||||||
49
src/mcilspy/constants.py
Normal file
49
src/mcilspy/constants.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""Constants and configuration values for mcilspy.
|
||||||
|
|
||||||
|
This module centralizes all timeouts, limits, and magic numbers used throughout
|
||||||
|
the codebase. Import from here rather than hardcoding values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Subprocess Timeouts
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum time to wait for ilspycmd decompilation (in seconds)
|
||||||
|
# Large assemblies or corrupted files may take longer
|
||||||
|
DECOMPILE_TIMEOUT_SECONDS: float = 300.0 # 5 minutes
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Output Limits
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum characters to display from subprocess output in error messages
|
||||||
|
MAX_ERROR_OUTPUT_CHARS: int = 1000
|
||||||
|
|
||||||
|
# Maximum line length when displaying code snippets (truncate longer lines)
|
||||||
|
MAX_LINE_LENGTH: int = 200
|
||||||
|
|
||||||
|
# Maximum unparsed lines to log before suppressing (avoid log spam)
|
||||||
|
MAX_UNPARSED_LOG_LINES: int = 3
|
||||||
|
|
||||||
|
# Preview length for unparsed line debug messages
|
||||||
|
UNPARSED_LINE_PREVIEW_LENGTH: int = 100
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Search Limits
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default maximum results for search operations
|
||||||
|
DEFAULT_MAX_SEARCH_RESULTS: int = 100
|
||||||
|
|
||||||
|
# Maximum matches to display per type in grouped results
|
||||||
|
MAX_MATCHES_PER_TYPE: int = 20
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tool Identifiers (for ilspycmd CLI)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default entity types for list operations
|
||||||
|
DEFAULT_ENTITY_TYPES: list[str] = ["class"]
|
||||||
|
|
||||||
|
# All supported entity types
|
||||||
|
ALL_ENTITY_TYPES: list[str] = ["class", "interface", "struct", "delegate", "enum"]
|
||||||
@ -9,6 +9,11 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
DECOMPILE_TIMEOUT_SECONDS,
|
||||||
|
MAX_UNPARSED_LOG_LINES,
|
||||||
|
UNPARSED_LINE_PREVIEW_LENGTH,
|
||||||
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AssemblyInfo,
|
AssemblyInfo,
|
||||||
AssemblyInfoRequest,
|
AssemblyInfoRequest,
|
||||||
@ -19,6 +24,7 @@ from .models import (
|
|||||||
ListTypesResponse,
|
ListTypesResponse,
|
||||||
TypeInfo,
|
TypeInfo,
|
||||||
)
|
)
|
||||||
|
from .utils import find_ilspycmd_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -28,7 +34,27 @@ MAX_OUTPUT_BYTES = 50_000_000 # 50 MB
|
|||||||
|
|
||||||
|
|
||||||
class ILSpyWrapper:
|
class ILSpyWrapper:
|
||||||
"""Wrapper class for ILSpy command line tool."""
|
"""Wrapper class for ILSpy command line tool.
|
||||||
|
|
||||||
|
This class encapsulates all interactions with the ilspycmd CLI tool.
|
||||||
|
While the wrapper is stateless in terms of decompilation operations
|
||||||
|
(each call is independent), it exists as a class to:
|
||||||
|
|
||||||
|
1. Cache the ilspycmd path lookup - Finding the executable involves
|
||||||
|
checking PATH and several common installation locations, which
|
||||||
|
is relatively expensive. Caching this on instantiation avoids
|
||||||
|
repeated filesystem operations.
|
||||||
|
|
||||||
|
2. Provide a single point of configuration - If ilspycmd is not found,
|
||||||
|
we fail fast at wrapper creation rather than on each tool call.
|
||||||
|
|
||||||
|
3. Enable future extensions - The class structure allows adding
|
||||||
|
connection pooling, result caching, or other optimizations without
|
||||||
|
changing the API.
|
||||||
|
|
||||||
|
The wrapper should be created once and reused across tool calls.
|
||||||
|
See get_wrapper() in server.py for the recommended usage pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, ilspycmd_path: str | None = None) -> None:
|
def __init__(self, ilspycmd_path: str | None = None) -> None:
|
||||||
"""Initialize the wrapper.
|
"""Initialize the wrapper.
|
||||||
@ -36,48 +62,12 @@ class ILSpyWrapper:
|
|||||||
Args:
|
Args:
|
||||||
ilspycmd_path: Path to ilspycmd executable. If None, will try to find it in PATH.
|
ilspycmd_path: Path to ilspycmd executable. If None, will try to find it in PATH.
|
||||||
"""
|
"""
|
||||||
self.ilspycmd_path = ilspycmd_path or self._find_ilspycmd()
|
self.ilspycmd_path = ilspycmd_path or find_ilspycmd_path()
|
||||||
if not self.ilspycmd_path:
|
if not self.ilspycmd_path:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"ILSpyCmd not found. Please install it with: dotnet tool install --global ilspycmd"
|
"ILSpyCmd not found. Please install it with: dotnet tool install --global ilspycmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_ilspycmd(self) -> str | None:
|
|
||||||
"""Find ilspycmd executable in PATH or common install locations.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Standard PATH (via shutil.which)
|
|
||||||
2. ~/.dotnet/tools (default location for 'dotnet tool install --global')
|
|
||||||
3. Platform-specific locations
|
|
||||||
"""
|
|
||||||
# Try standard PATH first
|
|
||||||
for cmd_name in ["ilspycmd", "ilspycmd.exe"]:
|
|
||||||
path = shutil.which(cmd_name)
|
|
||||||
if path:
|
|
||||||
return path
|
|
||||||
|
|
||||||
# Check common dotnet tools locations (not always in PATH)
|
|
||||||
home = os.path.expanduser("~")
|
|
||||||
dotnet_tools_paths = [
|
|
||||||
os.path.join(home, ".dotnet", "tools", "ilspycmd"),
|
|
||||||
os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Windows-specific paths
|
|
||||||
if os.name == "nt":
|
|
||||||
userprofile = os.environ.get("USERPROFILE", "")
|
|
||||||
if userprofile:
|
|
||||||
dotnet_tools_paths.extend([
|
|
||||||
os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"),
|
|
||||||
])
|
|
||||||
|
|
||||||
for tool_path in dotnet_tools_paths:
|
|
||||||
if os.path.isfile(tool_path) and os.access(tool_path, os.X_OK):
|
|
||||||
logger.info(f"Found ilspycmd at {tool_path} (not in PATH)")
|
|
||||||
return tool_path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _run_command(
|
async def _run_command(
|
||||||
self, args: list[str], input_data: str | None = None
|
self, args: list[str], input_data: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
@ -107,17 +97,18 @@ class ILSpyWrapper:
|
|||||||
|
|
||||||
input_bytes = input_data.encode("utf-8") if input_data else None
|
input_bytes = input_data.encode("utf-8") if input_data else None
|
||||||
|
|
||||||
# Timeout after 5 minutes to prevent hanging on malicious/corrupted assemblies
|
# Timeout to prevent hanging on malicious/corrupted assemblies
|
||||||
try:
|
try:
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
process.communicate(input=input_bytes),
|
process.communicate(input=input_bytes),
|
||||||
timeout=300.0 # 5 minutes
|
timeout=DECOMPILE_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("Command timed out after 5 minutes, killing process")
|
timeout_mins = DECOMPILE_TIMEOUT_SECONDS / 60
|
||||||
|
logger.warning(f"Command timed out after {timeout_mins:.0f} minutes, killing process")
|
||||||
process.kill()
|
process.kill()
|
||||||
await process.wait() # Ensure process is cleaned up
|
await process.wait() # Ensure process is cleaned up
|
||||||
return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex."
|
return -1, "", f"Command timed out after {timeout_mins:.0f} minutes. The assembly may be corrupted or too complex."
|
||||||
|
|
||||||
# Truncate output if it exceeds the limit to prevent memory exhaustion
|
# Truncate output if it exceeds the limit to prevent memory exhaustion
|
||||||
stdout_truncated = False
|
stdout_truncated = False
|
||||||
@ -369,10 +360,10 @@ class ILSpyWrapper:
|
|||||||
else:
|
else:
|
||||||
# Log unexpected lines (but don't fail - ilspycmd may output warnings/info)
|
# Log unexpected lines (but don't fail - ilspycmd may output warnings/info)
|
||||||
unparsed_count += 1
|
unparsed_count += 1
|
||||||
if unparsed_count <= 3: # Avoid log spam
|
if unparsed_count <= MAX_UNPARSED_LOG_LINES:
|
||||||
logger.debug(f"Skipping unparsed line from ilspycmd: {line[:100]}")
|
logger.debug(f"Skipping unparsed line from ilspycmd: {line[:UNPARSED_LINE_PREVIEW_LENGTH]}")
|
||||||
|
|
||||||
if unparsed_count > 3:
|
if unparsed_count > MAX_UNPARSED_LOG_LINES:
|
||||||
logger.debug(f"Skipped {unparsed_count} unparsed lines total")
|
logger.debug(f"Skipped {unparsed_count} unparsed lines total")
|
||||||
|
|
||||||
return types
|
return types
|
||||||
|
|||||||
@ -4,107 +4,39 @@ Provides access to all 34+ CLR metadata tables without requiring ilspycmd.
|
|||||||
This enables searching for methods, fields, properties, events, and resources
|
This enables searching for methods, fields, properties, events, and resources
|
||||||
that are not exposed via the ilspycmd CLI.
|
that are not exposed via the ilspycmd CLI.
|
||||||
|
|
||||||
|
This module contains CPU-bound synchronous code for parsing .NET PE metadata.
|
||||||
|
For heavy workloads with many concurrent requests, consider running these
|
||||||
|
operations in a thread pool (e.g., asyncio.to_thread) to avoid blocking
|
||||||
|
the event loop.
|
||||||
|
|
||||||
Note: dnfile provides flag attributes as boolean properties (e.g., mdPublic, fdStatic)
|
Note: dnfile provides flag attributes as boolean properties (e.g., mdPublic, fdStatic)
|
||||||
rather than traditional IntFlag enums, so we use those directly.
|
rather than traditional IntFlag enums, so we use those directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dnfile
|
import dnfile
|
||||||
from dnfile.mdtable import TypeDefRow
|
from dnfile.mdtable import TypeDefRow
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AssemblyMetadata,
|
||||||
|
EventInfo,
|
||||||
|
FieldInfo,
|
||||||
|
MethodInfo,
|
||||||
|
PropertyInfo,
|
||||||
|
ResourceInfo,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Maximum assembly file size to load (in megabytes)
|
# Maximum assembly file size to load (in megabytes)
|
||||||
# Prevents memory exhaustion from extremely large or malicious assemblies
|
# Prevents memory exhaustion from extremely large or malicious assemblies
|
||||||
MAX_ASSEMBLY_SIZE_MB = 500
|
MAX_ASSEMBLY_SIZE_MB = 500
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MethodInfo:
|
|
||||||
"""Information about a method in an assembly."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
declaring_type: str
|
|
||||||
namespace: str | None
|
|
||||||
return_type: str | None = None
|
|
||||||
is_public: bool = False
|
|
||||||
is_static: bool = False
|
|
||||||
is_virtual: bool = False
|
|
||||||
is_abstract: bool = False
|
|
||||||
parameters: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FieldInfo:
|
|
||||||
"""Information about a field in an assembly."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
declaring_type: str
|
|
||||||
namespace: str | None
|
|
||||||
field_type: str | None = None
|
|
||||||
is_public: bool = False
|
|
||||||
is_static: bool = False
|
|
||||||
is_literal: bool = False # Constant
|
|
||||||
default_value: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PropertyInfo:
|
|
||||||
"""Information about a property in an assembly."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
declaring_type: str
|
|
||||||
namespace: str | None
|
|
||||||
property_type: str | None = None
|
|
||||||
has_getter: bool = False
|
|
||||||
has_setter: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EventInfo:
|
|
||||||
"""Information about an event in an assembly."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
declaring_type: str
|
|
||||||
namespace: str | None
|
|
||||||
event_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResourceInfo:
|
|
||||||
"""Information about an embedded resource."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
size: int
|
|
||||||
is_public: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssemblyMetadata:
|
|
||||||
"""Complete assembly metadata from dnfile."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
version: str
|
|
||||||
culture: str | None = None
|
|
||||||
public_key_token: str | None = None
|
|
||||||
target_framework: str | None = None
|
|
||||||
type_count: int = 0
|
|
||||||
method_count: int = 0
|
|
||||||
field_count: int = 0
|
|
||||||
property_count: int = 0
|
|
||||||
event_count: int = 0
|
|
||||||
resource_count: int = 0
|
|
||||||
referenced_assemblies: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AssemblySizeError(ValueError):
|
class AssemblySizeError(ValueError):
|
||||||
"""Raised when an assembly exceeds the maximum allowed size."""
|
"""Raised when an assembly exceeds the maximum allowed size."""
|
||||||
|
|
||||||
|
|||||||
@ -161,3 +161,84 @@ class AssemblyInfo(BaseModel):
|
|||||||
runtime_version: str | None = None
|
runtime_version: str | None = None
|
||||||
is_signed: bool = False
|
is_signed: bool = False
|
||||||
has_debug_info: bool = False
|
has_debug_info: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metadata Reader Models (dnfile-based direct metadata access)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MethodInfo(BaseModel):
|
||||||
|
"""Information about a method in an assembly."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
declaring_type: str
|
||||||
|
namespace: str | None = None
|
||||||
|
return_type: str | None = None
|
||||||
|
is_public: bool = False
|
||||||
|
is_static: bool = False
|
||||||
|
is_virtual: bool = False
|
||||||
|
is_abstract: bool = False
|
||||||
|
parameters: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldInfo(BaseModel):
|
||||||
|
"""Information about a field in an assembly."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
declaring_type: str
|
||||||
|
namespace: str | None = None
|
||||||
|
field_type: str | None = None
|
||||||
|
is_public: bool = False
|
||||||
|
is_static: bool = False
|
||||||
|
is_literal: bool = False # Constant
|
||||||
|
default_value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyInfo(BaseModel):
|
||||||
|
"""Information about a property in an assembly."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
declaring_type: str
|
||||||
|
namespace: str | None = None
|
||||||
|
property_type: str | None = None
|
||||||
|
has_getter: bool = False
|
||||||
|
has_setter: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class EventInfo(BaseModel):
|
||||||
|
"""Information about an event in an assembly."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
declaring_type: str
|
||||||
|
namespace: str | None = None
|
||||||
|
event_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceInfo(BaseModel):
|
||||||
|
"""Information about an embedded resource."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
size: int = 0
|
||||||
|
is_public: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AssemblyMetadata(BaseModel):
|
||||||
|
"""Complete assembly metadata from dnfile."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
culture: str | None = None
|
||||||
|
public_key_token: str | None = None
|
||||||
|
target_framework: str | None = None
|
||||||
|
type_count: int = 0
|
||||||
|
method_count: int = 0
|
||||||
|
field_count: int = 0
|
||||||
|
property_count: int = 0
|
||||||
|
event_count: int = 0
|
||||||
|
resource_count: int = 0
|
||||||
|
referenced_assemblies: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@ -5,12 +5,19 @@ import platform
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
ALL_ENTITY_TYPES,
|
||||||
|
DEFAULT_MAX_SEARCH_RESULTS,
|
||||||
|
MAX_ERROR_OUTPUT_CHARS,
|
||||||
|
MAX_LINE_LENGTH,
|
||||||
|
MAX_MATCHES_PER_TYPE,
|
||||||
|
)
|
||||||
from .ilspy_wrapper import ILSpyWrapper
|
from .ilspy_wrapper import ILSpyWrapper
|
||||||
from .models import EntityType, LanguageVersion
|
from .models import EntityType, LanguageVersion
|
||||||
|
from .utils import find_ilspycmd_path
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||||
@ -21,27 +28,21 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Module-level cached wrapper instance (lazily initialized)
|
||||||
class AppState:
|
# This avoids complex lifespan context access and provides simple caching
|
||||||
"""Application state shared across tools via lifespan context."""
|
_cached_wrapper: ILSpyWrapper | None = None
|
||||||
|
|
||||||
wrapper: ILSpyWrapper | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def app_lifespan(server: FastMCP):
|
async def app_lifespan(server: FastMCP):
|
||||||
"""Initialize application state on startup, cleanup on shutdown.
|
"""Initialize application state on startup, cleanup on shutdown."""
|
||||||
|
global _cached_wrapper
|
||||||
The ILSpyWrapper is lazily initialized on first use to avoid
|
|
||||||
failing startup if ilspycmd isn't installed (dnfile-based tools
|
|
||||||
still work without it).
|
|
||||||
"""
|
|
||||||
state = AppState()
|
|
||||||
logger.info("mcilspy server starting up")
|
logger.info("mcilspy server starting up")
|
||||||
try:
|
try:
|
||||||
yield {"app_state": state}
|
yield {}
|
||||||
finally:
|
finally:
|
||||||
logger.info("mcilspy server shutting down")
|
logger.info("mcilspy server shutting down")
|
||||||
|
_cached_wrapper = None # Clear cache on shutdown
|
||||||
|
|
||||||
|
|
||||||
# Create FastMCP server with lifespan
|
# Create FastMCP server with lifespan
|
||||||
@ -49,12 +50,14 @@ mcp = FastMCP("mcilspy", lifespan=app_lifespan)
|
|||||||
|
|
||||||
|
|
||||||
def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper:
|
def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper:
|
||||||
"""Get ILSpy wrapper instance from context or create new one.
|
"""Get ILSpy wrapper instance, creating one if needed.
|
||||||
|
|
||||||
|
The wrapper is cached at module level for efficiency. It's lazily
|
||||||
|
initialized on first use to avoid failing startup if ilspycmd
|
||||||
|
isn't installed (dnfile-based tools still work without it).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: Optional FastMCP context. If provided, uses lifespan state
|
ctx: Optional FastMCP context (unused, kept for API compatibility)
|
||||||
for caching. If None, creates a new instance (for backwards
|
|
||||||
compatibility with non-tool callers).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ILSpyWrapper instance
|
ILSpyWrapper instance
|
||||||
@ -62,22 +65,10 @@ def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If ilspycmd is not installed
|
RuntimeError: If ilspycmd is not installed
|
||||||
"""
|
"""
|
||||||
if ctx is not None:
|
global _cached_wrapper
|
||||||
# Use lifespan state for caching (access via request_context)
|
if _cached_wrapper is None:
|
||||||
try:
|
_cached_wrapper = ILSpyWrapper()
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
return _cached_wrapper
|
||||||
if lifespan_ctx is not None:
|
|
||||||
state: AppState = lifespan_ctx.get("app_state")
|
|
||||||
if state is not None:
|
|
||||||
if state.wrapper is None:
|
|
||||||
state.wrapper = ILSpyWrapper()
|
|
||||||
return state.wrapper
|
|
||||||
except (AttributeError, ValueError):
|
|
||||||
# Context not fully initialized, fall through to create new instance
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: create new instance (no caching)
|
|
||||||
return ILSpyWrapper()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_error(error: Exception, context: str = "") -> str:
|
def _format_error(error: Exception, context: str = "") -> str:
|
||||||
@ -147,31 +138,29 @@ def _validate_assembly_path(assembly_path: str) -> str:
|
|||||||
return resolved_path
|
return resolved_path
|
||||||
|
|
||||||
|
|
||||||
def _find_ilspycmd_path() -> str | None:
|
def _compile_search_pattern(
|
||||||
"""Find ilspycmd in PATH or common install locations."""
|
pattern: str, case_sensitive: bool, use_regex: bool
|
||||||
# Check PATH first
|
) -> tuple[re.Pattern | None, str | None]:
|
||||||
path = shutil.which("ilspycmd")
|
"""Compile a search pattern into a regex, handling errors.
|
||||||
if path:
|
|
||||||
return path
|
|
||||||
|
|
||||||
# Check common dotnet tools locations (often not in PATH for MCP servers)
|
Args:
|
||||||
home = os.path.expanduser("~")
|
pattern: The search pattern string
|
||||||
candidates = [
|
case_sensitive: Whether matching should be case-sensitive
|
||||||
os.path.join(home, ".dotnet", "tools", "ilspycmd"),
|
use_regex: Whether to treat pattern as a regular expression
|
||||||
os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Windows-specific
|
Returns:
|
||||||
if os.name == "nt":
|
Tuple of (compiled_pattern, error_message). If use_regex is False,
|
||||||
userprofile = os.environ.get("USERPROFILE", "")
|
returns (None, None). If regex compilation fails, returns
|
||||||
if userprofile:
|
(None, error_message). Otherwise returns (pattern, None).
|
||||||
candidates.append(os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"))
|
"""
|
||||||
|
if not use_regex:
|
||||||
|
return None, None
|
||||||
|
|
||||||
for candidate in candidates:
|
try:
|
||||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
flags = 0 if case_sensitive else re.IGNORECASE
|
||||||
return candidate
|
return re.compile(pattern, flags), None
|
||||||
|
except re.error as e:
|
||||||
return None
|
return None, f"Invalid regex pattern: {e}"
|
||||||
|
|
||||||
|
|
||||||
async def _check_dotnet_tools() -> dict:
|
async def _check_dotnet_tools() -> dict:
|
||||||
@ -202,7 +191,7 @@ async def _check_dotnet_tools() -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Check if ilspycmd is available (check PATH and common locations)
|
# Check if ilspycmd is available (check PATH and common locations)
|
||||||
ilspy_path = _find_ilspycmd_path()
|
ilspy_path = find_ilspycmd_path()
|
||||||
if ilspy_path:
|
if ilspy_path:
|
||||||
result["ilspycmd_available"] = True
|
result["ilspycmd_available"] = True
|
||||||
result["ilspycmd_path"] = ilspy_path
|
result["ilspycmd_path"] = ilspy_path
|
||||||
@ -380,7 +369,7 @@ async def _try_install_dotnet_sdk(ctx: Context | None = None) -> tuple[bool, str
|
|||||||
return False, (
|
return False, (
|
||||||
f"❌ Installation failed (exit code {proc.returncode}).\n\n"
|
f"❌ Installation failed (exit code {proc.returncode}).\n\n"
|
||||||
f"Command: `{' '.join(cmd_args)}`\n\n"
|
f"Command: `{' '.join(cmd_args)}`\n\n"
|
||||||
f"Output:\n```\n{output[-1000:]}\n```\n\n"
|
f"Output:\n```\n{output[-MAX_ERROR_OUTPUT_CHARS:]}\n```\n\n"
|
||||||
"Try running the command manually with sudo if needed."
|
"Try running the command manually with sudo if needed."
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -628,11 +617,21 @@ async def decompile_assembly(
|
|||||||
# Use simplified request object (no complex pydantic validation needed)
|
# Use simplified request object (no complex pydantic validation needed)
|
||||||
from .models import DecompileRequest
|
from .models import DecompileRequest
|
||||||
|
|
||||||
|
# Validate language version with helpful error message
|
||||||
|
try:
|
||||||
|
lang_ver = LanguageVersion(language_version)
|
||||||
|
except ValueError:
|
||||||
|
valid_versions = [v.value for v in LanguageVersion]
|
||||||
|
return (
|
||||||
|
f"Invalid language version: '{language_version}'\n\n"
|
||||||
|
f"Valid options: {', '.join(valid_versions)}"
|
||||||
|
)
|
||||||
|
|
||||||
request = DecompileRequest(
|
request = DecompileRequest(
|
||||||
assembly_path=validated_path,
|
assembly_path=validated_path,
|
||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
type_name=type_name,
|
type_name=type_name,
|
||||||
language_version=LanguageVersion(language_version),
|
language_version=lang_ver,
|
||||||
create_project=create_project,
|
create_project=create_project,
|
||||||
show_il_code=show_il_code or show_il_sequence_points,
|
show_il_code=show_il_code or show_il_sequence_points,
|
||||||
remove_dead_code=remove_dead_code,
|
remove_dead_code=remove_dead_code,
|
||||||
@ -906,7 +905,7 @@ async def search_types(
|
|||||||
|
|
||||||
# Default to search all entity types
|
# Default to search all entity types
|
||||||
if entity_types is None:
|
if entity_types is None:
|
||||||
entity_types = ["class", "interface", "struct", "delegate", "enum"]
|
entity_types = ALL_ENTITY_TYPES.copy()
|
||||||
|
|
||||||
# Convert to EntityType enums
|
# Convert to EntityType enums
|
||||||
entity_type_enums = []
|
entity_type_enums = []
|
||||||
@ -926,14 +925,9 @@ async def search_types(
|
|||||||
return response.error_message or "Failed to list types"
|
return response.error_message or "Failed to list types"
|
||||||
|
|
||||||
# Compile regex if needed
|
# Compile regex if needed
|
||||||
if use_regex:
|
search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex)
|
||||||
try:
|
if regex_error:
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
return regex_error
|
||||||
search_pattern = re.compile(pattern, flags)
|
|
||||||
except re.error as e:
|
|
||||||
return f"Invalid regex pattern: {e}"
|
|
||||||
else:
|
|
||||||
search_pattern = None
|
|
||||||
|
|
||||||
# Filter types by pattern and namespace
|
# Filter types by pattern and namespace
|
||||||
matching_types = []
|
matching_types = []
|
||||||
@ -996,7 +990,7 @@ async def search_strings(
|
|||||||
pattern: str,
|
pattern: str,
|
||||||
case_sensitive: bool = False,
|
case_sensitive: bool = False,
|
||||||
use_regex: bool = False,
|
use_regex: bool = False,
|
||||||
max_results: int = 100,
|
max_results: int = DEFAULT_MAX_SEARCH_RESULTS,
|
||||||
ctx: Context | None = None,
|
ctx: Context | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Search for string literals in assembly code.
|
"""Search for string literals in assembly code.
|
||||||
@ -1046,12 +1040,9 @@ async def search_strings(
|
|||||||
source_code = response.source_code or ""
|
source_code = response.source_code or ""
|
||||||
|
|
||||||
# Compile regex if needed
|
# Compile regex if needed
|
||||||
if use_regex:
|
search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex)
|
||||||
try:
|
if regex_error:
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
return regex_error
|
||||||
search_pattern = re.compile(pattern, flags)
|
|
||||||
except re.error as e:
|
|
||||||
return f"Invalid regex pattern: {e}"
|
|
||||||
|
|
||||||
# Search for string literals containing the pattern
|
# Search for string literals containing the pattern
|
||||||
# In IL, strings appear as: ldstr "string value"
|
# In IL, strings appear as: ldstr "string value"
|
||||||
@ -1090,7 +1081,7 @@ async def search_strings(
|
|||||||
matches.append(
|
matches.append(
|
||||||
{
|
{
|
||||||
"line_num": i + 1,
|
"line_num": i + 1,
|
||||||
"line": line.strip()[:200], # Truncate long lines
|
"line": line.strip()[:MAX_LINE_LENGTH], # Truncate long lines
|
||||||
"type": current_type or "Unknown",
|
"type": current_type or "Unknown",
|
||||||
"method": current_method,
|
"method": current_method,
|
||||||
}
|
}
|
||||||
@ -1116,12 +1107,12 @@ async def search_strings(
|
|||||||
|
|
||||||
for type_name, type_matches in sorted(by_type.items()):
|
for type_name, type_matches in sorted(by_type.items()):
|
||||||
content += f"## {type_name}\n\n"
|
content += f"## {type_name}\n\n"
|
||||||
for match in type_matches[:20]: # Limit per type
|
for match in type_matches[:MAX_MATCHES_PER_TYPE]:
|
||||||
method_info = f" in `{match['method']}()`" if match["method"] else ""
|
method_info = f" in `{match['method']}()`" if match["method"] else ""
|
||||||
content += f"- Line {match['line_num']}{method_info}:\n"
|
content += f"- Line {match['line_num']}{method_info}:\n"
|
||||||
content += f" ```\n {match['line']}\n ```\n"
|
content += f" ```\n {match['line']}\n ```\n"
|
||||||
if len(type_matches) > 20:
|
if len(type_matches) > MAX_MATCHES_PER_TYPE:
|
||||||
content += f" ... and {len(type_matches) - 20} more matches in this type\n"
|
content += f" ... and {len(type_matches) - MAX_MATCHES_PER_TYPE} more matches in this type\n"
|
||||||
content += "\n"
|
content += "\n"
|
||||||
|
|
||||||
content += "\n**TIP**: Use `decompile_assembly` with `type_name` to see the full context of interesting matches."
|
content += "\n**TIP**: Use `decompile_assembly` with `type_name` to see the full context of interesting matches."
|
||||||
@ -1191,14 +1182,9 @@ async def search_methods(
|
|||||||
return "No methods found in assembly (or assembly has no metadata)"
|
return "No methods found in assembly (or assembly has no metadata)"
|
||||||
|
|
||||||
# Compile regex if needed
|
# Compile regex if needed
|
||||||
if use_regex:
|
search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex)
|
||||||
try:
|
if regex_error:
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
return regex_error
|
||||||
search_pattern = re.compile(pattern, flags)
|
|
||||||
except re.error as e:
|
|
||||||
return f"Invalid regex pattern: {e}"
|
|
||||||
else:
|
|
||||||
search_pattern = None
|
|
||||||
|
|
||||||
# Filter by pattern
|
# Filter by pattern
|
||||||
matching_methods = []
|
matching_methods = []
|
||||||
@ -1315,14 +1301,9 @@ async def search_fields(
|
|||||||
return "No fields found in assembly"
|
return "No fields found in assembly"
|
||||||
|
|
||||||
# Compile regex if needed
|
# Compile regex if needed
|
||||||
if use_regex:
|
search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex)
|
||||||
try:
|
if regex_error:
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
return regex_error
|
||||||
search_pattern = re.compile(pattern, flags)
|
|
||||||
except re.error as e:
|
|
||||||
return f"Invalid regex pattern: {e}"
|
|
||||||
else:
|
|
||||||
search_pattern = None
|
|
||||||
|
|
||||||
# Filter by pattern
|
# Filter by pattern
|
||||||
matching_fields = []
|
matching_fields = []
|
||||||
@ -1428,14 +1409,9 @@ async def search_properties(
|
|||||||
return "No properties found in assembly"
|
return "No properties found in assembly"
|
||||||
|
|
||||||
# Compile regex if needed
|
# Compile regex if needed
|
||||||
if use_regex:
|
search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex)
|
||||||
try:
|
if regex_error:
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
return regex_error
|
||||||
search_pattern = re.compile(pattern, flags)
|
|
||||||
except re.error as e:
|
|
||||||
return f"Invalid regex pattern: {e}"
|
|
||||||
else:
|
|
||||||
search_pattern = None
|
|
||||||
|
|
||||||
# Filter by pattern
|
# Filter by pattern
|
||||||
matching_props = []
|
matching_props = []
|
||||||
|
|||||||
50
src/mcilspy/utils.py
Normal file
50
src/mcilspy/utils.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""Shared utility functions for mcilspy.
|
||||||
|
|
||||||
|
This module contains common utilities used across the codebase to avoid
|
||||||
|
code duplication and ensure consistent behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def find_ilspycmd_path() -> str | None:
|
||||||
|
"""Find ilspycmd executable in PATH or common install locations.
|
||||||
|
|
||||||
|
This is the single source of truth for locating the ilspycmd binary.
|
||||||
|
It checks:
|
||||||
|
1. Standard PATH (via shutil.which)
|
||||||
|
2. ~/.dotnet/tools (default location for 'dotnet tool install --global')
|
||||||
|
3. Platform-specific locations (Windows %USERPROFILE%)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to ilspycmd executable if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Check PATH first (handles both ilspycmd and ilspycmd.exe)
|
||||||
|
for cmd_name in ["ilspycmd", "ilspycmd.exe"]:
|
||||||
|
path = shutil.which(cmd_name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Check common dotnet tools locations (not always in PATH for MCP servers)
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
candidates = [
|
||||||
|
os.path.join(home, ".dotnet", "tools", "ilspycmd"),
|
||||||
|
os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Windows-specific: also check USERPROFILE if different from ~
|
||||||
|
if os.name == "nt":
|
||||||
|
userprofile = os.environ.get("USERPROFILE", "")
|
||||||
|
if userprofile and userprofile != home:
|
||||||
|
candidates.append(os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"))
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
logger.info(f"Found ilspycmd at {candidate} (not in PATH)")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
Loading…
x
Reference in New Issue
Block a user