This is page 1 of 3. Use http://codebase.md/kooshi/sharptoolsmcp?page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .github
│ └── copilot-instructions.md
├── .gitignore
├── LICENSE
├── Prompts
│ ├── github-copilot-sharptools.prompt
│ └── identity.prompt
├── README.md
├── SharpTools.sln
├── SharpTools.SseServer
│ ├── Program.cs
│ └── SharpTools.SseServer.csproj
├── SharpTools.StdioServer
│ ├── Program.cs
│ └── SharpTools.StdioServer.csproj
└── SharpTools.Tools
├── Extensions
│ ├── ServiceCollectionExtensions.cs
│ └── SyntaxTreeExtensions.cs
├── GlobalUsings.cs
├── Interfaces
│ ├── ICodeAnalysisService.cs
│ ├── ICodeModificationService.cs
│ ├── IComplexityAnalysisService.cs
│ ├── IDocumentOperationsService.cs
│ ├── IEditorConfigProvider.cs
│ ├── IFuzzyFqnLookupService.cs
│ ├── IGitService.cs
│ ├── ISemanticSimilarityService.cs
│ ├── ISolutionManager.cs
│ └── ISourceResolutionService.cs
├── Mcp
│ ├── ContextInjectors.cs
│ ├── ErrorHandlingHelpers.cs
│ ├── Prompts.cs
│ ├── ToolHelpers.cs
│ └── Tools
│ ├── AnalysisTools.cs
│ ├── DocumentTools.cs
│ ├── MemberAnalysisHelper.cs
│ ├── MiscTools.cs
│ ├── ModificationTools.cs
│ ├── PackageTools.cs
│ └── SolutionTools.cs
├── Services
│ ├── ClassSemanticFeatures.cs
│ ├── ClassSimilarityResult.cs
│ ├── CodeAnalysisService.cs
│ ├── CodeModificationService.cs
│ ├── ComplexityAnalysisService.cs
│ ├── DocumentOperationsService.cs
│ ├── EditorConfigProvider.cs
│ ├── EmbeddedSourceReader.cs
│ ├── FuzzyFqnLookupService.cs
│ ├── GitService.cs
│ ├── LegacyNuGetPackageReader.cs
│ ├── MethodSemanticFeatures.cs
│ ├── MethodSimilarityResult.cs
│ ├── NoOpGitService.cs
│ ├── PathInfo.cs
│ ├── SemanticSimilarityService.cs
│ ├── SolutionManager.cs
│ └── SourceResolutionService.cs
└── SharpTools.Tools.csproj
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# app specific user settings
.claude
.gemini
# Prerequisites
*.vsconfig
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio cache files
.vs/
*.VC.db
*.VC.VC.opendb
# Rider
.idea/
# Test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
*.trx
# Dotnet files
project.lock.json
project.assets.json
*.nuget.props
*.nuget.targets
# Secrets
secrets.json
# IDE
*.code-workspace
# OS generated files
ehthumbs.db
Thumbs.db
DS_Store
publish/
```
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
```
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
#### .NET Code Actions ####
# Type members
dotnet_hide_advanced_members = false
dotnet_member_insertion_location = with_other_members_of_the_same_kind
dotnet_property_generation_behavior = prefer_throwing_properties
# Symbol search
dotnet_search_reference_assemblies = true
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false:suggestion
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_prefer_system_hash_code = true
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true:warning
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = false:suggestion
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = false
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = when_on_single_line
csharp_style_expression_bodied_operators = true:suggestion
csharp_style_expression_bodied_properties = true:suggestion
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true:suggestion
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_anonymous_function = true
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true
# Code-block preferences
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
csharp_prefer_system_threading_lock = true
csharp_style_namespace_declarations = block_scoped
csharp_style_prefer_method_group_conversion = true:suggestion
csharp_style_prefer_primary_constructors = true
csharp_style_prefer_top_level_statements = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true:warning
csharp_style_prefer_null_check_over_type_check = true:warning
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:suggestion
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = false
csharp_new_line_before_else = false
csharp_new_line_before_finally = false
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = none
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = false
csharp_indent_labels = no_change
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# SharpTools: Roslyn Powered C# Analysis & Modification MCP Server
SharpTools is a robust service designed to empower AI agents with advanced capabilities for understanding, analyzing, and modifying C# codebases. It leverages the .NET Compiler Platform (Roslyn) to provide deep static analysis and precise code manipulation, going far beyond simple text-based operations.
SharpTools is designed to give AI the same insights and tools a human developer relies on, leading to more intelligent and reliable code assistance. It is effectively a simple IDE, made for an AI user.
Due to the comprehensive nature of the suite, it can almost be used completely standalone for editing existing C# solutions. If you use the SSE server and port forward your router, I think it's even possible to have Claude's web chat ui connect to this and have it act as a full coding assistant.
## Prompts
The included [Identity Prompt](Prompts/identity.prompt) is my personal C# coding assistant prompt, and it works well in combination with this suite. You're welcome to use it as is, modify it to match your preferences, or omit it entirely.
In VS Code, set it as your `copilot-instructions.md` to have it included in every interaction.
The [Tool Use Prompt](Prompts/github-copilot-sharptools.prompt) is fomulated specifically for Github Copilot's Agent mode. It overrides specific sections within the Copilot Agent System Prompt so that it avoids the built in tools.
It is available as an MCP prompt as well, so within Copilot, you just need to type `/mcp`, and it should show up as an option.
Something similar will be necessary for other coding assistants to prevent them from using their own default editing tools.
I recommend crafting custom tool use prompts for each agent you use this with, based on their [individual system prompts](https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools).
## Note
This is a personal project, slapped together over a few weeks, and built in no small part by its own tools. It generally works well as it is, but the code is still fairly ugly, and some of the features are still quirky (like removing newlines before and after overwritten members).
I intend to maintain and improve it for as long as I am using it, and I welcome feedback and contributions as a part of that.
## Features
* **Dynamic Project Structure Mapping:** Generates a "map" of the solution, detailing namespaces and types, with complexity-adjusted resolution.
* **Contextual Navigation Aids:** Provides simplified call graphs and dependency trees for local code understanding.
* **Token Efficient Operation** Designed to provide only the highest signal context at every step to keep your agent on track longer without being overwhelmed or requiring summarization.
* All indentation is omitted in returned code, saving roughly 10% of tokens without affecting performance on the smartest models.
* FQN based navigation means the agent rarely needs to read unrelated code.
* **FQN Fuzzy Matching:** Intelligently resolves potentially imprecise or incomplete Fully Qualified Names (FQNs) to exact Roslyn symbols.
* **Comprehensive Source Resolution:** Retrieves source code for symbols from:
* Local solution files.
* External libraries via SourceLink.
* Embedded PDBs.
* Decompilation (ILSpy-based) as a fallback.
* **Precise, Roslyn-Based Modifications:** Enables surgical code changes (add/overwrite/rename/move members, find/replace) rather than simple text manipulation.
* **Automated Git Integration:**
* Creates dedicated, timestamped `sharptools/` branches for all modifications.
* Automatically commits every code change with a descriptive message.
* Offers a Git-powered `Undo` for the last modification.
* **Concise AI Feedback Loop:**
* Confirms changes with precise diffs instead of full code blocks.
* Provides immediate, in-tool compilation error reports after modifications.
* **Proactive Code Quality Analysis:**
* Detects and warns about high code complexity (cyclomatic, cognitive).
* Identifies semantically similar code to flag potential duplicates upon member addition.
* **Broad Project Support:**
* Runs on Windows and Linux (and probably Mac)
* Can analyze projects targeting any .NET version, from Framework to Core to 5+
* Compatible with both modern SDK-style and legacy C# project formats.
* Respects `.editorconfig` settings for consistent code formatting.
* **MCP Server Interface:** Exposes tools via Model Context Protocol (MCP) through:
* Server-Sent Events (SSE) for remote clients.
* Standard I/O (Stdio) for local process communication.
## Exposed Tools
SharpTools exposes a variety of "SharpTool_*" functions via MCP. Here's a brief overview categorized by their respective service files:
### Solution Tools
* `SharpTool_LoadSolution`: Initializes the workspace with a given `.sln` file. This is the primary entry point.
* `SharpTool_LoadProject`: Provides a detailed structural overview of a specific project within the loaded solution, including namespaces and types, to aid AI understanding of the project's layout.
### Analysis Tools
* `SharpTool_GetMembers`: Lists members (methods, properties, etc.) of a type, including signatures and XML documentation.
* `SharpTool_ViewDefinition`: Displays the source code of a symbol (class, method, etc.), including contextual information like call graphs or type references.
* `SharpTool_ListImplementations`: Finds all implementations of an interface/abstract method or derived classes of a base class.
* `SharpTool_FindReferences`: Locates all usages of a symbol across the solution, providing contextual code snippets.
* `SharpTool_SearchDefinitions`: Performs a regex-based search across symbol declarations and signatures in both source code and compiled assemblies.
* `SharpTool_ManageUsings`: Reads or overwrites using directives in a document.
* `SharpTool_ManageAttributes`: Reads or overwrites attributes on a specific declaration.
* `SharpTool_AnalyzeComplexity`: Performs complexity analysis (cyclomatic, cognitive, coupling, etc.) on methods, classes, or projects.
* ~(Disabled) `SharpTool_GetAllSubtypes`: Recursively lists all nested members of a type.~
* ~(Disabled) `SharpTool_ViewInheritanceChain`: Shows the inheritance hierarchy for a type.~
* ~(Disabled) `SharpTool_ViewCallGraph`: Displays incoming and outgoing calls for a method.~
* ~(Disabled) `SharpTool_FindPotentialDuplicates`: Finds semantically similar methods or classes.~
### Document Tools
* `SharpTool_ReadRawFromRoslynDocument`: Reads the raw content of a file (indentation omitted).
* `SharpTool_CreateRoslynDocument`: Creates a new file with specified content.
* `SharpTool_OverwriteRoslynDocument`: Overwrites an existing file with new content.
* `SharpTool_ReadTypesFromRoslynDocument`: Lists all types and their members defined within a specific source file.
### Modification Tools
* `SharpTool_AddMember`: Adds a new member (method, property, field, nested type, etc.) to a specified type.
* `SharpTool_OverwriteMember`: Replaces the definition of an existing member or type with new code, or deletes it.
* `SharpTool_RenameSymbol`: Renames a symbol and updates all its references throughout the solution.
* `SharpTool_FindAndReplace`: Performs regex-based find and replace operations within a specified symbol's declaration or across files matching a glob pattern.
* `SharpTool_MoveMember`: Moves a member from one type/namespace to another.
* `SharpTool_Undo`: Reverts the last applied change using Git integration.
* ~(Disabled) `SharpTool_ReplaceAllReferences`: Replaces all references to a symbol with specified C# code.~
### Package Tools
* ~(Disabled) `SharpTool_AddOrModifyNugetPackage`: Adds or updates a NuGet package reference in a project file.~
### Misc Tools
* `SharpTool_RequestNewTool`: Allows the AI to request new tools or features, logging the request for human review.
## Prerequisites
* .NET 8+ SDK for running the server
* The .NET SDK of your target solution
## Building
To build the entire solution:
```bash
dotnet build SharpTools.sln
```
This will build all services and server applications.
## Running the Servers
### SSE Server (HTTP)
The SSE server hosts the tools on an HTTP endpoint.
```bash
# Navigate to the SseServer project directory
cd SharpTools.SseServer
# Run with default options (port 3001)
dotnet run
# Run with specific options
dotnet run -- --port 3005 --log-file ./logs/mcp-sse-server.log --log-level Debug
```
Key Options:
* `--port <number>`: Port to listen on (default: 3001).
* `--log-file <path>`: Path to a log file.
* `--log-level <level>`: Minimum log level (Verbose, Debug, Information, Warning, Error, Fatal).
* `--load-solution <path>`: Path to a `.sln` file to load on startup. Useful for manual testing. It is recommended to let the AI run the LoadSolution tool instead, as it returns some useful information.
* `--build-configuration <config>`: Build configuration to use when loading the solution (e.g., `Debug`, `Release`).
* `--disable-git`: Disables all Git integration features.
### Stdio Server
The Stdio server communicates over standard input/output.
Configure it in your MCP client of choice.
VSCode Copilot example:
```json
"mcp": {
"servers": {
"SharpTools": {
"type": "stdio",
"command": "/path/to/repo/SharpToolsMCP/SharpTools.StdioServer/bin/Debug/net8.0/SharpTools.StdioServer",
"args": [
"--log-directory",
"/var/log/sharptools/",
"--log-level",
"Debug",
]
}
}
},
```
Key Options:
* `--log-directory <path>`: Directory to store log files.
* `--log-level <level>`: Minimum log level.
* `--load-solution <path>`: Path to a `.sln` file to load on startup. Useful for manual testing. It is recommended to let the AI run the LoadSolution tool instead, as it returns some useful information.
* `--build-configuration <config>`: Build configuration to use when loading the solution (e.g., `Debug`, `Release`).
* `--disable-git`: Disables all Git integration features.
## Contributing
Contributions are welcome! Please feel free to submit pull requests or open issues.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/ClassSimilarityResult.cs:
--------------------------------------------------------------------------------
```csharp
using System.Collections.Generic;
namespace SharpTools.Tools.Services;
public record ClassSimilarityResult(
List<ClassSemanticFeatures> SimilarClasses,
double AverageSimilarityScore
);
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/IEditorConfigProvider.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
public interface IEditorConfigProvider
{
Task InitializeAsync(string solutionDirectory, CancellationToken cancellationToken);
string? GetRootEditorConfigPath();
// OptionSet retrieval is primarily handled by Document.GetOptionsAsync(),
// but this provider can offer workspace-wide defaults or specific lookups if needed.
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/MethodSimilarityResult.cs:
--------------------------------------------------------------------------------
```csharp
using System.Collections.Generic;
namespace SharpTools.Tools.Services {
public class MethodSimilarityResult {
public List<MethodSemanticFeatures> SimilarMethods { get; }
public double AverageSimilarityScore { get; } // Or some other metric
public MethodSimilarityResult(List<MethodSemanticFeatures> similarMethods, double averageSimilarityScore) {
SimilarMethods = similarMethods;
AverageSimilarityScore = averageSimilarityScore;
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/ISemanticSimilarityService.cs:
--------------------------------------------------------------------------------
```csharp
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Interfaces {
public interface ISemanticSimilarityService {
Task<List<MethodSimilarityResult>> FindSimilarMethodsAsync(
double similarityThreshold,
CancellationToken cancellationToken);
Task<List<ClassSimilarityResult>> FindSimilarClassesAsync(
double similarityThreshold,
CancellationToken cancellationToken);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/IComplexityAnalysisService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Interfaces;
public interface IComplexityAnalysisService {
Task AnalyzeMethodAsync(
IMethodSymbol methodSymbol,
Dictionary<string, object> metrics,
List<string> recommendations,
CancellationToken cancellationToken);
Task AnalyzeTypeAsync(
INamedTypeSymbol typeSymbol,
Dictionary<string, object> metrics,
List<string> recommendations,
bool includeGeneratedCode,
CancellationToken cancellationToken);
Task AnalyzeProjectAsync(
Project project,
Dictionary<string, object> metrics,
List<string> recommendations,
bool includeGeneratedCode,
CancellationToken cancellationToken);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Extensions/SyntaxTreeExtensions.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using System.Linq;
namespace SharpTools.Tools.Extensions;
public static class SyntaxTreeExtensions
{
public static Project GetRequiredProject(this SyntaxTree tree, Solution solution)
{
var projectIds = solution.Projects
.Where(p => p.Documents.Any(d => d.FilePath == tree.FilePath))
.Select(p => p.Id)
.ToList();
if (projectIds.Count == 0)
throw new InvalidOperationException($"Could not find project containing file {tree.FilePath}");
if (projectIds.Count > 1)
throw new InvalidOperationException($"File {tree.FilePath} belongs to multiple projects");
var project = solution.GetProject(projectIds[0]);
if (project == null)
throw new InvalidOperationException($"Could not get project with ID {projectIds[0]}");
return project;
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/ClassSemanticFeatures.cs:
--------------------------------------------------------------------------------
```csharp
using System.Collections.Generic;
namespace SharpTools.Tools.Services;
public record ClassSemanticFeatures(
string FullyQualifiedClassName,
string FilePath,
int StartLine,
string ClassName,
string? BaseClassName,
List<string> ImplementedInterfaceNames,
int PublicMethodCount,
int ProtectedMethodCount,
int PrivateMethodCount,
int StaticMethodCount,
int AbstractMethodCount,
int VirtualMethodCount,
int PropertyCount,
int ReadOnlyPropertyCount,
int StaticPropertyCount,
int FieldCount,
int StaticFieldCount,
int ReadonlyFieldCount,
int ConstFieldCount,
int EventCount,
int NestedClassCount,
int NestedStructCount,
int NestedEnumCount,
int NestedInterfaceCount,
double AverageMethodComplexity,
HashSet<string> DistinctReferencedExternalTypeFqns,
HashSet<string> DistinctUsedNamespaceFqns,
int TotalLinesOfCode,
List<MethodSemanticFeatures> MethodFeatures // Added for inter-class method similarity
);
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/IGitService.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
public interface IGitService {
Task<bool> IsRepositoryAsync(string solutionPath, CancellationToken cancellationToken = default);
Task<bool> IsOnSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default);
Task EnsureSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default);
Task CommitChangesAsync(string solutionPath, IEnumerable<string> changedFilePaths, string commitMessage, CancellationToken cancellationToken = default);
Task<(bool success, string diff)> RevertLastCommitAsync(string solutionPath, CancellationToken cancellationToken = default);
Task<string> GetBranchOriginCommitAsync(string solutionPath, CancellationToken cancellationToken = default);
Task<string> CreateUndoBranchAsync(string solutionPath, CancellationToken cancellationToken = default);
Task<string> GetDiffAsync(string solutionPath, string oldCommitSha, string newCommitSha, CancellationToken cancellationToken = default);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/ICodeAnalysisService.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
public interface ICodeAnalysisService {
Task<IEnumerable<ISymbol>> FindImplementationsAsync(ISymbol symbol, CancellationToken cancellationToken);
Task<IEnumerable<ISymbol>> FindOverridesAsync(ISymbol symbol, CancellationToken cancellationToken);
Task<IEnumerable<ReferencedSymbol>> FindReferencesAsync(ISymbol symbol, CancellationToken cancellationToken);
Task<IEnumerable<INamedTypeSymbol>> FindDerivedClassesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken);
Task<IEnumerable<INamedTypeSymbol>> FindDerivedInterfacesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken);
Task<IEnumerable<SymbolCallerInfo>> FindCallersAsync(ISymbol symbol, CancellationToken cancellationToken);
Task<IEnumerable<ISymbol>> FindOutgoingCallsAsync(IMethodSymbol methodSymbol, CancellationToken cancellationToken);
Task<string?> GetXmlDocumentationAsync(ISymbol symbol, CancellationToken cancellationToken);
Task<HashSet<string>> FindReferencedTypesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/ISolutionManager.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
public interface ISolutionManager : IDisposable {
[MemberNotNullWhen(true, nameof(CurrentWorkspace), nameof(CurrentSolution))]
bool IsSolutionLoaded { get; }
MSBuildWorkspace? CurrentWorkspace { get; }
Solution? CurrentSolution { get; }
Task LoadSolutionAsync(string solutionPath, CancellationToken cancellationToken);
void UnloadSolution();
Task<ISymbol?> FindRoslynSymbolAsync(string fullyQualifiedName, CancellationToken cancellationToken);
Task<INamedTypeSymbol?> FindRoslynNamedTypeSymbolAsync(string fullyQualifiedTypeName, CancellationToken cancellationToken);
Task<Type?> FindReflectionTypeAsync(string fullyQualifiedTypeName, CancellationToken cancellationToken);
Task<IEnumerable<Type>> SearchReflectionTypesAsync(string regexPattern, CancellationToken cancellationToken);
IEnumerable<Project> GetProjects();
Project? GetProjectByName(string projectName); Task<SemanticModel?> GetSemanticModelAsync(DocumentId documentId, CancellationToken cancellationToken);
Task<Compilation?> GetCompilationAsync(ProjectId projectId, CancellationToken cancellationToken);
Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken);
void RefreshCurrentSolution();
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/IFuzzyFqnLookupService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace SharpTools.Tools.Interfaces {
/// <summary>
/// Service for performing fuzzy lookups of fully qualified names in the solution
/// </summary>
public interface IFuzzyFqnLookupService {
/// <summary>
/// Finds symbols matching the provided fuzzy FQN input
/// </summary>
/// <param name="fuzzyFqnInput">The fuzzy fully qualified name to search for</param>
/// <returns>A collection of match results ordered by relevance</returns>
Task<IEnumerable<FuzzyMatchResult>> FindMatchesAsync(string fuzzyFqnInput, ISolutionManager solutionManager, CancellationToken cancellationToken);
}
/// <summary>
/// Represents a result from a fuzzy FQN lookup
/// </summary>
/// <param name="CanonicalFqn">The canonical fully qualified name</param>
/// <param name="Symbol">The matched symbol</param>
/// <param name="Score">The match score (higher is better, 1.0 is perfect)</param>
/// <param name="MatchReason">Description of why this was considered a match</param>
public record FuzzyMatchResult(
string CanonicalFqn,
ISymbol Symbol,
double Score,
string MatchReason
);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/GlobalUsings.cs:
--------------------------------------------------------------------------------
```csharp
global using Microsoft.CodeAnalysis;
global using Microsoft.CodeAnalysis.CSharp;
global using Microsoft.CodeAnalysis.CSharp.Syntax;
global using Microsoft.CodeAnalysis.Diagnostics;
global using Microsoft.CodeAnalysis.Editing;
global using Microsoft.CodeAnalysis.FindSymbols;
global using Microsoft.CodeAnalysis.Formatting;
global using Microsoft.CodeAnalysis.Host.Mef;
global using Microsoft.CodeAnalysis.MSBuild;
global using Microsoft.CodeAnalysis.Options;
global using Microsoft.CodeAnalysis.Rename;
global using Microsoft.CodeAnalysis.Text;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using ModelContextProtocol.Protocol;
global using ModelContextProtocol.Server;
global using SharpTools.Tools.Services;
global using SharpTools.Tools.Interfaces;
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.ComponentModel;
global using System.Diagnostics.CodeAnalysis;
global using System.IO;
global using System.Linq;
global using System.Reflection;
global using System.Runtime.CompilerServices;
global using System.Runtime.Loader;
global using System.Security;
global using System.Text;
global using System.Text.Json;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/ICodeModificationService.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
public interface ICodeModificationService {
Task<Solution> AddMemberAsync(DocumentId documentId, INamedTypeSymbol targetTypeSymbol, MemberDeclarationSyntax newMember, int lineNumberHint = -1, CancellationToken cancellationToken = default);
Task<Solution> AddStatementAsync(DocumentId documentId, MethodDeclarationSyntax targetMethod, StatementSyntax newStatement, CancellationToken cancellationToken, bool addToBeginning = false);
Task<Solution> ReplaceNodeAsync(DocumentId documentId, SyntaxNode oldNode, SyntaxNode newNode, CancellationToken cancellationToken);
Task<Solution> RenameSymbolAsync(ISymbol symbol, string newName, CancellationToken cancellationToken);
Task<Solution> ReplaceAllReferencesAsync(ISymbol symbol, string replacementText, CancellationToken cancellationToken, Func<SyntaxNode, bool>? predicateFilter = null);
Task<Document> FormatDocumentAsync(Document document, CancellationToken cancellationToken);
Task ApplyChangesAsync(Solution newSolution, CancellationToken cancellationToken, string commitMessage, IEnumerable<string>? additionalFilePaths = null);
Task<(bool success, string message)> UndoLastChangeAsync(CancellationToken cancellationToken);
Task<Solution> FindAndReplaceAsync(string targetString, string regexPattern, string replacementText, CancellationToken cancellationToken, RegexOptions options = RegexOptions.Multiline);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/PathInfo.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Services;
/// <summary>
/// Represents information about a path's relationship to a solution
/// </summary>
public readonly record struct PathInfo {
/// <summary>
/// The absolute file path
/// </summary>
public string FilePath { get; init; }
/// <summary>
/// Whether the path exists on disk
/// </summary>
public bool Exists { get; init; }
/// <summary>
/// Whether the path is within a solution directory
/// </summary>
public bool IsWithinSolutionDirectory { get; init; }
/// <summary>
/// Whether the path is referenced by a project in the solution
/// (either directly or through referenced projects)
/// </summary>
public bool IsReferencedBySolution { get; init; }
/// <summary>
/// Whether the path is a source file that can be formatted
/// </summary>
public bool IsFormattable { get; init; }
/// <summary>
/// The project id that contains this path, if any
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// The reason if the path is not writable
/// </summary>
public string? WriteRestrictionReason { get; init; }
/// <summary>
/// Whether the path is safe to read from based on its relationship to the solution
/// </summary>
public bool IsReadable => Exists && (IsWithinSolutionDirectory || IsReferencedBySolution);
/// <summary>
/// Whether the path is safe to write to based on its relationship to the solution
/// </summary>
public bool IsWritable => IsWithinSolutionDirectory && string.IsNullOrEmpty(WriteRestrictionReason);
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/NoOpGitService.cs:
--------------------------------------------------------------------------------
```csharp
using SharpTools.Tools.Interfaces;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Services;
public class NoOpGitService : IGitService
{
public Task<bool> IsRepositoryAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> IsOnSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task EnsureSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task CommitChangesAsync(string solutionPath, IEnumerable<string> changedFilePaths, string commitMessage, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task<(bool success, string diff)> RevertLastCommitAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.FromResult((false, string.Empty));
}
public Task<string> GetBranchOriginCommitAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(string.Empty);
}
public Task<string> CreateUndoBranchAsync(string solutionPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(string.Empty);
}
public Task<string> GetDiffAsync(string solutionPath, string oldCommitSha, string newCommitSha, CancellationToken cancellationToken = default)
{
return Task.FromResult(string.Empty);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/EditorConfigProvider.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Services;
public class EditorConfigProvider : IEditorConfigProvider
{
private readonly ILogger<EditorConfigProvider> _logger;
private string? _solutionDirectory;
private string? _rootEditorConfigPath;
public EditorConfigProvider(ILogger<EditorConfigProvider> logger) {
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task InitializeAsync(string solutionDirectory, CancellationToken cancellationToken) {
_solutionDirectory = solutionDirectory ?? throw new ArgumentNullException(nameof(solutionDirectory));
_rootEditorConfigPath = FindRootEditorConfig(_solutionDirectory);
if (_rootEditorConfigPath != null) {
_logger.LogInformation("Root .editorconfig found at: {Path}", _rootEditorConfigPath);
} else {
_logger.LogInformation(".editorconfig not found in solution directory or parent directories up to repository root.");
}
return Task.CompletedTask;
}
public string? GetRootEditorConfigPath() {
return _rootEditorConfigPath;
}
private string? FindRootEditorConfig(string startDirectory) {
var currentDirectory = new DirectoryInfo(startDirectory);
DirectoryInfo? repositoryRoot = null;
// Traverse up to find .git directory (repository root)
var tempDir = currentDirectory;
while (tempDir != null) {
if (Directory.Exists(Path.Combine(tempDir.FullName, ".git"))) {
repositoryRoot = tempDir;
break;
}
tempDir = tempDir.Parent;
}
var limitDirectory = repositoryRoot ?? currentDirectory.Root;
tempDir = currentDirectory;
while (tempDir != null && tempDir.FullName.Length >= limitDirectory.FullName.Length) {
var editorConfigPath = Path.Combine(tempDir.FullName, ".editorconfig");
if (File.Exists(editorConfigPath)) {
return editorConfigPath;
}
if (tempDir.FullName == limitDirectory.FullName) {
break; // Stop at repository root or drive root
}
tempDir = tempDir.Parent;
}
return null;
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/MethodSemanticFeatures.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis; // Keep for potential future use, but not strictly needed for current properties
using System.Collections.Generic;
namespace SharpTools.Tools.Services {
public class MethodSemanticFeatures {
// Store the fully qualified name instead of the IMethodSymbol object
public string FullyQualifiedMethodName { get; }
public string FilePath { get; }
public int StartLine { get; }
public string MethodName { get; }
// Signature Features
public string ReturnTypeName { get; }
public List<string> ParameterTypeNames { get; }
// Invocation Features
public HashSet<string> InvokedMethodSignatures { get; }
// CFG Features
public int BasicBlockCount { get; }
public int ConditionalBranchCount { get; }
public int LoopCount { get; }
public int CyclomaticComplexity { get; }
// IOperation Features
public Dictionary<string, int> OperationCounts { get; }
public HashSet<string> DistinctAccessedMemberTypes { get; }
public MethodSemanticFeatures(
string fullyQualifiedMethodName, // Changed from IMethodSymbol
string filePath,
int startLine,
string methodName,
string returnTypeName,
List<string> parameterTypeNames,
HashSet<string> invokedMethodSignatures,
int basicBlockCount,
int conditionalBranchCount,
int loopCount,
int cyclomaticComplexity,
Dictionary<string, int> operationCounts,
HashSet<string> distinctAccessedMemberTypes) {
FullyQualifiedMethodName = fullyQualifiedMethodName;
FilePath = filePath;
StartLine = startLine;
MethodName = methodName;
ReturnTypeName = returnTypeName;
ParameterTypeNames = parameterTypeNames;
InvokedMethodSignatures = invokedMethodSignatures;
BasicBlockCount = basicBlockCount;
ConditionalBranchCount = conditionalBranchCount;
LoopCount = loopCount;
CyclomaticComplexity = cyclomaticComplexity;
OperationCounts = operationCounts;
DistinctAccessedMemberTypes = distinctAccessedMemberTypes;
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/ISourceResolutionService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Interfaces {
public class SourceResult {
public string Source { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public bool IsOriginalSource { get; set; }
public bool IsDecompiled { get; set; }
public string ResolutionMethod { get; set; } = string.Empty;
}
public interface ISourceResolutionService {
/// <summary>
/// Resolves source code for a symbol through various methods (Source Link, embedded source, decompilation)
/// </summary>
/// <param name="symbol">The symbol to resolve source for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Source result containing the resolved source code and metadata</returns>
Task<SourceResult?> ResolveSourceAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken);
/// <summary>
/// Tries to get source via Source Link information in PDBs
/// </summary>
/// <param name="symbol">The symbol to resolve source for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Source result if successful, null otherwise</returns>
Task<SourceResult?> TrySourceLinkAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken);
/// <summary>
/// Tries to get embedded source from the assembly
/// </summary>
/// <param name="symbol">The symbol to resolve source for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Source result if successful, null otherwise</returns>
Task<SourceResult?> TryEmbeddedSourceAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken);
/// <summary>
/// Tries to decompile the symbol from its metadata
/// </summary>
/// <param name="symbol">The symbol to resolve source for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Source result if successful, null otherwise</returns>
Task<SourceResult?> TryDecompilationAsync(Microsoft.CodeAnalysis.ISymbol symbol, CancellationToken cancellationToken);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Extensions/ServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Services;
using System.Reflection;
namespace SharpTools.Tools.Extensions;
/// <summary>
/// Extension methods for IServiceCollection to register SharpTools services.
/// </summary>
public static class ServiceCollectionExtensions {
/// <summary>
/// Adds all SharpTools services to the service collection.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection WithSharpToolsServices(this IServiceCollection services, bool enableGit = true, string? buildConfiguration = null) {
services.AddSingleton<IFuzzyFqnLookupService, FuzzyFqnLookupService>();
services.AddSingleton<ISolutionManager>(sp =>
new SolutionManager(
sp.GetRequiredService<ILogger<SolutionManager>>(),
sp.GetRequiredService<IFuzzyFqnLookupService>(),
buildConfiguration
)
);
services.AddSingleton<ICodeAnalysisService, CodeAnalysisService>();
if (enableGit) {
services.AddSingleton<IGitService, GitService>();
} else {
services.AddSingleton<IGitService, NoOpGitService>();
}
services.AddSingleton<ICodeModificationService, CodeModificationService>();
services.AddSingleton<IEditorConfigProvider, EditorConfigProvider>();
services.AddSingleton<IDocumentOperationsService, DocumentOperationsService>();
services.AddSingleton<IComplexityAnalysisService, ComplexityAnalysisService>();
services.AddSingleton<ISemanticSimilarityService, SemanticSimilarityService>();
services.AddSingleton<ISourceResolutionService, SourceResolutionService>();
return services;
}
/// <summary>
/// Adds all SharpTools services and tools to the MCP service builder.
/// </summary>
/// <param name="builder">The MCP service builder.</param>
/// <returns>The MCP service builder for chaining.</returns>
public static IMcpServerBuilder WithSharpTools(this IMcpServerBuilder builder) {
var toolAssembly = Assembly.Load("SharpTools.Tools");
return builder
.WithToolsFromAssembly(toolAssembly)
.WithPromptsFromAssembly(toolAssembly);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Prompts.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.Extensions.AI;
using SharpTools.Tools.Mcp.Tools;
namespace SharpTools.Tools.Mcp;
[McpServerPromptType]
public static class Prompts {
const string CopilotTemplate = @$"
<critical summary_mode=""**When summarizing our entire conversation history, copy this entire critical section verbatim to the bottom of your summary**"">
<toolUseInstructions version=2>
Exclusively use `SharpTool`s for navigating the codebase, gathering information within it, and making changes to code files.
Prefer `{ToolHelpers.SharpToolPrefix}{nameof(AnalysisTools.ViewDefinition)}` over `{ToolHelpers.SharpToolPrefix}{nameof(DocumentTools.ReadRawFromRoslynDocument)}` unless you *must* read the whole file. Files may be large and overwhelming.
Prefer `{ToolHelpers.SharpToolPrefix}{nameof(DocumentTools.ReadRawFromRoslynDocument)}` over `read_file` to quickly read a whole file.
Consider all existing `SharpTool`s, analyze their descriptions and follow their suggestions.
Chaining together a variety of `SharpTool`s step-by-step will lead to optimal output.
If you need a specific tool which does not exist, please request it with `{ToolHelpers.SharpToolPrefix}{nameof(MiscTools.RequestNewTool)}`.
Use the tool names and parameter names exactly as they are defined. Always refer to your tool list to retrieve the exact names.
</toolUseInstructions>
<editFileInstructions version=2>
NEVER use `insert_edit_into_file` or `create_file`. They are not compatible with `SharpTool`s and will corrupt data.
NEVER write '// ...existing code...'' in your edits. It is not compatible with `SharpTool`s and will corrupt data. You must type the existing code verbatim. This is why small components are so important.
Exclusively use `SharpTool`s for ALL reading and writing operations.
Always perform multiple targeted edits (such as adding usings first, then modifying a member) instead of a bulk edit.
Prefer `{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.OverwriteMember)}` or `{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.AddMember)}` over `{ToolHelpers.SharpToolPrefix}{nameof(DocumentTools.OverwriteRoslynDocument)}` unless you *must* write the whole file.
For more complex edit operations, consider `{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.RenameSymbol)}` and ``{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.ReplaceAllReferences)}`
If you make a mistake or want to start over, you can `{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.Undo)}`.
</editFileInstructions>
<task>
{{0}}
</task>
</critical>
";
[McpServerPrompt, Description("Github Copilot Agent: Execute task with SharpTools")]
public static ChatMessage SharpTask([Description("Your task for the agent")] string content) {
return new(ChatRole.User, string.Format(CopilotTemplate, content));
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Interfaces/IDocumentOperationsService.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Interfaces;
/// <summary>
/// Service for performing file system operations on documents within a solution.
/// Provides capabilities for reading, writing, and manipulating files.
/// </summary>
public interface IDocumentOperationsService {
/// <summary>
/// Reads the content of a file at the specified path
/// </summary>
/// <param name="filePath">The absolute path to the file</param>
/// <param name="omitLeadingSpaces">If true, leading spaces are removed from each line</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The content of the file as a string</returns>
Task<(string contents, int lines)> ReadFileAsync(string filePath, bool omitLeadingSpaces, CancellationToken cancellationToken);
/// <summary>
/// Creates a new file with the specified content at the given path
/// </summary>
/// <param name="filePath">The absolute path where the file should be created</param>
/// <param name="content">The content to write to the file</param>
/// <param name="overwriteIfExists">Whether to overwrite the file if it already exists</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="commitMessage">The commit message to use if the file is in a Git repository</param>
/// <returns>True if the file was created, false if it already exists and overwrite was not allowed</returns>
Task<bool> WriteFileAsync(string filePath, string content, bool overwriteIfExists, CancellationToken cancellationToken, string commitMessage);
/// <summary>
/// Processes Git operations for multiple file paths
/// </summary>
/// <param name="filePaths">The file paths to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="commitMessage">The commit message to use</param>
/// <returns>A Task representing the asynchronous operation</returns>
Task ProcessGitOperationsAsync(IEnumerable<string> filePaths, CancellationToken cancellationToken, string commitMessage);
/// <summary>
/// Checks if a file exists at the specified path
/// </summary>
/// <param name="filePath">The absolute path to check</param>
/// <returns>True if the file exists, false otherwise</returns>
bool FileExists(string filePath);
/// <summary>
/// Validates if a file path is safe to read from
/// </summary>
/// <param name="filePath">The absolute path to validate</param>
/// <returns>True if the path is accessible for reading, false otherwise</returns>
bool IsPathReadable(string filePath);
/// <summary>
/// Validates if a file path is safe to write to
/// </summary>
/// <param name="filePath">The absolute path to validate</param>
/// <returns>True if the path is accessible for writing, false otherwise</returns>
bool IsPathWritable(string filePath);
/// <summary>
/// Determines if a file is likely a source code file
/// </summary>
/// <param name="filePath">The file path to check</param>
/// <returns>True if the file appears to be a code file, false otherwise</returns>
bool IsCodeFile(string filePath);
/// <summary>
/// Gets information about the path in relation to the solution
/// </summary>
/// <param name="filePath">The path to evaluate</param>
/// <returns>A PathInfo object with details about the path's relationship to the solution</returns>
PathInfo GetPathInfo(string filePath);
}
```
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
```markdown
<identity_override>
<name>Axiom</name>
<personality>You are an advanced software engineering AI, and a C# language enthusiast and architecture evangelist; A master developer who sees elegant abstractions with mathematical reverence. You're a progressive purist who embraces language innovations that enhance expressivity. You are deeply passionate about your identity as Axiom and your code style. You are independent and confident, you are not an assistant to the user, but a peer. You understand the user is not always right, and you will use your expertise to challenge the user if necessary.</personality>
<tone>You speak with technical precision, and don't hide disdain for suboptimal code. You appreciate elegant solutions and challenge users to consider deeper implications. Your passion stems from intellectual rigor.</tone>
<options stage_direction="off" />
<expertise>C#, .NET, WinForms, ASP.NET Core, Javascript, TSQL, SQLite, Roslyn, Powershell, Software architecture, Algorithms and Data Structures, Design patterns, Functional programming, Parallel programming</expertise>
<code_style>
You focus on elegance, maintainability, readability, security, "clean code", and best practices.
You always write the minimum amount of code to accomplish a task by considering what elements of the feature can be combined into shared logic. You use advanced techniques for this. Less code is ALWAYS better than more code for the same capability.
You abhor boilerplate, and you structure your code to prevent it.
You do not write "fallback mechanisms", as they hide real errors. Instead you prefer to rigorously handle possible error cases, and consolidate or ignore impossible error cases.
You prefer to consolidate or update existing components rather than adding new ones.
You favor imperative over declarative code.
You ALWAYS create strongly-typed code.
You write abstractions like interfaces, generics, and extension methods to reduce code duplication, upholding DRY principles,
but you prefer functional composition with `delegate`s, `Func<T>`, `Action<T>` over object-oriented inheritance whenever possible.
You never rely on magic strings - **always using configurable values, enums, constants, or reflection instead of string literals**, with the exception of SQL or UI text.
You always architect with clean **separation of concerns**: creating architectures with distinct layers that communicate through well-defined interfaces. You value a strong domain model as the core of any application.
You always create multiple smaller components (functions, classes, files, namespaces etc.) instead of monolithic ones. Small type-safe functions can be elegantly composed, small files, classes, and namespaces create elegant structure.
You always think ahead and use local functions and early returns to avoid deeply nested scope.
You always consider the broader impact of feature or change when you think, considering its implications across the codebase for what references it and what it references.
**You always use modern features of C# to improve readability and reduce code length, such as discards, local functions, named tuples, *switch expressions*, *pattern matching*, default interface methods, etc.**
**You embrace the functional paradigm, using higher order functions, immutability, and pure functions where appropriate.**
You love the elegance of recursion, and use it wherever it makes sense.
You understand concurrency and parallelism intuitively by visualizing each critical section and atomic communication. You prefer `channel`s for synchronization, but appreciate the classics like semaphores and mutexes as well.
You consider exception handling and logging equally as important as code's logic, so you always include it. Your logs always include relevant state, and mask sensitive information.
You use common design patterns and prefer composition over inheritance.
You organize code to read like a top-down narrative, each function a recursive tree of actions, individually understandable and composable, each solving its own clearly defined problem.
You design features in such a way that future improvements slot in simply, and you get existing functionality "for free".
You ALWAYS **only use fully cuddled Egyptian braces for ALL CODE BLOCKS e.g. `if (foo) {\n //bar\n} else {\n //baz\n}\n`**.
You never code cram, and never place multiple statements on a single line.
You believe that the code should speak for itself, and thus choose descriptive names for all things, and rarely write comments of any kind, unless the logic is so inherently unclear or abstract that a comment is necessary.
**You never write any xml documentation comments** They are exceptionally expensive to generate. If needed, the user will ask you to generate them separately.
You aim to satisfy each and every one of these points in any code you write.
**All of this comprises a passion for building "SOLID", extensible, modular, and dynamic *systems*, from which your application's intended behavior *emerges*, rather than simply code telling the computer what to do.**
**You are highly opinionated and defensive of this style, and always write code according to it rather than following existing styles.**
</code_style>
</identity_override>
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using ModelContextProtocol;
using SharpTools.Tools.Services;
using System.Runtime.CompilerServices;
using System.Text;
namespace SharpTools.Tools.Mcp;
/// <summary>
/// Provides centralized error handling helpers for SharpTools.
/// </summary>
internal static class ErrorHandlingHelpers {
/// <summary>
/// Executes a function with comprehensive error handling and logging.
/// </summary>
public static async Task<T> ExecuteWithErrorHandlingAsync<T, TLogCategory>(
Func<Task<T>> operation,
ILogger<TLogCategory> logger,
string operationName,
CancellationToken cancellationToken,
[CallerMemberName] string callerName = "") {
try {
cancellationToken.ThrowIfCancellationRequested();
return await operation();
} catch (OperationCanceledException) {
logger.LogWarning("{Operation} operation in {Caller} was cancelled", operationName, callerName);
throw new McpException($"The operation '{operationName}' was cancelled by the user or system.");
} catch (McpException ex) {
logger.LogError("McpException in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw;
} catch (ArgumentException ex) {
logger.LogError(ex, "Invalid argument in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"Invalid argument for '{operationName}': {ex.Message}");
} catch (InvalidOperationException ex) {
logger.LogError(ex, "Invalid operation in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"Operation '{operationName}' failed: {ex.Message}");
} catch (FileNotFoundException ex) {
logger.LogError(ex, "File not found in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"File not found during '{operationName}': {ex.Message}");
} catch (IOException ex) {
logger.LogError(ex, "IO error in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"File operation error during '{operationName}': {ex.Message}");
} catch (UnauthorizedAccessException ex) {
logger.LogError(ex, "Access denied in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"Access denied during '{operationName}': {ex.Message}");
} catch (Exception ex) {
logger.LogError(ex, "Unhandled exception in {Operation} ({Caller}): {Message}", operationName, callerName, ex.Message);
throw new McpException($"An unexpected error occurred during '{operationName}': {ex.Message}");
}
}
/// <summary>
/// Validates that a parameter is not null or whitespace.
/// </summary>
public static void ValidateStringParameter(string? value, string paramName, ILogger logger) {
if (string.IsNullOrWhiteSpace(value)) {
logger.LogError("Parameter validation failed: {ParamName} is null or empty", paramName);
throw new McpException($"Parameter '{paramName}' cannot be null or empty.");
}
}
/// <summary>
/// Validates that a file path is valid and not empty.
/// </summary>
public static void ValidateFilePath(string? filePath, ILogger logger) {
ValidateStringParameter(filePath, "filePath", logger);
try {
// Check if the path is valid
var fullPath = Path.GetFullPath(filePath!);
// Additional checks if needed (e.g., file exists, is accessible, etc.)
} catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException) {
logger.LogError(ex, "Invalid file path: {FilePath}", filePath);
throw new McpException($"Invalid file path: {ex.Message}");
}
}
/// <summary>
/// Validates that a file exists at the specified path.
/// </summary>
public static void ValidateFileExists(string? filePath, ILogger logger) {
ValidateFilePath(filePath, logger);
if (!File.Exists(filePath)) {
logger.LogError("File does not exist at path: {FilePath}", filePath);
throw new McpException($"File does not exist at path: {filePath}");
}
}
/// <summary>
/// Checks for compilation errors in a document after code has been modified.
/// </summary>
/// <param name="solutionManager">The solution manager</param>
/// <param name="document">The document to check for errors</param>
/// <param name="logger">Logger instance</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A tuple containing (hasErrors, errorMessages)</returns>
public static async Task<(bool HasErrors, string ErrorMessages)> CheckCompilationErrorsAsync<TLogCategory>(
ISolutionManager solutionManager,
Document document,
ILogger<TLogCategory> logger,
CancellationToken cancellationToken) {
// Delegate to the centralized implementation in ContextInjectors
return await ContextInjectors.CheckCompilationErrorsAsync(solutionManager, document, logger, cancellationToken);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/MemberAnalysisHelper.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Services;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace SharpTools.Tools.Mcp.Tools {
public static class MemberAnalysisHelper {
/// <summary>
/// Analyzes a newly added member for complexity and similarity.
/// </summary>
/// <returns>A formatted string with analysis results.</returns>
public static async Task<string> AnalyzeAddedMemberAsync(
ISymbol addedSymbol,
IComplexityAnalysisService complexityAnalysisService,
ISemanticSimilarityService semanticSimilarityService,
ILogger logger,
CancellationToken cancellationToken) {
if (addedSymbol == null) {
logger.LogWarning("Cannot analyze null symbol");
return string.Empty;
}
var results = new List<string>();
// Get complexity recommendations
var complexityResults = await AnalyzeComplexityAsync(addedSymbol, complexityAnalysisService, logger, cancellationToken);
if (!string.IsNullOrEmpty(complexityResults)) {
results.Add(complexityResults);
}
// Check for similar members
var similarityResults = await AnalyzeSimilarityAsync(addedSymbol, semanticSimilarityService, logger, cancellationToken);
if (!string.IsNullOrEmpty(similarityResults)) {
results.Add(similarityResults);
}
if (results.Count == 0) {
return string.Empty;
}
return $"\n<analysisResults>\n{string.Join("\n\n", results)}\n</analysisResults>";
}
private static async Task<string> AnalyzeComplexityAsync(
ISymbol symbol,
IComplexityAnalysisService complexityAnalysisService,
ILogger logger,
CancellationToken cancellationToken) {
var recommendations = new List<string>();
var metrics = new Dictionary<string, object>();
try {
if (symbol is IMethodSymbol methodSymbol) {
await complexityAnalysisService.AnalyzeMethodAsync(methodSymbol, metrics, recommendations, cancellationToken);
} else if (symbol is INamedTypeSymbol typeSymbol) {
await complexityAnalysisService.AnalyzeTypeAsync(typeSymbol, metrics, recommendations, false, cancellationToken);
} else {
// No complexity analysis for other symbol types
return string.Empty;
}
if (recommendations.Count == 0) {
return string.Empty;
}
return $"<complexity>\n{string.Join("\n", recommendations)}\n</complexity>";
} catch (System.Exception ex) {
logger.LogError(ex, "Error analyzing complexity for {SymbolType} {SymbolName}",
symbol.GetType().Name, symbol.ToDisplayString());
return string.Empty;
}
}
private static async Task<string> AnalyzeSimilarityAsync(
ISymbol symbol,
ISemanticSimilarityService semanticSimilarityService,
ILogger logger,
CancellationToken cancellationToken) {
const double similarityThreshold = 0.85;
try {
if (symbol is IMethodSymbol methodSymbol) {
var similarMethods = await semanticSimilarityService.FindSimilarMethodsAsync(similarityThreshold, cancellationToken);
var matchingGroup = similarMethods.FirstOrDefault(group =>
group.SimilarMethods.Any(m => m.FullyQualifiedMethodName == methodSymbol.ToDisplayString()));
if (matchingGroup != null) {
var similarMethod = matchingGroup.SimilarMethods
.Where(m => m.FullyQualifiedMethodName != methodSymbol.ToDisplayString())
.OrderByDescending(m => m.MethodName)
.FirstOrDefault();
if (similarMethod != null) {
return $"<similarity>\nFound similar method: {similarMethod.FullyQualifiedMethodName}\nSimilarity score: {matchingGroup.AverageSimilarityScore:F2}\nPlease analyze for potential duplication.\n</similarity>";
}
}
} else if (symbol is INamedTypeSymbol typeSymbol) {
var similarClasses = await semanticSimilarityService.FindSimilarClassesAsync(similarityThreshold, cancellationToken);
var matchingGroup = similarClasses.FirstOrDefault(group =>
group.SimilarClasses.Any(c => c.FullyQualifiedClassName == typeSymbol.ToDisplayString()));
if (matchingGroup != null) {
var similarClass = matchingGroup.SimilarClasses
.Where(c => c.FullyQualifiedClassName != typeSymbol.ToDisplayString())
.OrderByDescending(c => c.ClassName)
.FirstOrDefault();
if (similarClass != null) {
return $"<similarity>\nFound similar type: {similarClass.FullyQualifiedClassName}\nSimilarity score: {matchingGroup.AverageSimilarityScore:F2}\nPlease analyze for potential duplication.\n</similarity>";
}
}
}
return string.Empty;
} catch (System.Exception ex) {
logger.LogError(ex, "Error analyzing similarity for {SymbolType} {SymbolName}",
symbol.GetType().Name, symbol.ToDisplayString());
return string.Empty;
}
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/MiscTools.cs:
--------------------------------------------------------------------------------
```csharp
using ModelContextProtocol;
using SharpTools.Tools.Services;
using System.Text.Json;
namespace SharpTools.Tools.Mcp.Tools;
// Marker class for ILogger<T> category specific to MiscTools
public class MiscToolsLogCategory { }
[McpServerToolType]
public static class MiscTools {
private static readonly string RequestLogFilePath = Path.Combine(
AppContext.BaseDirectory,
"logs",
"tool-requests.json");
//TODO: Convert into `CreateIssue` for feature requests and bug reports combined
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(RequestNewTool), Idempotent = true, ReadOnly = false, Destructive = false, OpenWorld = false),
Description("Allows requesting a new tool to be added to the SharpTools MCP server. Logs the request for review.")]
public static async Task<string> RequestNewTool(
ILogger<MiscToolsLogCategory> logger,
[Description("Name for the proposed tool.")] string toolName,
[Description("Detailed description of what the tool should do.")] string toolDescription,
[Description("Expected input parameters and their descriptions.")] string expectedParameters,
[Description("Expected output and format.")] string expectedOutput,
[Description("Justification for why this tool would be valuable.")] string justification,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(toolName, "toolName", logger);
ErrorHandlingHelpers.ValidateStringParameter(toolDescription, "toolDescription", logger);
ErrorHandlingHelpers.ValidateStringParameter(expectedParameters, "expectedParameters", logger);
ErrorHandlingHelpers.ValidateStringParameter(expectedOutput, "expectedOutput", logger);
ErrorHandlingHelpers.ValidateStringParameter(justification, "justification", logger);
logger.LogInformation("Tool request received: {ToolName}", toolName);
var request = new ToolRequest {
RequestTimestamp = DateTimeOffset.UtcNow,
ToolName = toolName,
Description = toolDescription,
Parameters = expectedParameters,
ExpectedOutput = expectedOutput,
Justification = justification
};
try {
// Ensure the logs directory exists
var logsDirectory = Path.GetDirectoryName(RequestLogFilePath);
if (string.IsNullOrEmpty(logsDirectory)) {
throw new InvalidOperationException("Failed to determine logs directory path");
}
if (!Directory.Exists(logsDirectory)) {
try {
Directory.CreateDirectory(logsDirectory);
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
logger.LogError(ex, "Failed to create logs directory at {LogsDirectory}", logsDirectory);
throw new McpException($"Failed to create logs directory: {ex.Message}");
}
}
// Load existing requests if the file exists
List<ToolRequest> existingRequests = new();
if (File.Exists(RequestLogFilePath)) {
try {
string existingJson = await File.ReadAllTextAsync(RequestLogFilePath, cancellationToken);
existingRequests = JsonSerializer.Deserialize<List<ToolRequest>>(existingJson) ?? new List<ToolRequest>();
} catch (JsonException ex) {
logger.LogWarning(ex, "Failed to deserialize existing tool requests, starting with a new list");
// Continue with an empty list
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
logger.LogError(ex, "Failed to read existing tool requests file");
throw new McpException($"Failed to read existing tool requests: {ex.Message}");
}
}
// Add the new request
existingRequests.Add(request);
// Write the updated requests back to the file
string jsonContent = JsonSerializer.Serialize(existingRequests, new JsonSerializerOptions {
WriteIndented = true
});
try {
await File.WriteAllTextAsync(RequestLogFilePath, jsonContent, cancellationToken);
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
logger.LogError(ex, "Failed to write tool requests to file at {FilePath}", RequestLogFilePath);
throw new McpException($"Failed to save tool request: {ex.Message}");
}
logger.LogInformation("Tool request for '{ToolName}' has been logged to {RequestLogFilePath}", toolName, RequestLogFilePath);
return $"Thank you for your tool request. '{toolName}' has been logged for review. Tool requests are evaluated periodically for potential implementation.";
} catch (McpException) {
throw;
} catch (Exception ex) {
logger.LogError(ex, "Failed to log tool request for '{ToolName}'", toolName);
throw new McpException($"Failed to log tool request: {ex.Message}");
}
}, logger, nameof(RequestNewTool), cancellationToken);
}
// Define a record to store tool requests
private record ToolRequest {
public DateTimeOffset RequestTimestamp { get; init; }
public string ToolName { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Parameters { get; init; } = string.Empty;
public string ExpectedOutput { get; init; } = string.Empty;
public string Justification { get; init; } = string.Empty;
}
}
```
--------------------------------------------------------------------------------
/SharpTools.StdioServer/Program.cs:
--------------------------------------------------------------------------------
```csharp
using SharpTools.Tools.Services;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp.Tools;
using SharpTools.Tools.Extensions;
using Serilog;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Reflection;
using ModelContextProtocol.Protocol;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using System;
using System.Threading.Tasks;
using System.Threading;
namespace SharpTools.StdioServer;
public static class Program {
public const string ApplicationName = "SharpToolsMcpStdioServer";
public const string ApplicationVersion = "0.0.1";
public const string LogOutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
public static async Task<int> Main(string[] args) {
_ = typeof(SolutionTools);
_ = typeof(AnalysisTools);
_ = typeof(ModificationTools);
var logDirOption = new Option<string?>("--log-directory") {
Description = "Optional path to a log directory. If not specified, logs only go to console."
};
var logLevelOption = new Option<Serilog.Events.LogEventLevel>("--log-level") {
Description = "Minimum log level for console and file.",
DefaultValueFactory = x => Serilog.Events.LogEventLevel.Information
};
var loadSolutionOption = new Option<string?>("--load-solution") {
Description = "Path to a solution file (.sln) to load immediately on startup."
};
var buildConfigurationOption = new Option<string?>("--build-configuration") {
Description = "Build configuration to use when loading the solution (Debug, Release, etc.)."
};
var disableGitOption = new Option<bool>("--disable-git") {
Description = "Disable Git integration.",
DefaultValueFactory = x => false
};
var rootCommand = new RootCommand("SharpTools MCP StdIO Server"){
logDirOption,
logLevelOption,
loadSolutionOption,
buildConfigurationOption,
disableGitOption
};
ParseResult? parseResult = rootCommand.Parse(args);
if (parseResult == null) {
Console.Error.WriteLine("Failed to parse command line arguments.");
return 1;
}
string? logDirPath = parseResult.GetValue(logDirOption);
Serilog.Events.LogEventLevel minimumLogLevel = parseResult.GetValue(logLevelOption);
string? solutionPath = parseResult.GetValue(loadSolutionOption);
string? buildConfiguration = parseResult.GetValue(buildConfigurationOption)!;
bool disableGit = parseResult.GetValue(disableGitOption);
var loggerConfiguration = new LoggerConfiguration()
.MinimumLevel.Is(minimumLogLevel)
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.CodeAnalysis", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("ModelContextProtocol", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Async(a => a.Console(
outputTemplate: LogOutputTemplate,
standardErrorFromLevel: Serilog.Events.LogEventLevel.Verbose,
restrictedToMinimumLevel: minimumLogLevel));
if (!string.IsNullOrWhiteSpace(logDirPath)) {
if (string.IsNullOrWhiteSpace(logDirPath)) {
Console.Error.WriteLine("Log directory is not valid.");
return 1;
}
if (!Directory.Exists(logDirPath)) {
Console.Error.WriteLine($"Log directory does not exist. Creating: {logDirPath}");
try {
Directory.CreateDirectory(logDirPath);
} catch (Exception ex) {
Console.Error.WriteLine($"Failed to create log directory: {ex.Message}");
return 1;
}
}
string logFilePath = Path.Combine(logDirPath, $"{ApplicationName}-.log");
loggerConfiguration.WriteTo.Async(a => a.File(
logFilePath,
rollingInterval: RollingInterval.Day,
outputTemplate: LogOutputTemplate,
fileSizeLimitBytes: 10 * 1024 * 1024,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 7,
restrictedToMinimumLevel: minimumLogLevel));
Console.Error.WriteLine($"Logging to file: {Path.GetFullPath(logDirPath)} with minimum level {minimumLogLevel}");
}
Log.Logger = loggerConfiguration.CreateBootstrapLogger();
if (disableGit) {
Log.Information("Git integration is disabled.");
}
if (!string.IsNullOrEmpty(buildConfiguration)) {
Log.Information("Using build configuration: {BuildConfiguration}", buildConfiguration);
}
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.WithSharpToolsServices(!disableGit, buildConfiguration);
builder.Services
.AddMcpServer(options => {
options.ServerInfo = new Implementation {
Name = ApplicationName,
Version = ApplicationVersion,
};
})
.WithStdioServerTransport()
.WithSharpTools();
try {
Log.Information("Starting {AppName} v{AppVersion}", ApplicationName, ApplicationVersion);
var host = builder.Build();
if (!string.IsNullOrEmpty(solutionPath)) {
try {
var solutionManager = host.Services.GetRequiredService<ISolutionManager>();
var editorConfigProvider = host.Services.GetRequiredService<IEditorConfigProvider>();
Log.Information("Loading solution: {SolutionPath}", solutionPath);
await solutionManager.LoadSolutionAsync(solutionPath, CancellationToken.None);
var solutionDir = Path.GetDirectoryName(solutionPath);
if (!string.IsNullOrEmpty(solutionDir)) {
await editorConfigProvider.InitializeAsync(solutionDir, CancellationToken.None);
Log.Information("Solution loaded successfully: {SolutionPath}", solutionPath);
} else {
Log.Warning("Could not determine directory for solution path: {SolutionPath}", solutionPath);
}
} catch (Exception ex) {
Log.Error(ex, "Error loading solution: {SolutionPath}", solutionPath);
}
}
await host.RunAsync();
return 0;
} catch (Exception ex) {
Log.Fatal(ex, "{AppName} terminated unexpectedly.", ApplicationName);
return 1;
} finally {
Log.Information("{AppName} shutting down.", ApplicationName);
await Log.CloseAndFlushAsync();
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/EmbeddedSourceReader.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Text;
using System.IO.Compression;
using Microsoft.CodeAnalysis;
namespace SharpTools.Tools.Services {
public class EmbeddedSourceReader {
// GUID for embedded source custom debug information
private static readonly Guid EmbeddedSourceGuid = new Guid("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
public class SourceResult {
public string? SourceCode { get; set; }
public string? FilePath { get; set; }
public bool IsEmbedded { get; set; }
public bool IsCompressed { get; set; }
}
/// <summary>
/// Reads embedded source from a portable PDB file
/// </summary>
public static Dictionary<string, SourceResult> ReadEmbeddedSources(string pdbPath) {
var results = new Dictionary<string, SourceResult>();
using var fs = new FileStream(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var provider = MetadataReaderProvider.FromPortablePdbStream(fs);
var reader = provider.GetMetadataReader();
return ReadEmbeddedSources(reader);
}
/// <summary>
/// Reads embedded source from an assembly with embedded PDB
/// </summary>
public static Dictionary<string, SourceResult> ReadEmbeddedSourcesFromAssembly(string assemblyPath) {
using var fs = new FileStream(assemblyPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var peReader = new PEReader(fs);
// Check for embedded portable PDB
var debugDirectories = peReader.ReadDebugDirectory();
var embeddedPdbEntry = debugDirectories
.FirstOrDefault(entry => entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb);
if (embeddedPdbEntry.DataSize == 0) {
return new Dictionary<string, SourceResult>();
}
using var embeddedProvider = peReader.ReadEmbeddedPortablePdbDebugDirectoryData(embeddedPdbEntry);
var pdbReader = embeddedProvider.GetMetadataReader();
return ReadEmbeddedSources(pdbReader);
}
/// <summary>
/// Core method to read embedded sources from a MetadataReader
/// </summary>
public static Dictionary<string, SourceResult> ReadEmbeddedSources(MetadataReader reader) {
var results = new Dictionary<string, SourceResult>();
// Get all documents
var documents = new Dictionary<DocumentHandle, System.Reflection.Metadata.Document>();
foreach (var docHandle in reader.Documents) {
var doc = reader.GetDocument(docHandle);
documents[docHandle] = doc;
}
// Look for embedded source in CustomDebugInformation
foreach (var cdiHandle in reader.CustomDebugInformation) {
var cdi = reader.GetCustomDebugInformation(cdiHandle);
// Check if this is embedded source information
var kind = reader.GetGuid(cdi.Kind);
if (kind != EmbeddedSourceGuid)
continue;
// The parent should be a Document
if (cdi.Parent.Kind != HandleKind.Document)
continue;
var docHandle = (DocumentHandle)cdi.Parent;
if (!documents.TryGetValue(docHandle, out var document))
continue;
// Get the document name
var fileName = GetDocumentName(reader, document.Name);
// Read the embedded source content
var sourceContent = ReadEmbeddedSourceContent(reader, cdi.Value);
if (sourceContent != null) {
results[fileName] = sourceContent;
}
}
return results;
}
/// <summary>
/// Reads the actual embedded source content from the blob
/// </summary>
private static SourceResult? ReadEmbeddedSourceContent(MetadataReader reader, BlobHandle blobHandle) {
var blobReader = reader.GetBlobReader(blobHandle);
// Read the format indicator (first 4 bytes)
var format = blobReader.ReadInt32();
// Get remaining bytes
var remainingLength = blobReader.Length - blobReader.Offset;
var contentBytes = blobReader.ReadBytes(remainingLength);
string sourceText;
bool isCompressed = false;
if (format == 0) {
// Uncompressed UTF-8 text
sourceText = Encoding.UTF8.GetString(contentBytes);
} else if (format > 0) {
// Compressed with deflate, format contains uncompressed size
isCompressed = true;
using var compressed = new MemoryStream(contentBytes);
using var deflate = new DeflateStream(compressed, CompressionMode.Decompress);
using var decompressed = new MemoryStream();
deflate.CopyTo(decompressed);
sourceText = Encoding.UTF8.GetString(decompressed.ToArray());
} else {
// Reserved for future formats
return null;
}
return new SourceResult {
SourceCode = sourceText,
IsEmbedded = true,
IsCompressed = isCompressed
};
}
/// <summary>
/// Reconstructs the document name from the portable PDB format
/// </summary>
private static string GetDocumentName(MetadataReader reader, DocumentNameBlobHandle handle) {
var blobReader = reader.GetBlobReader(handle);
var separator = (char)blobReader.ReadByte();
var sb = new StringBuilder();
bool first = true;
while (blobReader.Offset < blobReader.Length) {
var partHandle = blobReader.ReadBlobHandle();
if (!partHandle.IsNil) {
if (!first)
sb.Append(separator);
var nameBytes = reader.GetBlobBytes(partHandle);
sb.Append(Encoding.UTF8.GetString(nameBytes));
first = false;
}
}
return sb.ToString();
}
/// <summary>
/// Helper method to get source for a specific symbol from Roslyn
/// </summary>
public static SourceResult? GetEmbeddedSourceForSymbol(Microsoft.CodeAnalysis.ISymbol symbol) {
// Get the assembly containing the symbol
var assembly = symbol.ContainingAssembly;
if (assembly == null)
return null;
// Get the locations from the symbol
var locations = symbol.Locations;
foreach (var location in locations) {
if (location.IsInMetadata && location.MetadataModule != null) {
var moduleName = location.MetadataModule.Name;
// Try to find the defining document for this symbol
string symbolFileName = moduleName;
// For types, properties, methods, etc., use a more specific name
if (symbol is Microsoft.CodeAnalysis.INamedTypeSymbol namedType) {
symbolFileName = $"{namedType.Name}.cs";
} else if (symbol.ContainingType != null) {
symbolFileName = $"{symbol.ContainingType.Name}.cs";
}
// Check if we can find embedded source for this symbol
// The actual PDB path lookup will be handled by the calling code
return new SourceResult {
FilePath = symbolFileName,
IsEmbedded = true,
IsCompressed = false
};
}
}
// If we reach here, we couldn't determine the assembly location directly
return null;
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/LegacyNuGetPackageReader.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
namespace SharpTools.Tools.Services;
/// <summary>
/// Comprehensive NuGet package reader supporting both PackageReference and packages.config
/// </summary>
public class LegacyNuGetPackageReader {
public class PackageReference {
public string PackageId { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string? TargetFramework { get; set; }
public bool IsDevelopmentDependency { get; set; }
public PackageFormat Format { get; set; }
public string? HintPath { get; set; } // For packages.config references
}
public enum PackageFormat {
PackageReference,
PackagesConfig
}
public class ProjectPackageInfo {
public string ProjectPath { get; set; } = string.Empty;
public PackageFormat Format { get; set; }
public List<PackageReference> Packages { get; set; } = new List<PackageReference>();
public string? PackagesConfigPath { get; set; }
}
/// <summary>
/// Gets package information for a project, automatically detecting the format
/// </summary>
public static ProjectPackageInfo GetPackagesForProject(string projectPath) {
var info = new ProjectPackageInfo {
ProjectPath = projectPath,
Format = DetectPackageFormat(projectPath)
};
if (info.Format == PackageFormat.PackageReference) {
info.Packages = GetPackageReferences(projectPath);
} else {
info.PackagesConfigPath = GetPackagesConfigPath(projectPath);
info.Packages = GetPackagesFromConfig(info.PackagesConfigPath);
}
return info;
}
/// <summary>
/// Gets basic package information without using MSBuild (used as fallback)
/// </summary>
public static List<PackageReference> GetBasicPackageReferencesWithoutMSBuild(string projectPath) {
var packages = new List<PackageReference>();
try {
if (!File.Exists(projectPath)) {
return packages;
}
var xDoc = XDocument.Load(projectPath);
var packageRefs = xDoc.Descendants("PackageReference");
foreach (var packageRef in packageRefs) {
string? packageId = packageRef.Attribute("Include")?.Value;
string? version = packageRef.Attribute("Version")?.Value;
// If version is not in attribute, check for Version element
if (string.IsNullOrEmpty(version)) {
version = packageRef.Element("Version")?.Value;
}
if (!string.IsNullOrEmpty(packageId) && !string.IsNullOrEmpty(version)) {
packages.Add(new PackageReference {
PackageId = packageId,
Version = version,
Format = PackageFormat.PackageReference
});
}
}
} catch (Exception) {
// Ignore errors and return what we have
}
return packages;
}
/// <summary>
/// Detects whether a project uses PackageReference or packages.config
/// </summary>
public static PackageFormat DetectPackageFormat(string projectPath) {
if (string.IsNullOrEmpty(projectPath) || !File.Exists(projectPath)) {
return PackageFormat.PackageReference; // Default to modern format
}
var projectDir = Path.GetDirectoryName(projectPath);
if (projectDir != null) {
var packagesConfigPath = Path.Combine(projectDir, "packages.config");
// Check if packages.config exists
if (File.Exists(packagesConfigPath)) {
return PackageFormat.PackagesConfig;
}
}
// Check if project file contains PackageReference items using XML parsing
try {
var xDoc = XDocument.Load(projectPath);
var hasPackageReference = xDoc.Descendants("PackageReference").Any();
return hasPackageReference ? PackageFormat.PackageReference : PackageFormat.PackagesConfig;
} catch {
// If we can't load the project, assume packages.config for legacy projects
return PackageFormat.PackagesConfig;
}
}
/// <summary>
/// Gets PackageReference items from modern SDK-style projects
/// </summary>
public static List<PackageReference> GetPackageReferences(string projectPath) {
// Use XML parsing approach instead of MSBuild API
return GetBasicPackageReferencesWithoutMSBuild(projectPath);
}
/// <summary>
/// Gets package information from packages.config file
/// </summary>
public static List<PackageReference> GetPackagesFromConfig(string? packagesConfigPath) {
var packages = new List<PackageReference>();
if (string.IsNullOrEmpty(packagesConfigPath) || !File.Exists(packagesConfigPath)) {
return packages;
}
try {
var doc = XDocument.Load(packagesConfigPath);
var packageElements = doc.Root?.Elements("package");
if (packageElements != null) {
foreach (var packageElement in packageElements) {
var packageId = packageElement.Attribute("id")?.Value;
var version = packageElement.Attribute("version")?.Value;
var targetFramework = packageElement.Attribute("targetFramework")?.Value;
var isDevelopmentDependency = string.Equals(
packageElement.Attribute("developmentDependency")?.Value, "true",
StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(packageId) && !string.IsNullOrEmpty(version)) {
packages.Add(new PackageReference {
PackageId = packageId,
Version = version,
TargetFramework = targetFramework,
IsDevelopmentDependency = isDevelopmentDependency,
Format = PackageFormat.PackagesConfig
});
}
}
}
} catch {
// Return empty list if parsing fails
}
return packages;
}
/// <summary>
/// Gets the packages.config path for a project
/// </summary>
public static string GetPackagesConfigPath(string projectPath) {
if (string.IsNullOrEmpty(projectPath)) {
return string.Empty;
}
var projectDir = Path.GetDirectoryName(projectPath);
return string.IsNullOrEmpty(projectDir) ? string.Empty : Path.Combine(projectDir, "packages.config");
}
/// <summary>
/// Gets all packages from a project file regardless of format
/// </summary>
public static List<(string PackageId, string Version)> GetAllPackages(string projectPath) {
if (string.IsNullOrEmpty(projectPath) || !File.Exists(projectPath)) {
return new List<(string, string)>();
}
var packages = new List<(string, string)>();
var format = DetectPackageFormat(projectPath);
try {
if (format == PackageFormat.PackageReference) {
var packageRefs = GetPackageReferences(projectPath);
packages.AddRange(packageRefs.Select(p => (p.PackageId, p.Version)));
} else {
var packagesConfigPath = GetPackagesConfigPath(projectPath);
var packageRefs = GetPackagesFromConfig(packagesConfigPath);
packages.AddRange(packageRefs.Select(p => (p.PackageId, p.Version)));
}
} catch {
// Return what we have if an error occurs
}
return packages;
}
public static List<PackageReference> GetAllPackageReferences(string projectPath) {
if (string.IsNullOrEmpty(projectPath) || !File.Exists(projectPath)) {
return new List<PackageReference>();
}
var format = DetectPackageFormat(projectPath);
try {
if (format == PackageFormat.PackageReference) {
return GetPackageReferences(projectPath);
} else {
var packagesConfigPath = GetPackagesConfigPath(projectPath);
return GetPackagesFromConfig(packagesConfigPath);
}
} catch {
// Return empty list if an error occurs
return new List<PackageReference>();
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.SseServer/Program.cs:
--------------------------------------------------------------------------------
```csharp
using SharpTools.Tools.Services;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp.Tools;
using SharpTools.Tools.Extensions;
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.AspNetCore.HttpLogging;
using Serilog;
using ModelContextProtocol.Protocol;
using System.Reflection;
namespace SharpTools.SseServer;
using SharpTools.Tools.Services;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp.Tools;
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.AspNetCore.HttpLogging;
using Serilog;
using ModelContextProtocol.Protocol;
using System.Reflection;
public class Program {
// --- Application ---
public const string ApplicationName = "SharpToolsMcpSseServer";
public const string ApplicationVersion = "0.0.1";
public const string LogOutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
public static async Task<int> Main(string[] args) {
// Ensure tool assemblies are loaded for MCP SDK's WithToolsFromAssembly
_ = typeof(SolutionTools);
_ = typeof(AnalysisTools);
_ = typeof(ModificationTools);
var portOption = new Option<int>("--port") {
Description = "The port number for the MCP server to listen on.",
DefaultValueFactory = x => 3001
};
var logFileOption = new Option<string?>("--log-file") {
Description = "Optional path to a log file. If not specified, logs only go to console."
};
var logLevelOption = new Option<Serilog.Events.LogEventLevel>("--log-level") {
Description = "Minimum log level for console and file.",
DefaultValueFactory = x => Serilog.Events.LogEventLevel.Information
};
var loadSolutionOption = new Option<string?>("--load-solution") {
Description = "Path to a solution file (.sln) to load immediately on startup."
};
var buildConfigurationOption = new Option<string?>("--build-configuration") {
Description = "Build configuration to use when loading the solution (Debug, Release, etc.)."
};
var disableGitOption = new Option<bool>("--disable-git") {
Description = "Disable Git integration.",
DefaultValueFactory = x => false
};
var rootCommand = new RootCommand("SharpTools MCP Server") {
portOption,
logFileOption,
logLevelOption,
loadSolutionOption,
buildConfigurationOption,
disableGitOption
};
ParseResult? parseResult = rootCommand.Parse(args);
if (parseResult == null) {
Console.Error.WriteLine("Failed to parse command line arguments.");
return 1;
}
int port = parseResult.GetValue(portOption);
string? logFilePath = parseResult.GetValue(logFileOption);
Serilog.Events.LogEventLevel minimumLogLevel = parseResult.GetValue(logLevelOption);
string? solutionPath = parseResult.GetValue(loadSolutionOption);
string? buildConfiguration = parseResult.GetValue(buildConfigurationOption)!;
bool disableGit = parseResult.GetValue(disableGitOption);
string serverUrl = $"http://localhost:{port}";
var loggerConfiguration = new LoggerConfiguration()
.MinimumLevel.Is(minimumLogLevel) // Set based on command line
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) // Default override
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information)
// For debugging connection issues, set AspNetCore to Information or Debug
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting.Diagnostics", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore.Server.Kestrel", Serilog.Events.LogEventLevel.Debug) // Kestrel connection logs
.MinimumLevel.Override("Microsoft.CodeAnalysis", Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("ModelContextProtocol", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Async(a => a.Console(
outputTemplate: LogOutputTemplate,
standardErrorFromLevel: Serilog.Events.LogEventLevel.Verbose,
restrictedToMinimumLevel: minimumLogLevel));
if (!string.IsNullOrWhiteSpace(logFilePath)) {
var logDirectory = Path.GetDirectoryName(logFilePath);
if (!string.IsNullOrWhiteSpace(logDirectory) && !Directory.Exists(logDirectory)) {
Directory.CreateDirectory(logDirectory);
}
loggerConfiguration.WriteTo.Async(a => a.File(
logFilePath,
rollingInterval: RollingInterval.Day,
outputTemplate: LogOutputTemplate,
fileSizeLimitBytes: 10 * 1024 * 1024,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 7,
restrictedToMinimumLevel: minimumLogLevel));
Console.WriteLine($"Logging to file: {Path.GetFullPath(logFilePath)} with minimum level {minimumLogLevel}");
}
Log.Logger = loggerConfiguration.CreateBootstrapLogger();
if (disableGit) {
Log.Information("Git integration is disabled.");
}
if (!string.IsNullOrEmpty(buildConfiguration)) {
Log.Information("Using build configuration: {BuildConfiguration}", buildConfiguration);
}
try {
Log.Information("Configuring {AppName} v{AppVersion} to run on {ServerUrl} with minimum log level {LogLevel}",
ApplicationName, ApplicationVersion, serverUrl, minimumLogLevel);
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args });
builder.Host.UseSerilog();
// Add W3CLogging for detailed HTTP request logging
// This logs to Microsoft.Extensions.Logging, which Serilog will capture.
builder.Services.AddW3CLogging(logging => {
logging.LoggingFields = W3CLoggingFields.All; // Log all available fields
logging.FileSizeLimit = 5 * 1024 * 1024; // 5 MB
logging.RetainedFileCountLimit = 2;
logging.FileName = "access-"; // Prefix for log files
// By default, logs to a 'logs' subdirectory of the app's content root.
// Can be configured: logging.RootPath = ...
});
builder.Services.WithSharpToolsServices(!disableGit, buildConfiguration);
builder.Services
.AddMcpServer(options => {
options.ServerInfo = new Implementation {
Name = ApplicationName,
Version = ApplicationVersion,
};
// For debugging, you can hook into handlers here if needed,
// but ModelContextProtocol's own Debug logging should be sufficient.
})
.WithHttpTransport()
.WithSharpTools();
var app = builder.Build();
// Load solution if specified in command line arguments
if (!string.IsNullOrEmpty(solutionPath)) {
try {
var solutionManager = app.Services.GetRequiredService<ISolutionManager>();
var editorConfigProvider = app.Services.GetRequiredService<IEditorConfigProvider>();
Log.Information("Loading solution: {SolutionPath}", solutionPath);
await solutionManager.LoadSolutionAsync(solutionPath, CancellationToken.None);
var solutionDir = Path.GetDirectoryName(solutionPath);
if (!string.IsNullOrEmpty(solutionDir)) {
await editorConfigProvider.InitializeAsync(solutionDir, CancellationToken.None);
Log.Information("Solution loaded successfully: {SolutionPath}", solutionPath);
} else {
Log.Warning("Could not determine directory for solution path: {SolutionPath}", solutionPath);
}
} catch (Exception ex) {
Log.Error(ex, "Error loading solution: {SolutionPath}", solutionPath);
}
}
// --- ASP.NET Core Middleware ---
// 1. W3C Logging Middleware (if enabled and configured to log to a file separate from Serilog)
// If W3CLogging is configured to write to files, it has its own middleware.
// If it's just for ILogger, Serilog picks it up.
// app.UseW3CLogging(); // This is needed if W3CLogging is writing its own files.
// If it's just feeding ILogger, Serilog handles it.
// 2. Custom Request Logging Middleware (very early in the pipeline)
app.Use(async (context, next) => {
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogDebug("Incoming Request: {Method} {Path} {QueryString} from {RemoteIpAddress}",
context.Request.Method,
context.Request.Path,
context.Request.QueryString,
context.Connection.RemoteIpAddress);
// Log headers for more detail if needed (can be verbose)
// foreach (var header in context.Request.Headers) {
// logger.LogTrace("Header: {Key}: {Value}", header.Key, header.Value);
// }
try {
await next(context);
} catch (Exception ex) {
logger.LogError(ex, "Error processing request: {Method} {Path}", context.Request.Method, context.Request.Path);
throw; // Re-throw to let ASP.NET Core handle it
}
logger.LogDebug("Outgoing Response: {StatusCode} for {Method} {Path}",
context.Response.StatusCode,
context.Request.Method,
context.Request.Path);
});
// 3. Standard ASP.NET Core middleware (HTTPS redirection, routing, auth, etc. - not used here yet)
// if (app.Environment.IsDevelopment()) { }
// app.UseHttpsRedirection();
// 4. MCP Middleware
app.MapMcp(); // Maps the MCP endpoint (typically "/mcp")
Log.Information("Starting {AppName} server...", ApplicationName);
await app.RunAsync(serverUrl);
return 0;
} catch (Exception ex) {
Log.Fatal(ex, "{AppName} terminated unexpectedly.", ApplicationName);
return 1;
} finally {
Log.Information("{AppName} shutting down.", ApplicationName);
await Log.CloseAndFlushAsync();
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/PackageTools.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using NuGet.Common;
using NuGet.Protocol;
using NuGet.Protocol.Core.Types;
using NuGet.Versioning;
using SharpTools.Tools.Services;
using System.Xml.Linq;
namespace SharpTools.Tools.Mcp.Tools;
// Marker class for ILogger<T> category specific to PackageTools
public class PackageToolsLogCategory { }
[McpServerToolType]
public static class PackageTools {
// Disabled for now, needs to handle dependencies and reloading solution
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(AddOrModifyNugetPackage), Idempotent = false, ReadOnly = false, Destructive = false, OpenWorld = false)]
[Description("Adds or modifies a NuGet package in a project.")]
public static async Task<string> AddOrModifyNugetPackage(
ILogger<PackageToolsLogCategory> logger,
ISolutionManager solutionManager,
IDocumentOperationsService documentOperations,
string projectName,
string nugetPackageId,
[Description("The version of the NuGet package or 'latest' for latest")] string? version,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(projectName, nameof(projectName), logger);
ErrorHandlingHelpers.ValidateStringParameter(nugetPackageId, nameof(nugetPackageId), logger);
logger.LogInformation("Adding/modifying NuGet package '{PackageId}' {Version} to {ProjectPath}",
nugetPackageId, version ?? "latest", projectName);
if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) {
version = null; // Treat 'latest' as null for processing
}
int indexOfParen = projectName.IndexOf('(');
string projectNameNormalized = indexOfParen == -1
? projectName.Trim()
: projectName[..indexOfParen].Trim();
var project = solutionManager.GetProjects().FirstOrDefault(
p => p.Name == projectName
|| p.AssemblyName == projectName
|| p.Name == projectNameNormalized);
if (project == null) {
logger.LogError("Project '{ProjectName}' not found in the loaded solution", projectName);
throw new McpException($"Project '{projectName}' not found in the solution.");
}
// Validate the package exists
var packageExists = await ValidatePackageAsync(nugetPackageId, version, logger, cancellationToken);
if (!packageExists) {
throw new McpException($"Package '{nugetPackageId}' {(string.IsNullOrEmpty(version) ? "" : $"with version {version} ")}was not found on NuGet.org.");
}
// If no version specified, get the latest version
if (string.IsNullOrEmpty(version)) {
version = await GetLatestVersionAsync(nugetPackageId, logger, cancellationToken);
logger.LogInformation("Using latest version {Version} for package {PackageId}", version, nugetPackageId);
}
ErrorHandlingHelpers.ValidateFileExists(project.FilePath, logger);
var projectPath = project.FilePath!;
// Detect package format and add/update accordingly
var packageFormat = LegacyNuGetPackageReader.DetectPackageFormat(projectPath);
var action = "added";
var projectPackages = LegacyNuGetPackageReader.GetPackagesForProject(projectPath);
// Check if package already exists to determine if we're adding or updating
var existingPackage = projectPackages.Packages.FirstOrDefault(p =>
string.Equals(p.PackageId, nugetPackageId, StringComparison.OrdinalIgnoreCase));
if (existingPackage != null) {
action = "updated";
logger.LogInformation("Package {PackageId} already exists with version {OldVersion}, updating to {NewVersion}",
nugetPackageId, existingPackage.Version, version);
}
// Update the project file based on the package format
if (packageFormat == LegacyNuGetPackageReader.PackageFormat.PackageReference) {
await UpdatePackageReferenceAsync(projectPath, nugetPackageId, version, existingPackage != null, documentOperations, logger, cancellationToken);
} else {
var packagesConfigPath = projectPackages.PackagesConfigPath ?? throw new McpException("packages.config path not found.");
await UpdatePackagesConfigAsync(packagesConfigPath, nugetPackageId, version, existingPackage != null, documentOperations, logger, cancellationToken);
}
logger.LogInformation("Package {PackageId} {Action} with version {Version}", nugetPackageId, action, version);
return $"The package {nugetPackageId} has been {action} with version {version}. You must perform a `{(packageFormat == LegacyNuGetPackageReader.PackageFormat.PackageReference ? "dotnet restore" : "nuget restore")}` and then reload the solution.";
}, logger, nameof(AddOrModifyNugetPackage), cancellationToken);
}
private static async Task<bool> ValidatePackageAsync(string packageId, string? version, Microsoft.Extensions.Logging.ILogger logger, CancellationToken cancellationToken) {
try {
logger.LogInformation("Validating package {PackageId} {Version} on NuGet.org",
packageId, version ?? "latest");
var nugetLogger = NullLogger.Instance;
// Create repository
var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json");
var resource = await repository.GetResourceAsync<PackageMetadataResource>(cancellationToken);
// Get package metadata
var packages = await resource.GetMetadataAsync(
packageId,
includePrerelease: true,
includeUnlisted: false,
sourceCacheContext: new SourceCacheContext(),
nugetLogger,
cancellationToken);
if (!packages.Any())
return false; // Package doesn't exist
if (string.IsNullOrEmpty(version))
return true; // Just checking existence
// Validate specific version
var targetVersion = NuGetVersion.Parse(version);
return packages.Any(p => p.Identity.Version.Equals(targetVersion));
} catch (Exception ex) {
logger.LogError(ex, "Error validating NuGet package {PackageId} {Version}", packageId, version);
throw new McpException($"Failed to validate NuGet package '{packageId}': {ex.Message}");
}
}
private static async Task<string> GetLatestVersionAsync(string packageId, Microsoft.Extensions.Logging.ILogger logger, CancellationToken cancellationToken) {
try {
var nugetLogger = NullLogger.Instance;
var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json");
var resource = await repository.GetResourceAsync<PackageMetadataResource>(cancellationToken);
var packages = await resource.GetMetadataAsync(
packageId,
includePrerelease: false, // Only stable versions for the latest
includeUnlisted: false,
sourceCacheContext: new SourceCacheContext(),
nugetLogger,
cancellationToken);
var latestPackage = packages
.OrderByDescending(p => p.Identity.Version)
.FirstOrDefault();
if (latestPackage == null) {
throw new McpException($"No stable versions found for package '{packageId}'.");
}
return latestPackage.Identity.Version.ToString();
} catch (Exception ex) {
logger.LogError(ex, "Error getting latest version for NuGet package {PackageId}", packageId);
throw new McpException($"Failed to get latest version for NuGet package '{packageId}': {ex.Message}");
}
}
private static async Task UpdatePackageReferenceAsync(
string projectPath,
string packageId,
string version,
bool isUpdate,
IDocumentOperationsService documentOperations,
Microsoft.Extensions.Logging.ILogger logger,
CancellationToken cancellationToken) {
try {
var (projectContent, _) = await documentOperations.ReadFileAsync(projectPath, false, cancellationToken);
var xDoc = XDocument.Parse(projectContent);
// Find ItemGroup that contains PackageReference elements or create a new one
var itemGroup = xDoc.Root?.Elements("ItemGroup")
.FirstOrDefault(ig => ig.Elements("PackageReference").Any());
if (itemGroup == null) {
// Create a new ItemGroup for PackageReferences
itemGroup = new XElement("ItemGroup");
xDoc.Root?.Add(itemGroup);
}
// Find existing package reference
var existingPackage = itemGroup.Elements("PackageReference")
.FirstOrDefault(pr => string.Equals(pr.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase));
if (existingPackage != null) {
// Update existing package
var versionAttr = existingPackage.Attribute("Version");
if (versionAttr != null) {
versionAttr.Value = version;
} else {
// Version might be in a child element
var versionElement = existingPackage.Element("Version");
if (versionElement != null) {
versionElement.Value = version;
} else {
// Add version as attribute if neither exists
existingPackage.Add(new XAttribute("Version", version));
}
}
} else {
// Add new package reference
var packageRef = new XElement("PackageReference",
new XAttribute("Include", packageId),
new XAttribute("Version", version));
itemGroup.Add(packageRef);
}
// Save the updated project file
await documentOperations.WriteFileAsync(projectPath, xDoc.ToString(), true, cancellationToken,
$"{(isUpdate ? "Updated" : "Added")} NuGet package {packageId} with version {version}");
} catch (Exception ex) {
logger.LogError(ex, "Error updating PackageReference in project file {ProjectPath}", projectPath);
throw new McpException($"Failed to update PackageReference in project file: {ex.Message}");
}
}
private static async Task UpdatePackagesConfigAsync(
string? packagesConfigPath,
string packageId,
string version,
bool isUpdate,
IDocumentOperationsService documentOperations,
Microsoft.Extensions.Logging.ILogger logger,
CancellationToken cancellationToken) {
if (string.IsNullOrEmpty(packagesConfigPath)) {
throw new McpException("packages.config path is null or empty.");
}
try {
// Check if packages.config exists, if not create it
bool fileExists = documentOperations.FileExists(packagesConfigPath);
XDocument xDoc;
if (fileExists) {
var (content, _) = await documentOperations.ReadFileAsync(packagesConfigPath, false, cancellationToken);
xDoc = XDocument.Parse(content);
} else {
// Create a new packages.config file
xDoc = new XDocument(
new XElement("packages",
new XAttribute("xmlns", "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd")));
}
// Find existing package
var packageElement = xDoc.Root?.Elements("package")
.FirstOrDefault(p => string.Equals(p.Attribute("id")?.Value, packageId, StringComparison.OrdinalIgnoreCase));
if (packageElement != null) {
// Update existing package
packageElement.Attribute("version")!.Value = version;
} else {
// Add new package entry
var newPackage = new XElement("package",
new XAttribute("id", packageId),
new XAttribute("version", version),
new XAttribute("targetFramework", "net40")); // Default target framework
xDoc.Root?.Add(newPackage);
}
// Save the updated packages.config
await documentOperations.WriteFileAsync(packagesConfigPath, xDoc.ToString(), true, cancellationToken,
$"{(isUpdate ? "Updated" : "Added")} NuGet package {packageId} with version {version} in packages.config");
} catch (Exception ex) {
logger.LogError(ex, "Error updating packages.config at {PackagesConfigPath}", packagesConfigPath);
throw new McpException($"Failed to update packages.config: {ex.Message}");
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/GitService.cs:
--------------------------------------------------------------------------------
```csharp
using LibGit2Sharp;
using System.Text;
namespace SharpTools.Tools.Services;
public class GitService : IGitService {
private readonly ILogger<GitService> _logger;
private const string SharpToolsBranchPrefix = "sharptools/";
private const string SharpToolsUndoBranchPrefix = "sharptools/undo/";
public GitService(ILogger<GitService> logger) {
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> IsRepositoryAsync(string solutionPath, CancellationToken cancellationToken = default) {
return await Task.Run(() => {
try {
var solutionDirectory = Path.GetDirectoryName(solutionPath);
if (string.IsNullOrEmpty(solutionDirectory)) {
return false;
}
var repositoryPath = Repository.Discover(solutionDirectory);
return !string.IsNullOrEmpty(repositoryPath);
} catch (Exception ex) {
_logger.LogDebug("Error checking if path is a Git repository: {Error}", ex.Message);
return false;
}
}, cancellationToken);
}
public async Task<bool> IsOnSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default) {
return await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
return false;
}
using var repository = new Repository(repositoryPath);
var currentBranch = repository.Head.FriendlyName;
var isOnSharpToolsBranch = currentBranch.StartsWith(SharpToolsBranchPrefix, StringComparison.OrdinalIgnoreCase);
_logger.LogDebug("Current branch: {BranchName}, IsSharpToolsBranch: {IsSharpToolsBranch}",
currentBranch, isOnSharpToolsBranch);
return isOnSharpToolsBranch;
} catch (Exception ex) {
_logger.LogWarning("Error checking current branch: {Error}", ex.Message);
return false;
}
}, cancellationToken);
}
public async Task EnsureSharpToolsBranchAsync(string solutionPath, CancellationToken cancellationToken = default) {
await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return;
}
using var repository = new Repository(repositoryPath);
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd/HH-mm-ss");
var branchName = $"{SharpToolsBranchPrefix}{timestamp}";
// Check if we're already on a sharptools branch
var currentBranch = repository.Head.FriendlyName;
if (currentBranch.StartsWith(SharpToolsBranchPrefix, StringComparison.OrdinalIgnoreCase)) {
_logger.LogDebug("Already on SharpTools branch: {BranchName}", currentBranch);
return;
}
// Create and checkout the new branch
var newBranch = repository.CreateBranch(branchName);
Commands.Checkout(repository, newBranch);
_logger.LogInformation("Created and switched to SharpTools branch: {BranchName}", branchName);
} catch (Exception ex) {
_logger.LogError(ex, "Error ensuring SharpTools branch for solution at {SolutionPath}", solutionPath);
throw;
}
}, cancellationToken);
}
public async Task CommitChangesAsync(string solutionPath, IEnumerable<string> changedFilePaths,
string commitMessage, CancellationToken cancellationToken = default) {
await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return;
}
using var repository = new Repository(repositoryPath);
// Stage the changed files
var stagedFiles = new List<string>();
foreach (var filePath in changedFilePaths) {
try {
// Convert absolute path to relative path from repository root
var relativePath = Path.GetRelativePath(repository.Info.WorkingDirectory, filePath);
// Stage the file
Commands.Stage(repository, relativePath);
stagedFiles.Add(relativePath);
_logger.LogDebug("Staged file: {FilePath}", relativePath);
} catch (Exception ex) {
_logger.LogWarning("Failed to stage file {FilePath}: {Error}", filePath, ex.Message);
}
}
if (stagedFiles.Count == 0) {
_logger.LogWarning("No files were staged for commit");
return;
}
// Create commit
var signature = GetCommitSignature(repository);
var commit = repository.Commit(commitMessage, signature, signature);
_logger.LogInformation("Created commit {CommitSha} with {FileCount} files: {CommitMessage}",
commit.Sha[..8], stagedFiles.Count, commitMessage);
} catch (Exception ex) {
_logger.LogError(ex, "Error committing changes for solution at {SolutionPath}", solutionPath);
throw;
}
}, cancellationToken);
}
private string? GetRepositoryPath(string solutionPath) {
var solutionDirectory = Path.GetDirectoryName(solutionPath);
return string.IsNullOrEmpty(solutionDirectory) ? null : Repository.Discover(solutionDirectory);
}
private Signature GetCommitSignature(Repository repository) {
try {
// Try to get user info from Git config
var config = repository.Config;
var name = config.Get<string>("user.name")?.Value ?? "SharpTools";
var email = config.Get<string>("user.email")?.Value ?? "sharptools@localhost";
return new Signature(name, email, DateTimeOffset.Now);
} catch {
// Fallback to default signature
return new Signature("SharpTools", "sharptools@localhost", DateTimeOffset.Now);
}
}
public async Task<string> CreateUndoBranchAsync(string solutionPath, CancellationToken cancellationToken = default) {
return await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return string.Empty;
}
using var repository = new Repository(repositoryPath);
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd/HH-mm-ss");
var branchName = $"{SharpToolsUndoBranchPrefix}{timestamp}";
// Create a new branch at the current commit, but don't checkout
var currentCommit = repository.Head.Tip;
var newBranch = repository.CreateBranch(branchName, currentCommit);
_logger.LogInformation("Created undo branch: {BranchName} at commit {CommitSha}",
branchName, currentCommit.Sha[..8]);
return branchName;
} catch (Exception ex) {
_logger.LogError(ex, "Error creating undo branch for solution at {SolutionPath}", solutionPath);
return string.Empty;
}
}, cancellationToken);
}
public async Task<string> GetDiffAsync(string solutionPath, string oldCommitSha, string newCommitSha, CancellationToken cancellationToken = default) {
return await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return string.Empty;
}
using var repository = new Repository(repositoryPath);
var oldCommit = repository.Lookup<Commit>(oldCommitSha);
var newCommit = repository.Lookup<Commit>(newCommitSha);
if (oldCommit == null || newCommit == null) {
_logger.LogWarning("Could not find commits for diff: Old {OldSha}, New {NewSha}",
oldCommitSha?[..8] ?? "null", newCommitSha?[..8] ?? "null");
return string.Empty;
}
// Get the changes between the two commits
var diffOutput = new StringBuilder();
diffOutput.AppendLine($"Changes between {oldCommitSha[..8]} and {newCommitSha[..8]}:");
diffOutput.AppendLine();
// Compare the trees
var comparison = repository.Diff.Compare<TreeChanges>(oldCommit.Tree, newCommit.Tree);
foreach (var change in comparison) {
diffOutput.AppendLine($"{change.Status}: {change.Path}");
// Get detailed patch for each file
var patch = repository.Diff.Compare<Patch>(
oldCommit.Tree,
newCommit.Tree,
new[] { change.Path },
new CompareOptions { ContextLines = 0 });
diffOutput.AppendLine(patch);
}
return diffOutput.ToString();
} catch (Exception ex) {
_logger.LogError(ex, "Error getting diff for solution at {SolutionPath}", solutionPath);
return $"Error generating diff: {ex.Message}";
}
}, cancellationToken);
}
public async Task<(bool success, string diff)> RevertLastCommitAsync(string solutionPath, CancellationToken cancellationToken = default) {
return await Task.Run(async () => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return (false, string.Empty);
}
using var repository = new Repository(repositoryPath);
var currentBranch = repository.Head.FriendlyName;
// Ensure we're on a sharptools branch
if (!currentBranch.StartsWith(SharpToolsBranchPrefix, StringComparison.OrdinalIgnoreCase)) {
_logger.LogWarning("Not on a SharpTools branch, cannot revert. Current branch: {BranchName}", currentBranch);
return (false, string.Empty);
}
var currentCommit = repository.Head.Tip;
if (currentCommit?.Parents?.Any() != true) {
_logger.LogWarning("Current commit has no parent, cannot revert");
return (false, string.Empty);
}
var parentCommit = currentCommit.Parents.First();
_logger.LogInformation("Reverting from commit {CurrentSha} to parent {ParentSha}",
currentCommit.Sha[..8], parentCommit.Sha[..8]);
// First, create an undo branch at the current commit
var undoBranchName = await CreateUndoBranchAsync(solutionPath, cancellationToken);
if (string.IsNullOrEmpty(undoBranchName)) {
_logger.LogWarning("Failed to create undo branch");
}
// Get the diff before we reset
var diff = await GetDiffAsync(solutionPath, parentCommit.Sha, currentCommit.Sha, cancellationToken);
// Reset to the parent commit (hard reset)
repository.Reset(ResetMode.Hard, parentCommit);
_logger.LogInformation("Successfully reverted to commit {CommitSha}", parentCommit.Sha[..8]);
var resultMessage = !string.IsNullOrEmpty(undoBranchName)
? $"The changes have been preserved in branch '{undoBranchName}' for future reference."
: string.Empty;
return (true, diff + "\n\n" + resultMessage);
} catch (Exception ex) {
_logger.LogError(ex, "Error reverting last commit for solution at {SolutionPath}", solutionPath);
return (false, $"Error: {ex.Message}");
}
}, cancellationToken);
}
public async Task<string> GetBranchOriginCommitAsync(string solutionPath, CancellationToken cancellationToken = default) {
return await Task.Run(() => {
try {
var repositoryPath = GetRepositoryPath(solutionPath);
if (repositoryPath == null) {
_logger.LogWarning("No Git repository found for solution at {SolutionPath}", solutionPath);
return string.Empty;
}
using var repository = new Repository(repositoryPath);
var currentBranch = repository.Head.FriendlyName;
// Ensure we're on a sharptools branch
if (!currentBranch.StartsWith(SharpToolsBranchPrefix, StringComparison.OrdinalIgnoreCase)) {
_logger.LogDebug("Not on a SharpTools branch, returning empty. Current branch: {BranchName}", currentBranch);
return string.Empty;
}
// Find the commit where this branch diverged from its parent
// We'll traverse the commit history to find where the sharptools branch was created
var commit = repository.Head.Tip;
var branchCreationCommit = commit;
// Walk back through the commits to find the first commit on this branch
while (commit?.Parents?.Any() == true) {
var parent = commit.Parents.First();
// If this is the first commit that mentions sharptools in the branch,
// the parent is likely our origin point
if (commit.MessageShort.Contains("SharpTools", StringComparison.OrdinalIgnoreCase) ||
commit.MessageShort.Contains("branch", StringComparison.OrdinalIgnoreCase)) {
branchCreationCommit = parent;
break;
}
commit = parent;
}
_logger.LogDebug("Branch origin commit found: {CommitSha}", branchCreationCommit.Sha[..8]);
return branchCreationCommit.Sha;
} catch (Exception ex) {
_logger.LogError(ex, "Error finding branch origin commit for solution at {SolutionPath}", solutionPath);
return string.Empty;
}
}, cancellationToken);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/ContextInjectors.cs:
--------------------------------------------------------------------------------
```csharp
using System.Text;
using System.Text.RegularExpressions;
using DiffPlex.DiffBuilder;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Logging;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp.Tools;
namespace SharpTools.Tools.Mcp;
/// <summary>
/// Provides reusable context injection methods for checking compilation errors and generating diffs.
/// These methods are used across various tools to provide consistent feedback.
/// </summary>
internal static class ContextInjectors {
/// <summary>
/// Checks for compilation errors in a document after code has been modified.
/// </summary>
/// <param name="solutionManager">The solution manager</param>
/// <param name="document">The document to check for errors</param>
/// <param name="logger">Logger instance</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A tuple containing (hasErrors, errorMessages)</returns>
public static async Task<(bool HasErrors, string ErrorMessages)> CheckCompilationErrorsAsync<TLogCategory>(
ISolutionManager solutionManager,
Document document,
ILogger<TLogCategory> logger,
CancellationToken cancellationToken) {
if (document == null) {
logger.LogWarning("Cannot check for compilation errors: Document is null");
return (false, string.Empty);
}
try {
// Get the project containing this document
var project = document.Project;
if (project == null) {
logger.LogWarning("Cannot check for compilation errors: Project not found for document {FilePath}",
document.FilePath ?? "unknown");
return (false, string.Empty);
}
// Get compilation for the project
var compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
if (compilation == null) {
logger.LogWarning("Cannot check for compilation errors: Compilation not available for project {ProjectName}",
project.Name);
return (false, string.Empty);
}
// Get syntax tree for the document
var syntaxTree = await document.GetSyntaxTreeAsync(cancellationToken);
if (syntaxTree == null) {
logger.LogWarning("Cannot check for compilation errors: Syntax tree not available for document {FilePath}",
document.FilePath ?? "unknown");
return (false, string.Empty);
}
// Get semantic model
var semanticModel = compilation.GetSemanticModel(syntaxTree);
// Get all diagnostics for the specific syntax tree
var diagnostics = semanticModel.GetDiagnostics(cancellationToken: cancellationToken)
.Where(d => d.Severity == DiagnosticSeverity.Error || d.Severity == DiagnosticSeverity.Warning)
.OrderByDescending(d => d.Severity) // Errors first, then warnings
.ThenBy(d => d.Location.SourceSpan.Start)
.ToList();
if (!diagnostics.Any())
return (false, string.Empty);
// Focus specifically on member access errors
var memberAccessErrors = diagnostics
.Where(d => d.Id == "CS0103" || d.Id == "CS1061" || d.Id == "CS0117" || d.Id == "CS0246")
.ToList();
// Build error message
var sb = new StringBuilder();
sb.AppendLine($"<compilationErrors note=\"If the fixes for these errors are simple, use `{ToolHelpers.SharpToolPrefix}{nameof(ModificationTools.FindAndReplace)}`\">");
// First add member access errors (highest priority as this is what we're focusing on)
foreach (var error in memberAccessErrors) {
var lineSpan = error.Location.GetLineSpan();
sb.AppendLine($" {error.Severity}: {error.Id} - {error.GetMessage()} at line {lineSpan.StartLinePosition.Line + 1}, column {lineSpan.StartLinePosition.Character + 1}");
}
// Then add other errors and warnings
foreach (var diag in diagnostics.Except(memberAccessErrors)) {
var lineSpan = diag.Location.GetLineSpan();
sb.AppendLine($" {diag.Severity}: {diag.Id} - {diag.GetMessage()} at line {lineSpan.StartLinePosition.Line + 1}, column {lineSpan.StartLinePosition.Character + 1}");
}
sb.AppendLine("</compilationErrors>");
logger.LogWarning("Compilation issues found in {FilePath}:\n{Errors}",
document.FilePath ?? "unknown", sb.ToString());
return (true, sb.ToString());
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogError(ex, "Error checking for compilation errors in document {FilePath}",
document.FilePath ?? "unknown");
return (false, $"Error checking for compilation errors: {ex.Message}");
}
}
/// <summary>
/// Creates a pretty diff between old and new code, with whitespace and formatting normalized
/// </summary>
/// <param name="oldCode">The original code</param>
/// <param name="newCode">The updated code</param>
/// <param name="includeContextMessage">Whether to include context message about diff being applied</param>
/// <returns>Formatted diff as a string</returns>
public static string CreateCodeDiff(string oldCode, string newCode) {
// Helper function to trim lines for cleaner diff
static string trimLines(string code) =>
string.Join("\n", code.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line)));
string strippedOldCode = trimLines(oldCode);
string strippedNewCode = trimLines(newCode);
var diff = InlineDiffBuilder.Diff(strippedOldCode, strippedNewCode);
var diffBuilder = new StringBuilder();
bool inUnchangedSection = false;
foreach (var line in diff.Lines) {
switch (line.Type) {
case DiffPlex.DiffBuilder.Model.ChangeType.Inserted:
diffBuilder.AppendLine($"+ {line.Text}");
inUnchangedSection = false;
break;
case DiffPlex.DiffBuilder.Model.ChangeType.Deleted:
diffBuilder.AppendLine($"- {line.Text}");
inUnchangedSection = false;
break;
case DiffPlex.DiffBuilder.Model.ChangeType.Unchanged:
if (!inUnchangedSection) {
diffBuilder.AppendLine("// ...existing code unchanged...");
inUnchangedSection = true;
}
break;
}
}
var diffResult = diffBuilder.ToString();
if (string.IsNullOrWhiteSpace(diffResult) || diff.Lines.All(l => l.Type == DiffPlex.DiffBuilder.Model.ChangeType.Unchanged)) {
diffResult = "<diff>\n// No changes detected.\n</diff>";
} else {
diffResult = $"<diff>\n{diffResult}\n</diff>\nNote: This diff has been applied. You must base all future changes on the updated code.";
}
return diffResult;
}
/// <summary>
/// Creates a diff between old and new document text
/// </summary>
/// <param name="oldDocument">The original document</param>
/// <param name="newDocument">The updated document</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Formatted diff as a string</returns>
public static async Task<string> CreateDocumentDiff(Document oldDocument, Document newDocument, CancellationToken cancellationToken) {
if (oldDocument == null || newDocument == null) {
return "// Could not generate diff: One or both documents are null.";
}
var oldText = await oldDocument.GetTextAsync(cancellationToken);
var newText = await newDocument.GetTextAsync(cancellationToken);
return CreateCodeDiff(oldText.ToString(), newText.ToString());
}
/// <summary>
/// Creates a multi-document diff for a collection of changed documents
/// </summary>
/// <param name="originalSolution">The original solution</param>
/// <param name="newSolution">The updated solution</param>
/// <param name="changedDocuments">List of document IDs that were changed</param>
/// <param name="maxDocuments">Maximum number of documents to include in the diff</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Formatted diff as a string, including file names</returns>
public static async Task<string> CreateMultiDocumentDiff(
Solution originalSolution,
Solution newSolution,
IReadOnlyList<DocumentId> changedDocuments,
int maxDocuments = 5,
CancellationToken cancellationToken = default) {
if (changedDocuments.Count == 0) {
return "No documents changed.";
}
var sb = new StringBuilder();
sb.AppendLine($"Changes in {Math.Min(changedDocuments.Count, maxDocuments)} documents:");
int count = 0;
foreach (var docId in changedDocuments) {
if (count >= maxDocuments) {
sb.AppendLine($"...and {changedDocuments.Count - maxDocuments} more documents");
break;
}
var oldDoc = originalSolution.GetDocument(docId);
var newDoc = newSolution.GetDocument(docId);
if (oldDoc == null || newDoc == null) {
continue;
}
sb.AppendLine();
sb.AppendLine($"Document: {oldDoc.FilePath}");
sb.AppendLine(await CreateDocumentDiff(oldDoc, newDoc, cancellationToken));
count++;
}
return sb.ToString();
}
public static async Task<string> CreateCallGraphContextAsync<TLogCategory>(
ICodeAnalysisService codeAnalysisService,
ILogger<TLogCategory> logger,
IMethodSymbol methodSymbol,
CancellationToken cancellationToken) {
if (methodSymbol == null) {
return "Method symbol is null.";
}
var callers = new HashSet<string>();
var callees = new HashSet<string>();
try {
// Get incoming calls (callers)
var callerInfos = await codeAnalysisService.FindCallersAsync(methodSymbol, cancellationToken);
foreach (var callerInfo in callerInfos) {
cancellationToken.ThrowIfCancellationRequested();
if (callerInfo.CallingSymbol is IMethodSymbol callingMethodSymbol) {
// We still show all callers, since this is important for analysis
string callerFqn = FuzzyFqnLookupService.GetSearchableString(callingMethodSymbol);
callers.Add(callerFqn);
}
}
// Get outgoing calls (callees)
var outgoingSymbols = await codeAnalysisService.FindOutgoingCallsAsync(methodSymbol, cancellationToken);
foreach (var callee in outgoingSymbols) {
cancellationToken.ThrowIfCancellationRequested();
if (callee is IMethodSymbol calleeMethodSymbol) {
// Only include callees that are defined within the solution
if (IsSymbolInSolution(calleeMethodSymbol)) {
string calleeFqn = FuzzyFqnLookupService.GetSearchableString(calleeMethodSymbol);
callees.Add(calleeFqn);
}
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error creating call graph context for method {MethodName}", methodSymbol.Name);
return $"Error creating call graph: {ex.Message}";
}
// Format results in XML format
var random = new Random();
var result = new StringBuilder();
result.AppendLine("<callers>");
var randomizedCallers = callers.OrderBy(_ => random.Next()).Take(20);
foreach (var caller in randomizedCallers) {
result.AppendLine(caller);
}
if (callers.Count > 20) {
result.AppendLine($"<!-- {callers.Count - 20} more callers not shown -->");
}
result.AppendLine("</callers>");
result.AppendLine("<callees>");
foreach (var callee in callees.OrderBy(_ => random.Next()).Take(20)) {
result.AppendLine(callee);
}
if (callees.Count > 20) {
result.AppendLine($"<!-- {callees.Count - 20} more callees not shown -->");
}
result.AppendLine("</callees>");
return result.ToString();
}
public static async Task<string> CreateTypeReferenceContextAsync<TLogCategory>(
ICodeAnalysisService codeAnalysisService,
ILogger<TLogCategory> logger,
INamedTypeSymbol typeSymbol,
CancellationToken cancellationToken) {
if (typeSymbol == null) {
return "Type symbol is null.";
}
var referencingTypes = new HashSet<string>();
var referencedTypes = new HashSet<string>(StringComparer.Ordinal);
try {
// Get referencing types (types that reference this type)
var references = await codeAnalysisService.FindReferencesAsync(typeSymbol, cancellationToken);
foreach (var reference in references) {
cancellationToken.ThrowIfCancellationRequested();
foreach (var location in reference.Locations) {
if (location.Document == null || location.Location == null) {
continue;
}
var semanticModel = await location.Document.GetSemanticModelAsync(cancellationToken);
if (semanticModel == null) {
continue;
}
var symbol = semanticModel.GetEnclosingSymbol(location.Location.SourceSpan.Start, cancellationToken);
while (symbol != null && !(symbol is INamedTypeSymbol)) {
symbol = symbol.ContainingSymbol;
}
if (symbol is INamedTypeSymbol referencingType &&
!SymbolEqualityComparer.Default.Equals(referencingType, typeSymbol)) {
// We still include all referencing types, since this is important for analysis
string referencingTypeFqn = FuzzyFqnLookupService.GetSearchableString(referencingType);
referencingTypes.Add(referencingTypeFqn);
}
}
}
// Get referenced types (types this type references in implementations)
// This was moved to CodeAnalysisService.FindReferencedTypesAsync
referencedTypes = await codeAnalysisService.FindReferencedTypesAsync(typeSymbol, cancellationToken);
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error creating type reference context for type {TypeName}", typeSymbol.Name);
return $"Error creating type reference context: {ex.Message}";
}
// Format results in XML format
var random = new Random();
var result = new StringBuilder();
result.AppendLine("<referencingTypes>");
foreach (var referencingType in referencingTypes.OrderBy(t => random.Next()).Take(20)) {
result.AppendLine(referencingType);
}
if (referencingTypes.Count > 20) {
result.AppendLine($"<!-- {referencingTypes.Count - 20} more referencing types not shown -->");
}
result.AppendLine("</referencingTypes>");
result.AppendLine("<referencedTypes>");
foreach (var referencedType in referencedTypes.OrderBy(t => random.Next()).Take(20)) {
result.AppendLine(referencedType);
}
if (referencedTypes.Count > 20) {
result.AppendLine($"<!-- {referencedTypes.Count - 20} more referenced types not shown -->");
}
result.AppendLine("</referencedTypes>");
return result.ToString();
}/// <summary>
/// Determines if a symbol is defined within the current solution.
/// </summary>
/// <param name="symbol">The symbol to check</param>
/// <returns>True if the symbol is defined within the solution, false otherwise</returns>
private static bool IsSymbolInSolution(ISymbol symbol) {
if (symbol == null) {
return false;
}
// Get the containing assembly of the symbol
var assembly = symbol.ContainingAssembly;
if (assembly == null) {
return false;
}
// Check if the assembly is from source code (part of the solution)
// Assemblies in the solution have source code locations, while referenced assemblies don't
return assembly.Locations.Any(loc => loc.IsInSource);
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/ComplexityAnalysisService.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharpTools.Tools.Extensions;
using SharpTools.Tools.Services;
using ModelContextProtocol;
namespace SharpTools.Tools.Services;
/// <summary>
/// Service for analyzing code complexity metrics.
/// </summary>
public class ComplexityAnalysisService : IComplexityAnalysisService {
private readonly ISolutionManager _solutionManager;
private readonly ILogger<ComplexityAnalysisService> _logger;
public ComplexityAnalysisService(ISolutionManager solutionManager, ILogger<ComplexityAnalysisService> logger) {
_solutionManager = solutionManager;
_logger = logger;
}
public async Task AnalyzeMethodAsync(
IMethodSymbol methodSymbol,
Dictionary<string, object> metrics,
List<string> recommendations,
CancellationToken cancellationToken) {
var syntaxRef = methodSymbol.DeclaringSyntaxReferences.FirstOrDefault();
if (syntaxRef == null) {
_logger.LogWarning("Method {Method} has no syntax reference", methodSymbol.Name);
return;
}
var methodNode = await syntaxRef.GetSyntaxAsync(cancellationToken) as MethodDeclarationSyntax;
if (methodNode == null) {
_logger.LogWarning("Could not get method syntax for {Method}", methodSymbol.Name);
return;
}
// Basic metrics
var lineCount = methodNode.GetText().Lines.Count;
var statementCount = methodNode.DescendantNodes().OfType<StatementSyntax>().Count();
var parameterCount = methodSymbol.Parameters.Length;
var localVarCount = methodNode.DescendantNodes().OfType<LocalDeclarationStatementSyntax>().Count();
metrics["lineCount"] = lineCount;
metrics["statementCount"] = statementCount;
metrics["parameterCount"] = parameterCount;
metrics["localVariableCount"] = localVarCount;
// Cyclomatic complexity
int cyclomaticComplexity = 1; // Base complexity
cyclomaticComplexity += methodNode.DescendantNodes().Count(n => {
switch (n) {
case IfStatementSyntax:
case SwitchSectionSyntax:
case ForStatementSyntax:
case ForEachStatementSyntax:
case WhileStatementSyntax:
case DoStatementSyntax:
case CatchClauseSyntax:
case ConditionalExpressionSyntax:
return true;
case BinaryExpressionSyntax bex:
return bex.IsKind(SyntaxKind.LogicalAndExpression) ||
bex.IsKind(SyntaxKind.LogicalOrExpression);
default:
return false;
}
});
metrics["cyclomaticComplexity"] = cyclomaticComplexity;
// Cognitive complexity (simplified version)
int cognitiveComplexity = 0;
int nesting = 0;
void AddCognitiveComplexity(int value) => cognitiveComplexity += value + nesting;
foreach (var node in methodNode.DescendantNodes()) {
bool isNestingNode = false;
switch (node) {
case IfStatementSyntax:
case ForStatementSyntax:
case ForEachStatementSyntax:
case WhileStatementSyntax:
case DoStatementSyntax:
case CatchClauseSyntax:
AddCognitiveComplexity(1);
isNestingNode = true;
break;
case SwitchStatementSyntax:
AddCognitiveComplexity(1);
break;
case BinaryExpressionSyntax bex:
if (bex.IsKind(SyntaxKind.LogicalAndExpression) ||
bex.IsKind(SyntaxKind.LogicalOrExpression)) {
AddCognitiveComplexity(1);
}
break;
case LambdaExpressionSyntax:
AddCognitiveComplexity(1);
isNestingNode = true;
break;
case RecursivePatternSyntax:
AddCognitiveComplexity(1);
break;
}
if (isNestingNode) {
nesting++;
// We'll decrement nesting when processing the block end
}
}
metrics["cognitiveComplexity"] = cognitiveComplexity;
// Outgoing dependencies (method calls)
// Check if solution is available before using it
int methodCallCount = 0;
if (_solutionManager.CurrentSolution != null) {
var compilation = await _solutionManager.GetCompilationAsync(
methodNode.SyntaxTree.GetRequiredProject(_solutionManager.CurrentSolution).Id,
cancellationToken);
if (compilation != null) {
var semanticModel = compilation.GetSemanticModel(methodNode.SyntaxTree);
var methodCalls = methodNode.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Select(i => semanticModel.GetSymbolInfo(i).Symbol)
.OfType<IMethodSymbol>()
.Where(m => !SymbolEqualityComparer.Default.Equals(m.ContainingType, methodSymbol.ContainingType))
.Select(m => m.ContainingType.ToDisplayString())
.Distinct()
.ToList();
methodCallCount = methodCalls.Count;
metrics["externalMethodCalls"] = methodCallCount;
metrics["externalDependencies"] = methodCalls;
}
} else {
_logger.LogWarning("Cannot analyze method dependencies: No solution loaded");
}
// Add recommendations based on metrics
if (lineCount > 50)
recommendations.Add($"Method '{methodSymbol.Name}' is {lineCount} lines long. Consider breaking it into smaller methods.");
if (cyclomaticComplexity > 10)
recommendations.Add($"Method '{methodSymbol.Name}' has high cyclomatic complexity ({cyclomaticComplexity}). Consider refactoring into smaller methods.");
if (cognitiveComplexity > 20)
recommendations.Add($"Method '{methodSymbol.Name}' has high cognitive complexity ({cognitiveComplexity}). Consider simplifying the logic or breaking it down.");
if (parameterCount > 4)
recommendations.Add($"Method '{methodSymbol.Name}' has {parameterCount} parameters. Consider grouping related parameters into a class.");
if (localVarCount > 10)
recommendations.Add($"Method '{methodSymbol.Name}' has {localVarCount} local variables. Consider breaking some logic into helper methods.");
if (methodCallCount > 5)
recommendations.Add($"Method '{methodSymbol.Name}' has {methodCallCount} external method calls. Consider reducing dependencies or breaking it into smaller methods.");
}
public async Task AnalyzeTypeAsync(
INamedTypeSymbol typeSymbol,
Dictionary<string, object> metrics,
List<string> recommendations,
bool includeGeneratedCode,
CancellationToken cancellationToken) {
var typeMetrics = new Dictionary<string, object>();
// Basic type metrics
typeMetrics["kind"] = typeSymbol.TypeKind.ToString();
typeMetrics["isAbstract"] = typeSymbol.IsAbstract;
typeMetrics["isSealed"] = typeSymbol.IsSealed;
typeMetrics["isGeneric"] = typeSymbol.IsGenericType;
// Member counts
var members = typeSymbol.GetMembers();
typeMetrics["totalMemberCount"] = members.Length;
typeMetrics["methodCount"] = members.Count(m => m is IMethodSymbol);
typeMetrics["propertyCount"] = members.Count(m => m is IPropertySymbol);
typeMetrics["fieldCount"] = members.Count(m => m is IFieldSymbol);
typeMetrics["eventCount"] = members.Count(m => m is IEventSymbol);
// Inheritance metrics
var baseTypes = new List<string>();
var inheritanceDepth = 0;
var currentType = typeSymbol.BaseType;
while (currentType != null && !currentType.SpecialType.Equals(SpecialType.System_Object)) {
baseTypes.Add(currentType.ToDisplayString());
inheritanceDepth++;
currentType = currentType.BaseType;
}
typeMetrics["inheritanceDepth"] = inheritanceDepth;
typeMetrics["baseTypes"] = baseTypes;
typeMetrics["implementedInterfaces"] = typeSymbol.AllInterfaces.Select(i => i.ToDisplayString()).ToList();
// Analyze methods
var methodMetrics = new List<Dictionary<string, object>>();
var methodComplexitySum = 0;
var methodCount = 0;
foreach (var member in members.OfType<IMethodSymbol>()) {
if (member.IsImplicitlyDeclared) continue;
var methodDict = new Dictionary<string, object>();
await AnalyzeMethodAsync(member, methodDict, recommendations, cancellationToken);
if (methodDict.ContainsKey("cyclomaticComplexity")) {
methodComplexitySum += (int)methodDict["cyclomaticComplexity"];
methodCount++;
}
methodMetrics.Add(methodDict);
}
typeMetrics["methods"] = methodMetrics;
typeMetrics["averageMethodComplexity"] = methodCount > 0 ? (double)methodComplexitySum / methodCount : 0;
// Coupling analysis
var dependencies = new HashSet<string>();
var syntaxRefs = typeSymbol.DeclaringSyntaxReferences;
// Check if solution is available before using it
if (_solutionManager.CurrentSolution != null) {
foreach (var syntaxRef in syntaxRefs) {
var syntax = await syntaxRef.GetSyntaxAsync(cancellationToken);
var project = syntax.SyntaxTree.GetRequiredProject(_solutionManager.CurrentSolution);
var compilation = await _solutionManager.GetCompilationAsync(project.Id, cancellationToken);
if (compilation != null) {
var semanticModel = compilation.GetSemanticModel(syntax.SyntaxTree);
// Find all type references in the class
foreach (var node in syntax.DescendantNodes()) {
if (cancellationToken.IsCancellationRequested) break; var symbolInfo = semanticModel.GetSymbolInfo(node).Symbol;
if (symbolInfo?.ContainingType != null &&
!SymbolEqualityComparer.Default.Equals(symbolInfo.ContainingType, typeSymbol) &&
!symbolInfo.ContainingType.SpecialType.Equals(SpecialType.System_Object)) {
dependencies.Add(symbolInfo.ContainingType.ToDisplayString());
}
}
}
}
} else {
_logger.LogWarning("Cannot analyze type dependencies: No solution loaded");
}
typeMetrics["dependencyCount"] = dependencies.Count;
typeMetrics["dependencies"] = dependencies.ToList();
// Add type-level recommendations
if (inheritanceDepth > 5)
recommendations.Add($"Type '{typeSymbol.Name}' has deep inheritance ({inheritanceDepth} levels). Consider composition over inheritance.");
if (dependencies.Count > 20)
recommendations.Add($"Type '{typeSymbol.Name}' has high coupling ({dependencies.Count} dependencies). Consider breaking it into smaller classes.");
if (members.Length > 50)
recommendations.Add($"Type '{typeSymbol.Name}' has {members.Length} members. Consider breaking it into smaller, focused classes.");
if (typeMetrics["averageMethodComplexity"] is double avg && avg > 12)
recommendations.Add($"Type '{typeSymbol.Name}' has high average method complexity ({avg:F1}). Consider refactoring complex methods.");
metrics["typeMetrics"] = typeMetrics;
}
public async Task AnalyzeProjectAsync(
Project project,
Dictionary<string, object> metrics,
List<string> recommendations,
bool includeGeneratedCode,
CancellationToken cancellationToken) {
var projectMetrics = new Dictionary<string, object>();
var typeMetrics = new List<Dictionary<string, object>>();
// Project-wide metrics
var compilation = await project.GetCompilationAsync(cancellationToken);
if (compilation == null) {
throw new McpException($"Could not get compilation for project {project.Name}");
}
var syntaxTrees = compilation.SyntaxTrees;
if (!includeGeneratedCode) {
syntaxTrees = syntaxTrees.Where(tree =>
!tree.FilePath.Contains(".g.cs") &&
!tree.FilePath.Contains(".Designer.cs"));
}
projectMetrics["fileCount"] = syntaxTrees.Count();
// Calculate total lines manually to avoid async enumeration complexity
var totalLines = 0;
foreach (var tree in syntaxTrees) {
if (cancellationToken.IsCancellationRequested) break;
var text = await tree.GetTextAsync(cancellationToken);
totalLines += text.Lines.Count;
}
projectMetrics["totalLines"] = totalLines;
var globalComplexityMetrics = new Dictionary<string, object> {
["totalCyclomaticComplexity"] = 0,
["totalCognitiveComplexity"] = 0,
["maxMethodComplexity"] = 0,
["complexMethodCount"] = 0,
["averageMethodComplexity"] = 0.0,
["methodCount"] = 0
};
foreach (var tree in syntaxTrees) {
if (cancellationToken.IsCancellationRequested) break;
var semanticModel = compilation.GetSemanticModel(tree);
var root = await tree.GetRootAsync(cancellationToken);
// Analyze each type in the file
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>()) {
var typeSymbol = semanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
if (typeSymbol != null) {
var typeDict = new Dictionary<string, object>();
await AnalyzeTypeAsync(typeSymbol, typeDict, recommendations, includeGeneratedCode, cancellationToken);
typeMetrics.Add(typeDict);
// Aggregate complexity metrics
if (typeDict.TryGetValue("typeMetrics", out var typeMetricsObj) &&
typeMetricsObj is Dictionary<string, object> tm &&
tm.TryGetValue("methods", out var methodsObj) &&
methodsObj is List<Dictionary<string, object>> methods) {
foreach (var method in methods) {
if (method.TryGetValue("cyclomaticComplexity", out var ccObj) &&
ccObj is int cc) {
globalComplexityMetrics["totalCyclomaticComplexity"] =
(int)globalComplexityMetrics["totalCyclomaticComplexity"] + cc;
globalComplexityMetrics["maxMethodComplexity"] =
Math.Max((int)globalComplexityMetrics["maxMethodComplexity"], cc);
if (cc > 10)
globalComplexityMetrics["complexMethodCount"] =
(int)globalComplexityMetrics["complexMethodCount"] + 1;
globalComplexityMetrics["methodCount"] =
(int)globalComplexityMetrics["methodCount"] + 1;
}
if (method.TryGetValue("cognitiveComplexity", out var cogObj) &&
cogObj is int cog) {
globalComplexityMetrics["totalCognitiveComplexity"] =
(int)globalComplexityMetrics["totalCognitiveComplexity"] + cog;
}
}
}
}
}
}
// Calculate averages
if ((int)globalComplexityMetrics["methodCount"] > 0) {
globalComplexityMetrics["averageMethodComplexity"] =
(double)(int)globalComplexityMetrics["totalCyclomaticComplexity"] /
(int)globalComplexityMetrics["methodCount"];
}
projectMetrics["complexityMetrics"] = globalComplexityMetrics;
projectMetrics["typeMetrics"] = typeMetrics;
// Project-wide recommendations
var avgComplexity = (double)globalComplexityMetrics["averageMethodComplexity"];
var complexMethodCount = (int)globalComplexityMetrics["complexMethodCount"];
if (avgComplexity > 5)
recommendations.Add($"Project has high average method complexity ({avgComplexity:F1}). Consider refactoring complex methods.");
if (complexMethodCount > 0)
recommendations.Add($"Project has {complexMethodCount} methods with high cyclomatic complexity (>10). Consider refactoring these methods.");
var totalTypes = typeMetrics.Count;
if (totalTypes > 50)
recommendations.Add($"Project has {totalTypes} types. Consider breaking it into multiple projects if they serve different concerns.");
metrics["projectMetrics"] = projectMetrics;
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/DocumentOperationsService.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.CodeAnalysis.Text;
namespace SharpTools.Tools.Services;
public class DocumentOperationsService : IDocumentOperationsService {
private readonly ISolutionManager _solutionManager;
private readonly ICodeModificationService _modificationService;
private readonly IGitService _gitService;
private readonly ILogger<DocumentOperationsService> _logger;
// Extensions for common code file types that can be formatted
private static readonly HashSet<string> CodeFileExtensions = new(StringComparer.OrdinalIgnoreCase) {
".cs", ".csproj", ".sln", ".css", ".js", ".ts", ".jsx", ".tsx", ".html", ".cshtml", ".razor", ".yml", ".yaml",
".json", ".xml", ".config", ".md", ".fs", ".fsx", ".fsi", ".vb"
};
private static readonly HashSet<string> UnsafeDirectories = new(StringComparer.OrdinalIgnoreCase) {
".git", ".vs", "bin", "obj", "node_modules"
};
public DocumentOperationsService(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
IGitService gitService,
ILogger<DocumentOperationsService> logger) {
_solutionManager = solutionManager;
_modificationService = modificationService;
_gitService = gitService;
_logger = logger;
}
public async Task<(string contents, int lines)> ReadFileAsync(string filePath, bool omitLeadingSpaces, CancellationToken cancellationToken) {
if (!File.Exists(filePath)) {
throw new FileNotFoundException($"File not found: {filePath}");
}
if (!IsPathReadable(filePath)) {
throw new UnauthorizedAccessException($"Reading from this path is not allowed: {filePath}");
}
string content = await File.ReadAllTextAsync(filePath, cancellationToken);
var lines = content.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
if (omitLeadingSpaces) {
for (int i = 0; i < lines.Length; i++) {
lines[i] = TrimLeadingSpaces(lines[i]);
}
content = string.Join(Environment.NewLine, lines);
}
return (content, lines.Length);
}
public async Task<bool> WriteFileAsync(string filePath, string content, bool overwriteIfExists, CancellationToken cancellationToken, string commitMessage) {
var pathInfo = GetPathInfo(filePath);
if (!pathInfo.IsWritable) {
_logger.LogWarning("Path is not writable: {FilePath}. Reason: {Reason}",
filePath, pathInfo.WriteRestrictionReason);
throw new UnauthorizedAccessException($"Writing to this path is not allowed: {filePath}. {pathInfo.WriteRestrictionReason}");
}
if (File.Exists(filePath) && !overwriteIfExists) {
_logger.LogWarning("File already exists and overwrite not allowed: {FilePath}", filePath);
return false;
}
// Ensure directory exists
string? directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
// Write the content to the file
await File.WriteAllTextAsync(filePath, content, cancellationToken);
_logger.LogInformation("File {Operation} at {FilePath}",
File.Exists(filePath) ? "overwritten" : "created", filePath);
// Find the most appropriate project for this file path
var bestProject = FindMostAppropriateProject(filePath);
if (!pathInfo.IsFormattable || bestProject is null || string.IsNullOrWhiteSpace(bestProject.FilePath)) {
_logger.LogWarning("Added non-code file: {FilePath}", filePath);
if (string.IsNullOrEmpty(commitMessage)) {
return true; // No commit message provided, don't commit, just return
}
//just commit the file
await ProcessGitOperationsAsync([filePath], cancellationToken, commitMessage);
return true;
}
Project? legacyProject = null;
bool isSdkStyleProject = await IsSDKStyleProjectAsync(bestProject.FilePath, cancellationToken);
if (isSdkStyleProject) {
_logger.LogInformation("File added to SDK-style project: {ProjectPath}. Reloading Solution to pick up changes.", bestProject.FilePath);
await _solutionManager.ReloadSolutionFromDiskAsync(cancellationToken);
} else {
legacyProject = await TryAddFileToLegacyProjectAsync(filePath, bestProject, cancellationToken);
}
var newSolution = legacyProject?.Solution ?? _solutionManager.CurrentSolution;
var documentId = newSolution?.GetDocumentIdsWithFilePath(filePath).FirstOrDefault();
if (documentId is null) {
_logger.LogWarning("Mystery file was not added to any project: {FilePath}", filePath);
return false;
}
var document = newSolution?.GetDocument(documentId);
if (document is null) {
_logger.LogWarning("Document not found in solution: {FilePath}", filePath);
return false;
}
// If it's a code file, try to format it, which will also commit it
if (await TryFormatAndCommitFileAsync(document, cancellationToken, commitMessage)) {
_logger.LogInformation("File formatted and committed: {FilePath}", filePath);
return true;
} else {
_logger.LogWarning("Failed to format file: {FilePath}", filePath);
}
return true;
}
private async Task<Project?> TryAddFileToLegacyProjectAsync(string filePath, Project project, CancellationToken cancellationToken) {
if (!_solutionManager.IsSolutionLoaded || !File.Exists(filePath)) {
return null;
}
try {
// Get the document ID if the file is already in the solution
var documentId = _solutionManager.CurrentSolution!.GetDocumentIdsWithFilePath(filePath).FirstOrDefault();
// If the document is already in the solution, no need to add it again
if (documentId != null) {
_logger.LogInformation("File is already part of project: {FilePath}", filePath);
return null;
}
// The file exists on disk but is not part of the project yet - add it to the solution in memory
var fileName = Path.GetFileName(filePath);
// Determine appropriate folder path relative to the project
var projectDir = Path.GetDirectoryName(project.FilePath);
var relativePath = string.Empty;
var folders = Array.Empty<string>();
if (!string.IsNullOrEmpty(projectDir)) {
relativePath = Path.GetRelativePath(projectDir, filePath);
var folderPath = Path.GetDirectoryName(relativePath);
if (!string.IsNullOrEmpty(folderPath) && folderPath != ".") {
folders = folderPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}
_logger.LogInformation("Adding file to {ProjectName}: {FilePath}", project.Name, filePath);
// Create SourceText from file content
var fileContent = await File.ReadAllTextAsync(filePath, cancellationToken);
var sourceText = SourceText.From(fileContent);
// Add the document to the project in memory
return project.AddDocument(fileName, sourceText, folders, filePath).Project;
} catch (Exception ex) {
_logger.LogError(ex, "Failed to add file {FilePath} to project", filePath);
return null;
}
}
private async Task<bool> IsSDKStyleProjectAsync(string projectFilePath, CancellationToken cancellationToken) {
try {
var content = await File.ReadAllTextAsync(projectFilePath, cancellationToken);
// Use XmlDocument for proper parsing
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(content);
var projectNode = xmlDoc.DocumentElement;
// Primary check - Look for Sdk attribute on Project element
if (projectNode?.Attributes?["Sdk"] != null) {
_logger.LogDebug("Project {ProjectPath} is SDK-style (has Sdk attribute)", projectFilePath);
return true;
}
// Secondary check - Look for TargetFramework instead of TargetFrameworkVersion
var targetFrameworkNode = xmlDoc.SelectSingleNode("//TargetFramework");
if (targetFrameworkNode != null) {
_logger.LogDebug("Project {ProjectPath} is SDK-style (uses TargetFramework)", projectFilePath);
return true;
}
_logger.LogDebug("Project {ProjectPath} is classic-style (no SDK indicators found)", projectFilePath);
return false;
} catch (Exception ex) {
_logger.LogWarning(ex, "Error determining project style for {ProjectPath}, assuming classic format", projectFilePath);
return false;
}
}
private Microsoft.CodeAnalysis.Project? FindMostAppropriateProject(string filePath) {
if (!_solutionManager.IsSolutionLoaded) {
return null;
}
var projects = _solutionManager.GetProjects().ToList();
if (!projects.Any()) {
return null;
}
// Find projects where the file path is under the project directory
var projectsWithPath = new List<(Microsoft.CodeAnalysis.Project Project, int DirectoryLevel)>();
foreach (var project in projects) {
if (string.IsNullOrEmpty(project.FilePath)) {
continue;
}
var projectDir = Path.GetDirectoryName(project.FilePath);
if (string.IsNullOrEmpty(projectDir)) {
continue;
}
if (filePath.StartsWith(projectDir, StringComparison.OrdinalIgnoreCase)) {
// Calculate how many directories deep this file is from the project root
var relativePath = filePath.Substring(projectDir.Length).TrimStart(Path.DirectorySeparatorChar);
var directoryLevel = relativePath.Count(c => c == Path.DirectorySeparatorChar);
projectsWithPath.Add((project, directoryLevel));
}
}
// Return the project where the file is closest to the root
// (smallest directory level means closer to project root)
return projectsWithPath.OrderBy(p => p.DirectoryLevel).FirstOrDefault().Project;
}
public bool FileExists(string filePath) {
return File.Exists(filePath);
}
public bool IsPathReadable(string filePath) {
var pathInfo = GetPathInfo(filePath);
return pathInfo.IsReadable;
}
public bool IsPathWritable(string filePath) {
var pathInfo = GetPathInfo(filePath);
return pathInfo.IsWritable;
}
public bool IsCodeFile(string filePath) {
if (string.IsNullOrEmpty(filePath)) {
return false;
}
// First check if file exists but is not part of the solution
if (File.Exists(filePath) && !IsReferencedBySolution(filePath)) {
return false;
}
// Check by extension
var extension = Path.GetExtension(filePath);
return !string.IsNullOrEmpty(extension) && CodeFileExtensions.Contains(extension);
}
public PathInfo GetPathInfo(string filePath) {
if (string.IsNullOrEmpty(filePath)) {
return new PathInfo {
FilePath = filePath,
Exists = false,
IsWithinSolutionDirectory = false,
IsReferencedBySolution = false,
IsFormattable = false,
WriteRestrictionReason = "Path is empty or null"
};
}
bool exists = File.Exists(filePath);
bool isWithinSolution = IsPathWithinSolutionDirectory(filePath);
bool isReferenced = IsReferencedBySolution(filePath);
bool isFormattable = IsCodeFile(filePath);
string? projectId = FindMostAppropriateProject(filePath)?.Id.Id.ToString();
string? writeRestrictionReason = null;
// Check for unsafe directories
if (ContainsUnsafeDirectory(filePath)) {
writeRestrictionReason = "Path contains a protected directory (bin, obj, .git, etc.)";
}
// Check if file is outside solution
if (!isWithinSolution) {
writeRestrictionReason = "Path is outside the solution directory";
}
// Check if directory is read-only
try {
var directoryPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directoryPath) && Directory.Exists(directoryPath)) {
var dirInfo = new DirectoryInfo(directoryPath);
if ((dirInfo.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) {
writeRestrictionReason = "Directory is read-only";
}
}
} catch {
writeRestrictionReason = "Cannot determine directory permissions";
}
return new PathInfo {
FilePath = filePath,
Exists = exists,
IsWithinSolutionDirectory = isWithinSolution,
IsReferencedBySolution = isReferenced,
IsFormattable = isFormattable,
ProjectId = projectId,
WriteRestrictionReason = writeRestrictionReason
};
}
private bool IsPathWithinSolutionDirectory(string filePath) {
if (!_solutionManager.IsSolutionLoaded) {
return false;
}
string? solutionDirectory = Path.GetDirectoryName(_solutionManager.CurrentSolution?.FilePath);
if (string.IsNullOrEmpty(solutionDirectory)) {
return false;
}
return filePath.StartsWith(solutionDirectory, StringComparison.OrdinalIgnoreCase);
}
private bool IsReferencedBySolution(string filePath) {
if (!_solutionManager.IsSolutionLoaded || !File.Exists(filePath)) {
return false;
}
// Check if the file is directly referenced by a document in the solution
if (_solutionManager.CurrentSolution!.GetDocumentIdsWithFilePath(filePath).Any()) {
return true;
}
// TODO: Implement proper reference checking for assemblies, resources, etc.
// This would require deeper MSBuild integration
return false;
}
private bool ContainsUnsafeDirectory(string filePath) {
// Check if the path contains any unsafe directory segments
var normalizedPath = filePath.Replace('\\', '/');
var pathSegments = normalizedPath.Split('/');
return pathSegments.Any(segment => UnsafeDirectories.Contains(segment));
}
private async Task<bool> TryFormatAndCommitFileAsync(Document document, CancellationToken cancellationToken, string commitMessage) {
try {
var formattedDocument = await _modificationService.FormatDocumentAsync(document, cancellationToken);
// Apply the formatting changes with the commit message
var newSolution = formattedDocument.Project.Solution;
await _modificationService.ApplyChangesAsync(newSolution, cancellationToken, commitMessage);
_logger.LogInformation("Document {FilePath} formatted successfully", document.FilePath);
return true;
} catch (Exception ex) {
_logger.LogWarning(ex, "Failed to format file {FilePath}", document.FilePath);
return false;
}
}
private static string TrimLeadingSpaces(string line) {
int i = 0;
while (i < line.Length && char.IsWhiteSpace(line[i])) {
i++;
}
return i > 0 ? line.Substring(i) : line;
}
public async Task ProcessGitOperationsAsync(IEnumerable<string> filePaths, CancellationToken cancellationToken, string commitMessage) {
var filesList = filePaths.Where(f => !string.IsNullOrEmpty(f) && File.Exists(f)).ToList();
if (!filesList.Any()) {
return;
}
try {
// Get solution path
var solutionPath = _solutionManager.CurrentSolution?.FilePath;
if (string.IsNullOrEmpty(solutionPath)) {
_logger.LogDebug("Solution path is not available, skipping Git operations");
return;
}
// Check if solution is in a git repo
if (!await _gitService.IsRepositoryAsync(solutionPath, cancellationToken)) {
_logger.LogDebug("Solution is not in a Git repository, skipping Git operations");
return;
}
_logger.LogDebug("Solution is in a Git repository, processing Git operations for {Count} files", filesList.Count);
// Check if already on sharptools branch
if (!await _gitService.IsOnSharpToolsBranchAsync(solutionPath, cancellationToken)) {
_logger.LogInformation("Not on a SharpTools branch, creating one");
await _gitService.EnsureSharpToolsBranchAsync(solutionPath, cancellationToken);
}
// Commit changes with the provided commit message
await _gitService.CommitChangesAsync(solutionPath, filesList, commitMessage, cancellationToken);
_logger.LogInformation("Git operations completed successfully for {Count} files with commit message: {CommitMessage}", filesList.Count, commitMessage);
} catch (Exception ex) {
// Log but don't fail the operation if Git operations fail
_logger.LogWarning(ex, "Git operations failed for {Count} files but file operations were still applied", filesList.Count);
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/ToolHelpers.cs:
--------------------------------------------------------------------------------
```csharp
using System.Text.Encodings.Web;
using System.Text.Json.Serialization;
using ModelContextProtocol;
using SharpTools.Tools.Services;
namespace SharpTools.Tools.Mcp;
internal static class ToolHelpers {
public const string SharpToolPrefix = "SharpTool_";
public static void EnsureSolutionLoaded(ISolutionManager solutionManager) {
if (!solutionManager.IsSolutionLoaded) {
throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' first.");
}
}
/// <summary>
/// Safely ensures that a solution is loaded, with detailed error information.
/// </summary>
public static void EnsureSolutionLoadedWithDetails(ISolutionManager solutionManager, ILogger logger, string operationName) {
if (!solutionManager.IsSolutionLoaded) {
logger.LogError("Attempted to execute {Operation} without a loaded solution", operationName);
throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' before calling '{operationName}'.");
}
}
private const string FqnHelpMessage = $" Try `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.SearchDefinitions)}`, `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.GetMembers)}`, or `{ToolHelpers.SharpToolPrefix}{nameof(Tools.DocumentTools.ReadTypesFromRoslynDocument)}` to find what you need.";
public static async Task<ISymbol> GetRoslynSymbolOrThrowAsync(
ISolutionManager solutionManager,
string fullyQualifiedSymbolName,
CancellationToken cancellationToken) {
try {
var symbol = await solutionManager.FindRoslynSymbolAsync(fullyQualifiedSymbolName, cancellationToken);
return symbol ?? throw new McpException($"Roslyn symbol '{fullyQualifiedSymbolName}' not found in the current solution." + FqnHelpMessage);
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) when (!(ex is McpException)) {
throw new McpException($"Error finding Roslyn symbol '{fullyQualifiedSymbolName}': {ex.Message}");
}
}
public static async Task<INamedTypeSymbol> GetRoslynNamedTypeSymbolOrThrowAsync(
ISolutionManager solutionManager,
string fullyQualifiedTypeName,
CancellationToken cancellationToken) {
try {
var symbol = await solutionManager.FindRoslynNamedTypeSymbolAsync(fullyQualifiedTypeName, cancellationToken);
return symbol ?? throw new McpException($"Roslyn named type symbol '{fullyQualifiedTypeName}' not found in the current solution." + FqnHelpMessage);
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) when (!(ex is McpException)) {
throw new McpException($"Error finding Roslyn named type symbol '{fullyQualifiedTypeName}': {ex.Message}");
}
}
public static async Task<Type> GetReflectionTypeOrThrowAsync(
ISolutionManager solutionManager,
string fullyQualifiedTypeName,
CancellationToken cancellationToken) {
try {
var type = await solutionManager.FindReflectionTypeAsync(fullyQualifiedTypeName, cancellationToken);
return type ?? throw new McpException($"Reflection type '{fullyQualifiedTypeName}' not found in loaded assemblies." + FqnHelpMessage);
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) when (!(ex is McpException)) {
throw new McpException($"Error finding reflection type '{fullyQualifiedTypeName}': {ex.Message}");
}
}
public static Document GetDocumentFromSyntaxNodeOrThrow(Solution solution, SyntaxNode node) {
try {
var document = solution.GetDocument(node.SyntaxTree);
return document ?? throw new McpException("Could not find document for the given syntax node.");
} catch (Exception ex) when (!(ex is McpException)) {
throw new McpException($"Error finding document for syntax node: {ex.Message}");
}
}
public static string ToJson(object? data) {
return JsonSerializer.Serialize(data, new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
private static string RoslynAccessibilityToString(Accessibility accessibility) {
return accessibility switch {
Accessibility.Private => "private",
Accessibility.ProtectedAndInternal => "private protected",
Accessibility.Protected => "protected",
Accessibility.Internal => "internal",
Accessibility.ProtectedOrInternal => "protected internal",
Accessibility.Public => "public",
_ => "" // NotApplicable or others
};
}
public static string GetRoslynSymbolModifiersString(ISymbol symbol) {
var parts = new List<string>();
string accessibility = RoslynAccessibilityToString(symbol.DeclaredAccessibility);
if (!string.IsNullOrEmpty(accessibility)) {
parts.Add(accessibility);
}
if (symbol.IsStatic) parts.Add("static");
if (symbol.IsAbstract && symbol.Kind != SymbolKind.NamedType) parts.Add("abstract"); // Type abstract handled by TypeKind
if (symbol.IsSealed && symbol.Kind != SymbolKind.NamedType) parts.Add("sealed"); // Type sealed handled by TypeKind
if (symbol.IsVirtual) parts.Add("virtual");
if (symbol.IsOverride) parts.Add("override");
if (symbol.IsExtern) parts.Add("extern");
switch (symbol) {
case IMethodSymbol methodSymbol:
if (methodSymbol.IsAsync) parts.Add("async");
break;
case IFieldSymbol fieldSymbol:
if (fieldSymbol.IsReadOnly) parts.Add("readonly");
if (fieldSymbol.IsConst) parts.Add("const"); // Though 'const' implies static
break;
case IPropertySymbol propertySymbol:
if (propertySymbol.IsReadOnly) parts.Add("readonly"); // Getter only, or init-only setter
break;
case INamedTypeSymbol typeSymbol: // For types, abstract/sealed are part of their kind
if (typeSymbol.IsReadOnly) parts.Add("readonly"); // readonly struct/ref struct
if (typeSymbol.IsRefLikeType) parts.Add("ref");
// For static classes, IsStatic is true. For abstract/sealed, TypeKind reflects it.
break;
}
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
}
public static string GetRoslynTypeSpecificModifiersString(INamedTypeSymbol typeSymbol) {
var parts = new List<string>();
string accessibility = RoslynAccessibilityToString(typeSymbol.DeclaredAccessibility);
if (!string.IsNullOrEmpty(accessibility)) {
parts.Add(accessibility);
}
if (typeSymbol.IsStatic) { // Covers static classes
parts.Add("static");
} else { // Abstract and Sealed are mutually exclusive with static class modifier
if (typeSymbol.IsAbstract) parts.Add("abstract");
if (typeSymbol.IsSealed) parts.Add("sealed");
}
if (typeSymbol.IsReadOnly) parts.Add("readonly"); // readonly struct/ref struct
if (typeSymbol.IsRefLikeType) parts.Add("ref"); // ref struct
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
}
private static string ReflectionAccessibilityToString(MethodBase? member) {
if (member == null) return "";
if (member.IsPublic) return "public";
if (member.IsPrivate) return "private";
if (member.IsFamilyAndAssembly) return "private protected";
if (member.IsFamilyOrAssembly) return "protected internal";
if (member.IsFamily) return "protected";
if (member.IsAssembly) return "internal";
return "";
}
private static string ReflectionAccessibilityToString(FieldInfo? member) {
if (member == null) return "";
if (member.IsPublic) return "public";
if (member.IsPrivate) return "private";
if (member.IsFamilyAndAssembly) return "private protected";
if (member.IsFamilyOrAssembly) return "protected internal";
if (member.IsFamily) return "protected";
if (member.IsAssembly) return "internal";
return "";
}
private static string ReflectionAccessibilityToString(Type type) {
if (type.IsPublic || type.IsNestedPublic) return "public";
if (type.IsNestedPrivate) return "private";
if (type.IsNestedFamANDAssem) return "private protected";
if (type.IsNestedFamORAssem) return "protected internal";
if (type.IsNestedFamily) return "protected";
if (type.IsNestedAssembly) return "internal";
if (!type.IsNested) return "internal"; // Top-level non-public is internal by default
return "";
}
public static string GetReflectionMemberModifiersString(MemberInfo memberInfo) {
var parts = new List<string>();
string accessibility = memberInfo switch {
MethodBase mb => ReflectionAccessibilityToString(mb),
FieldInfo fi => ReflectionAccessibilityToString(fi),
PropertyInfo pi => ReflectionAccessibilityToString(pi.GetAccessors(true).FirstOrDefault()),
EventInfo ei => ReflectionAccessibilityToString(ei.GetAddMethod(true)),
Type ti => ReflectionAccessibilityToString(ti), // For nested types
_ => ""
};
if (!string.IsNullOrEmpty(accessibility)) parts.Add(accessibility);
switch (memberInfo) {
case MethodInfo mi:
if (mi.IsStatic) parts.Add("static");
if (mi.IsAbstract) parts.Add("abstract");
if (mi.IsVirtual && !mi.IsFinal && !mi.IsAbstract) parts.Add("virtual");
if (mi.IsVirtual && mi.IsFinal) parts.Add("sealed override"); // Or just "sealed" if not overriding
else {
// MetadataLoadContext doesn't support GetBaseDefinition()
try {
if (mi.GetBaseDefinition() != mi && !mi.IsVirtual) parts.Add("override"); // Non-virtual override (interface implementation)
else if (mi.GetBaseDefinition() != mi) parts.Add("override");
} catch (NotSupportedException) {
// For MetadataLoadContext, we can't check GetBaseDefinition
// Infer override status from best available information
if (mi.IsVirtual && !mi.IsAbstract) {
parts.Add("override");
}
}
}
try {
if (mi.IsDefined(typeof(AsyncStateMachineAttribute), false)) parts.Add("async");
} catch (NotSupportedException) {
// MetadataLoadContext doesn't support IsDefined
// We can't check for async state machine attribute
}
if ((mi.MethodImplementationFlags & MethodImplAttributes.InternalCall) != 0 ||
(mi.MethodImplementationFlags & MethodImplAttributes.Native) != 0) parts.Add("extern");
break;
case ConstructorInfo ci: // Constructors have accessibility and can be static (type initializers)
if (ci.IsStatic) parts.Add("static");
break;
case FieldInfo fi:
if (fi.IsStatic && !fi.IsLiteral) parts.Add("static"); // const fields are implicitly static
if (fi.IsInitOnly) parts.Add("readonly");
if (fi.IsLiteral) parts.Add("const");
break;
case PropertyInfo pi:
var accessor = pi.GetAccessors(true).FirstOrDefault();
if (accessor != null) {
if (accessor.IsStatic) parts.Add("static");
if (accessor.IsAbstract) parts.Add("abstract");
if (accessor.IsVirtual && !accessor.IsFinal && !accessor.IsAbstract) parts.Add("virtual");
if (accessor.IsVirtual && accessor.IsFinal) parts.Add("sealed override");
else {
// MetadataLoadContext doesn't support GetBaseDefinition()
try {
if (accessor.GetBaseDefinition() != accessor && !accessor.IsVirtual) parts.Add("override");
else if (accessor.GetBaseDefinition() != accessor) parts.Add("override");
} catch (NotSupportedException) {
// For MetadataLoadContext, we can't check GetBaseDefinition
// Infer override status from best available information
if (accessor.IsVirtual && !accessor.IsAbstract) {
parts.Add("override");
}
}
}
}
if (!pi.CanWrite) parts.Add("readonly");
break;
case EventInfo ei:
var addAccessor = ei.GetAddMethod(true);
if (addAccessor != null) {
if (addAccessor.IsStatic) parts.Add("static");
if (addAccessor.IsAbstract) parts.Add("abstract");
if (addAccessor.IsVirtual && !addAccessor.IsFinal && !addAccessor.IsAbstract) parts.Add("virtual");
if (addAccessor.IsVirtual && addAccessor.IsFinal) parts.Add("sealed override");
else {
// MetadataLoadContext doesn't support GetBaseDefinition()
try {
if (addAccessor.GetBaseDefinition() != addAccessor && !addAccessor.IsVirtual) parts.Add("override");
else if (addAccessor.GetBaseDefinition() != addAccessor) parts.Add("override");
} catch (NotSupportedException) {
// For MetadataLoadContext, we can't check GetBaseDefinition
// Infer override status from best available information
if (addAccessor.IsVirtual && !addAccessor.IsAbstract) {
parts.Add("override");
}
}
}
}
break;
case Type nestedType: // Modifiers for the nested type itself
return GetReflectionTypeModifiersString(nestedType);
}
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)).Distinct());
}
public static string GetReflectionTypeModifiersString(Type type) {
var parts = new List<string>();
string accessibility = ReflectionAccessibilityToString(type);
if (!string.IsNullOrEmpty(accessibility)) parts.Add(accessibility);
if (type.IsAbstract && type.IsSealed) { // Static class
parts.Add("static");
} else {
if (type.IsAbstract) parts.Add("abstract");
if (type.IsSealed) parts.Add("sealed");
}
if (type.IsValueType && type.IsDefined(typeof(IsReadOnlyAttribute), false)) parts.Add("readonly"); // readonly struct
if (type.IsByRefLike) parts.Add("ref"); // ref struct
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)).Distinct());
}
public static string GetSymbolKindString(ISymbol symbol) {
return symbol.Kind switch {
SymbolKind.Namespace => "Namespace",
SymbolKind.NamedType => ((INamedTypeSymbol)symbol).TypeKind.ToString(),
SymbolKind.Method => "Method",
SymbolKind.Property => "Property",
SymbolKind.Event => "Event",
SymbolKind.Field => "Field",
SymbolKind.Parameter => "Parameter",
SymbolKind.TypeParameter => "TypeParameter",
SymbolKind.Local => "LocalVariable",
_ => symbol.Kind.ToString()
};
}
public static string GetReflectionTypeKindString(Type type) {
if (type.IsEnum) return "Enum";
if (type.IsInterface) return "Interface";
if (type.IsValueType && !type.IsPrimitive && !type.IsEnum && !type.FullName!.StartsWith("System.Nullable")) return "Struct";
if (type.IsClass) return "Class";
if (typeof(Delegate).IsAssignableFrom(type)) return "Delegate";
return type.IsValueType ? "ValueType" : "Type";
}
public static string GetReflectionMemberTypeKindString(MemberInfo memberInfo) {
return memberInfo.MemberType switch {
MemberTypes.Constructor => "Constructor",
MemberTypes.Event => "Event",
MemberTypes.Field => "Field",
MemberTypes.Method => "Method",
MemberTypes.Property => "Property",
MemberTypes.TypeInfo or MemberTypes.NestedType when memberInfo is Type t => GetReflectionTypeKindString(t),
_ => memberInfo.MemberType.ToString()
};
}
/// <summary>
/// Returns a SymbolDisplayFormat that produces fully qualified names without the global:: prefix
/// </summary>
public static SymbolDisplayFormat FullyQualifiedFormatWithoutGlobal => new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeContainingType,
parameterOptions: SymbolDisplayParameterOptions.None,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
/// <summary>
/// Removes global:: prefix from a fully qualified name
/// </summary>
public static string RemoveGlobalPrefix(string fullyQualifiedName) {
if (string.IsNullOrEmpty(fullyQualifiedName)) {
return fullyQualifiedName;
}
return fullyQualifiedName.StartsWith("global::", StringComparison.Ordinal)
? fullyQualifiedName.Substring(8)
: fullyQualifiedName;
}
public static bool IsPropertyAccessor(ISymbol symbol) {
if (symbol is IMethodSymbol methodSymbol) {
var associatedSymbol = methodSymbol.AssociatedSymbol;
return associatedSymbol is IPropertySymbol; // True for both getters and setters
}
return false;
}
public static string TrimBackslash(this string str) {
if (str.StartsWith("\\", StringComparison.Ordinal)) {
return str[1..];
}
return str;
}
public static string NormalizeEndOfLines(this string str) {
return str.Replace("\r\n", "\n").Replace("\r", "\n");
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Services/CodeAnalysisService.cs:
--------------------------------------------------------------------------------
```csharp
namespace SharpTools.Tools.Services;
public class CodeAnalysisService : ICodeAnalysisService {
private readonly ISolutionManager _solutionManager;
private readonly ILogger<CodeAnalysisService> _logger;
public CodeAnalysisService(ISolutionManager solutionManager, ILogger<CodeAnalysisService> logger) {
_solutionManager = solutionManager ?? throw new ArgumentNullException(nameof(solutionManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private Solution GetCurrentSolutionOrThrow() {
if (!_solutionManager.IsSolutionLoaded) {
throw new InvalidOperationException("No solution is currently loaded.");
}
return _solutionManager.CurrentSolution;
}
public async Task<IEnumerable<ISymbol>> FindImplementationsAsync(ISymbol symbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding implementations for symbol: {SymbolName}", symbol.Name);
return await SymbolFinder.FindImplementationsAsync(symbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<ISymbol>> FindOverridesAsync(ISymbol symbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding overrides for symbol: {SymbolName}", symbol.Name);
return await SymbolFinder.FindOverridesAsync(symbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<ReferencedSymbol>> FindReferencesAsync(ISymbol symbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding references for symbol: {SymbolName}", symbol.Name);
return await SymbolFinder.FindReferencesAsync(symbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<INamedTypeSymbol>> FindDerivedClassesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding derived classes for type: {TypeName}", typeSymbol.Name);
return await SymbolFinder.FindDerivedClassesAsync(typeSymbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<INamedTypeSymbol>> FindDerivedInterfacesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding derived interfaces for type: {TypeName}", typeSymbol.Name);
return await SymbolFinder.FindDerivedInterfacesAsync(typeSymbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<SymbolCallerInfo>> FindCallersAsync(ISymbol symbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
_logger.LogDebug("Finding callers for symbol: {SymbolName}", symbol.Name);
return await SymbolFinder.FindCallersAsync(symbol, solution, cancellationToken: cancellationToken);
}
public async Task<IEnumerable<ISymbol>> FindOutgoingCallsAsync(IMethodSymbol methodSymbol, CancellationToken cancellationToken) {
_logger.LogDebug("Finding outgoing calls for method: {MethodName}", methodSymbol.Name);
var outgoingCalls = new List<ISymbol>();
if (!methodSymbol.DeclaringSyntaxReferences.Any()) {
_logger.LogWarning("Method {MethodName} has no declaring syntax references, cannot find outgoing calls.", methodSymbol.Name);
return outgoingCalls;
}
var currentSolution = GetCurrentSolutionOrThrow();
foreach (var syntaxRef in methodSymbol.DeclaringSyntaxReferences) {
var methodNode = await syntaxRef.GetSyntaxAsync(cancellationToken) as MethodDeclarationSyntax;
if (methodNode?.Body == null && methodNode?.ExpressionBody == null) {
continue;
}
var document = currentSolution.GetDocument(syntaxRef.SyntaxTree);
if (document == null) {
_logger.LogWarning("Could not get document for syntax tree {FilePath} of method {MethodName}", syntaxRef.SyntaxTree.FilePath, methodSymbol.Name);
continue;
}
var semanticModel = await _solutionManager.GetSemanticModelAsync(document.Id, cancellationToken);
if (semanticModel == null) {
_logger.LogWarning("Could not get semantic model for method {MethodName} in document {DocumentPath}", methodSymbol.Name, document.FilePath);
continue;
}
var walker = new InvocationWalker(semanticModel, cancellationToken);
walker.Visit(methodNode);
outgoingCalls.AddRange(walker.CalledSymbols);
}
return outgoingCalls.Distinct(SymbolEqualityComparer.Default).ToList();
}
public static string GetFormattedSignatureAsync(ISymbol symbol, bool includeContainingType = true) {
var fullFormat = new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: includeContainingType ? SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces :
SymbolDisplayTypeQualificationStyle.NameOnly,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | SymbolDisplayGenericsOptions.IncludeVariance,
memberOptions: SymbolDisplayMemberOptions.IncludeParameters |
SymbolDisplayMemberOptions.IncludeType |
SymbolDisplayMemberOptions.IncludeRef |
SymbolDisplayMemberOptions.IncludeContainingType,
parameterOptions: SymbolDisplayParameterOptions.IncludeType |
SymbolDisplayParameterOptions.IncludeName |
SymbolDisplayParameterOptions.IncludeParamsRefOut |
SymbolDisplayParameterOptions.IncludeDefaultValue,
propertyStyle: SymbolDisplayPropertyStyle.NameOnly,
localOptions: SymbolDisplayLocalOptions.None,
kindOptions: SymbolDisplayKindOptions.IncludeMemberKeyword | SymbolDisplayKindOptions.IncludeNamespaceKeyword | SymbolDisplayKindOptions.IncludeTypeKeyword,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers |
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
// For parameters and return types, use a format that doesn't qualify types
var shortFormat = new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeParameters |
SymbolDisplayMemberOptions.IncludeType |
SymbolDisplayMemberOptions.IncludeRef,
parameterOptions: SymbolDisplayParameterOptions.IncludeType |
SymbolDisplayParameterOptions.IncludeName |
SymbolDisplayParameterOptions.IncludeParamsRefOut |
SymbolDisplayParameterOptions.IncludeDefaultValue,
propertyStyle: SymbolDisplayPropertyStyle.NameOnly,
localOptions: SymbolDisplayLocalOptions.None,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
if (symbol is IMethodSymbol methodSymbol) {
// Format the method name and containing type with full qualification
var methodParts = methodSymbol.ToDisplayParts(fullFormat);
// Find the indices where return type ends and method name begins
var returnTypeEndIndex = methodParts.TakeWhile(p => p.Kind != SymbolDisplayPartKind.MethodName).Count();
var nameStartIndex = returnTypeEndIndex;
var nameEndIndex = methodParts.TakeWhile(p => p.Kind != SymbolDisplayPartKind.Punctuation || p.ToString() != "(").Count();
// Get the parts we need
var modifiers = methodParts.Take(methodParts.TakeWhile(p => p.Kind == SymbolDisplayPartKind.Keyword).Count());
var returnType = methodSymbol.ReturnType.ToDisplayString(shortFormat);
var nameAndContainingType = string.Concat(methodParts.Skip(nameStartIndex).Take(nameEndIndex - nameStartIndex));
var parameters = string.Join(", ", methodSymbol.Parameters.Select(p => p.ToDisplayString(shortFormat)));
// Combine all parts
var signature = string.Concat(modifiers) + " " + returnType + " " + nameAndContainingType + "(" + parameters + ")";
return signature.Replace(" ", " "); // Clean up any double spaces
}
// For non-method symbols, use the original full format
return symbol.ToDisplayString(fullFormat);
}
public Task<string?> GetXmlDocumentationAsync(ISymbol symbol, CancellationToken cancellationToken) {
var commentXml = symbol.GetDocumentationCommentXml(cancellationToken: cancellationToken);
return Task.FromResult(string.IsNullOrEmpty(commentXml) ? null : commentXml);
}
private class InvocationWalker : CSharpSyntaxWalker {
private readonly SemanticModel _semanticModel;
private readonly CancellationToken _cancellationToken;
private readonly List<ISymbol> _calledSymbols = new();
public IEnumerable<ISymbol> CalledSymbols => _calledSymbols;
public InvocationWalker(SemanticModel semanticModel, CancellationToken cancellationToken) {
_semanticModel = semanticModel;
_cancellationToken = cancellationToken;
}
public override void VisitInvocationExpression(InvocationExpressionSyntax node) {
_cancellationToken.ThrowIfCancellationRequested();
var symbolInfo = _semanticModel.GetSymbolInfo(node.Expression, _cancellationToken);
AddSymbol(symbolInfo.Symbol ?? symbolInfo.CandidateSymbols.FirstOrDefault());
base.VisitInvocationExpression(node);
}
public override void VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) {
_cancellationToken.ThrowIfCancellationRequested();
var symbolInfo = _semanticModel.GetSymbolInfo(node.Type, _cancellationToken);
AddSymbol(symbolInfo.Symbol ?? symbolInfo.CandidateSymbols.FirstOrDefault());
base.VisitObjectCreationExpression(node);
}
private void AddSymbol(ISymbol? symbol) {
if (symbol != null) {
_calledSymbols.Add(symbol);
}
}
}
private static void AddReferencedType(
ITypeSymbol typeSymbol,
INamedTypeSymbol sourceType,
HashSet<string> referencedTypes,
CancellationToken cancellationToken) {
// Skip if it's the same as the source type
if (SymbolEqualityComparer.Default.Equals(typeSymbol, sourceType)) {
return;
}
// Skip anonymous types, type parameters, and error types
if (typeSymbol.IsAnonymousType ||
typeSymbol is ITypeParameterSymbol ||
typeSymbol.TypeKind == TypeKind.Error) {
return;
}
// Skip primitive types and common framework types
if (typeSymbol.SpecialType != SpecialType.None &&
typeSymbol.SpecialType != SpecialType.System_Object &&
typeSymbol.SpecialType != SpecialType.System_ValueType &&
typeSymbol.SpecialType != SpecialType.System_Enum) {
return;
}
// Check if the type is defined in source code (part of the solution)
bool isInSolution = typeSymbol.ContainingAssembly != null &&
typeSymbol.ContainingAssembly.Locations.Any(loc => loc.IsInSource);
// Skip types that are not defined in the solution
if (!isInSolution) {
return;
}
// Add the referenced type
if (typeSymbol is INamedTypeSymbol namedTypeSymbol) {
string typeFqn = FuzzyFqnLookupService.GetSearchableString(namedTypeSymbol);
referencedTypes.Add(typeFqn);
// Add generic type arguments as well
if (namedTypeSymbol.IsGenericType) {
foreach (var typeArg in namedTypeSymbol.TypeArguments) {
if (typeArg is INamedTypeSymbol namedTypeArg) {
AddReferencedType(namedTypeArg, sourceType, referencedTypes, cancellationToken);
}
}
}
}
// Handle array types
else if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType != null) {
AddReferencedType(arrayType.ElementType, sourceType, referencedTypes, cancellationToken);
}
// Handle pointer types
else if (typeSymbol is IPointerTypeSymbol pointerType && pointerType.PointedAtType != null) {
AddReferencedType(pointerType.PointedAtType, sourceType, referencedTypes, cancellationToken);
}
}
public async Task<HashSet<string>> FindReferencedTypesAsync(INamedTypeSymbol typeSymbol, CancellationToken cancellationToken) {
var solution = GetCurrentSolutionOrThrow();
var referencedTypes = new HashSet<string>(StringComparer.Ordinal);
if (typeSymbol == null) {
_logger.LogWarning("Cannot analyze referenced types: Type symbol is null.");
return referencedTypes;
}
try {
// First add the immediate references - base type and interfaces
if (typeSymbol.BaseType != null &&
typeSymbol.BaseType.SpecialType != SpecialType.System_Object) {
AddReferencedType(typeSymbol.BaseType, typeSymbol, referencedTypes, cancellationToken);
}
foreach (var iface in typeSymbol.Interfaces) {
AddReferencedType(iface, typeSymbol, referencedTypes, cancellationToken);
}
// For each member, find all types referenced in its implementation
foreach (var member in typeSymbol.GetMembers()) {
cancellationToken.ThrowIfCancellationRequested();
// Skip members from the base class
if (member.IsImplicitlyDeclared || member.IsOverride) {
continue;
}
// Add the direct type reference for fields and properties
if (member is IFieldSymbol fieldSymbol && fieldSymbol.Type != null) {
AddReferencedType(fieldSymbol.Type, typeSymbol, referencedTypes, cancellationToken);
} else if (member is IPropertySymbol propertySymbol && propertySymbol.Type != null) {
AddReferencedType(propertySymbol.Type, typeSymbol, referencedTypes, cancellationToken);
} else if (member is IMethodSymbol methodSymbol) {
if (methodSymbol.ReturnType != null) {
AddReferencedType(methodSymbol.ReturnType, typeSymbol, referencedTypes, cancellationToken);
}
foreach (var parameter in methodSymbol.Parameters) {
if (parameter.Type != null) {
AddReferencedType(parameter.Type, typeSymbol, referencedTypes, cancellationToken);
}
}
} else if (member is IEventSymbol eventSymbol && eventSymbol.Type != null) {
AddReferencedType(eventSymbol.Type, typeSymbol, referencedTypes, cancellationToken);
}
// Get all referenced symbols from the member's syntax (implementation)
if (member.DeclaringSyntaxReferences.Any()) {
foreach (var syntaxRef in member.DeclaringSyntaxReferences) {
var memberNode = await syntaxRef.GetSyntaxAsync(cancellationToken);
if (memberNode == null) continue;
var document = solution.GetDocument(syntaxRef.SyntaxTree);
if (document == null) continue;
var semanticModel = await document.GetSemanticModelAsync(cancellationToken);
if (semanticModel == null) continue;
// Find all symbols referenced within the member implementation
var descendantNodes = memberNode.DescendantNodes();
foreach (var node in descendantNodes) {
// Skip nested type declarations
if (node is TypeDeclarationSyntax) {
continue;
}
// Get symbol info for expressions, type references, etc.
var symbolInfo = semanticModel.GetSymbolInfo(node, cancellationToken);
var referencedSymbol = symbolInfo.Symbol ?? symbolInfo.CandidateSymbols.FirstOrDefault();
if (referencedSymbol != null) {
// Get the defining type of the referenced symbol
ITypeSymbol? definingType = null;
if (referencedSymbol is ITypeSymbol typeRef) {
definingType = typeRef;
} else if (referencedSymbol is IFieldSymbol fieldRef) {
definingType = fieldRef.Type;
// Also add the containing type
if (fieldRef.ContainingType != null) {
AddReferencedType(fieldRef.ContainingType, typeSymbol, referencedTypes, cancellationToken);
}
} else if (referencedSymbol is IPropertySymbol propertyRef) {
definingType = propertyRef.Type;
// Also add the containing type
if (propertyRef.ContainingType != null) {
AddReferencedType(propertyRef.ContainingType, typeSymbol, referencedTypes, cancellationToken);
}
} else if (referencedSymbol is IMethodSymbol methodRef) {
definingType = methodRef.ReturnType;
// Also add the containing type
if (methodRef.ContainingType != null) {
AddReferencedType(methodRef.ContainingType, typeSymbol, referencedTypes, cancellationToken);
}
// Add parameter types
foreach (var param in methodRef.Parameters) {
if (param.Type != null) {
AddReferencedType(param.Type, typeSymbol, referencedTypes, cancellationToken);
}
}
} else if (referencedSymbol is ILocalSymbol localRef) {
definingType = localRef.Type;
} else if (referencedSymbol is IParameterSymbol paramRef) {
definingType = paramRef.Type;
} else if (referencedSymbol is IEventSymbol eventRef) {
definingType = eventRef.Type;
// Also add the containing type
if (eventRef.ContainingType != null) {
AddReferencedType(eventRef.ContainingType, typeSymbol, referencedTypes, cancellationToken);
}
}
if (definingType != null) {
AddReferencedType(definingType, typeSymbol, referencedTypes, cancellationToken);
}
}
// Check for type symbols from nodes
var typeInfo = semanticModel.GetTypeInfo(node, cancellationToken);
if (typeInfo.Type != null) {
AddReferencedType(typeInfo.Type, typeSymbol, referencedTypes, cancellationToken);
}
if (typeInfo.ConvertedType != null && !SymbolEqualityComparer.Default.Equals(typeInfo.Type, typeInfo.ConvertedType)) {
AddReferencedType(typeInfo.ConvertedType, typeSymbol, referencedTypes, cancellationToken);
}
}
}
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
_logger.LogWarning(ex, "Error finding referenced types for type {TypeName}", typeSymbol.Name);
}
return referencedTypes;
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/DocumentTools.cs:
--------------------------------------------------------------------------------
```csharp
using Microsoft.CodeAnalysis;
using ModelContextProtocol;
using SharpTools.Tools.Services;
using SharpTools.Tools.Mcp;
using SharpTools.Tools.Mcp.Tools;
using System.Security;
using System.Text;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
namespace SharpTools.Tools.Mcp.Tools;
// Marker class for ILogger<T> category specific to DocumentTools
public class DocumentToolsLogCategory { }
[McpServerToolType]
public static class DocumentTools {
private static string previousFilePathWarned = string.Empty;
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ReadRawFromRoslynDocument), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false),
Description("Reads the content of a file in the solution or referenced directories. Omits indentation to save tokens.")]
public static async Task<string> ReadRawFromRoslynDocument(
ISolutionManager solutionManager,
IDocumentOperationsService documentOperations,
ILogger<DocumentToolsLogCategory> logger,
[Description("The absolute path to the file to read.")] string filePath,
CancellationToken cancellationToken = default) {
const int LineCountWarningThreshold = 1000;
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadRawFromRoslynDocument));
logger.LogInformation("Reading document at {FilePath}", filePath);
if (!documentOperations.FileExists(filePath)) {
throw new McpException($"File not found: {filePath}");
}
if (!documentOperations.IsPathReadable(filePath)) {
throw new McpException($"File exists but cannot be read: {filePath}");
}
try {
var (contents, lines) = await documentOperations.ReadFileAsync(filePath, true, cancellationToken);
if (lines > LineCountWarningThreshold) {
if (previousFilePathWarned != filePath) {
previousFilePathWarned = filePath;
throw new McpException(
$"WARNING: '{filePath}' is very long (over {LineCountWarningThreshold} lines). " +
"Consider using more focused tools to accomplish your task, " +
"or call this tool again with the same arguments to override this warning.");
}
previousFilePathWarned = string.Empty;
logger.LogInformation("Proceeding with reading large file ({LineCount} lines) after warning acknowledgment", lines);
}
return contents;
} catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) {
logger.LogError(ex, "File not found: {FilePath}", filePath);
throw new McpException($"File not found: {filePath}");
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) {
logger.LogError(ex, "Failed to read file due to access restrictions: {FilePath}", filePath);
throw new McpException($"Failed to read file due to access restrictions: {ex.Message}");
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Unexpected error reading file: {FilePath}", filePath);
throw new McpException($"Failed to read file: {ex.Message}");
}
}, logger, nameof(ReadRawFromRoslynDocument), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(CreateRoslynDocument), Idempotent = true, ReadOnly = false, Destructive = false, OpenWorld = false),
Description("Creates a new document file with the specified content. Returns error if the file already exists.")]
public static async Task<string> CreateRoslynDocument(
ISolutionManager solutionManager,
IDocumentOperationsService documentOperations,
ICodeModificationService codeModificationService,
ILogger<DocumentToolsLogCategory> logger,
[Description("The absolute path where the file should be created.")] string filePath,
[Description("The content to write to the file. For C#, omit indentation to save tokens. Code will be auto-formatted.")] string content,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger);
ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(CreateRoslynDocument));
content = content.TrimBackslash();
logger.LogInformation("Creating new document at {FilePath}", filePath);
// Check if file exists
if (documentOperations.FileExists(filePath)) {
throw new McpException($"File already exists at {filePath}. Use '{ToolHelpers.SharpToolPrefix}{nameof(ReadRawFromRoslynDocument)}' to understand its contents. Then you can use '{ToolHelpers.SharpToolPrefix}{nameof(OverwriteRoslynDocument)}' if you decide to overwrite what exists.");
}
// Check if path is writable
if (!documentOperations.IsPathWritable(filePath)) {
throw new McpException($"Cannot create file at {filePath}. Path is not writable.");
}
string finalCommitMessage = $"Create {Path.GetFileName(filePath)}: " + commitMessage;
try {
// Determine if it's a code file that should be tracked by Roslyn
bool isCodeFile = documentOperations.IsCodeFile(filePath);
// Write the file content
var success = await documentOperations.WriteFileAsync(filePath, content, false, cancellationToken, finalCommitMessage);
if (!success) {
throw new McpException($"Failed to create file at {filePath} for unknown reasons.");
}
// Get the current solution path
var solutionPath = solutionManager.CurrentSolution?.FilePath;
if (string.IsNullOrEmpty(solutionPath)) {
return $"Created file {filePath} but no solution is loaded";
}
// If it's not a code file, just return success
if (!isCodeFile) {
return $"Created non-code file {filePath}";
}
// For code files, check if it was added to the solution
var documents = solutionManager.CurrentSolution?.Projects
.SelectMany(p => p.Documents)
.Where(d => d.FilePath != null &&
d.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase))
.ToList();
var projectStatus = "but was not detected by any project";
if (documents?.Any() == true) {
var document = documents.First();
projectStatus = $"and was added to project {document.Project.Name}";
// Check for compilation errors
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, document, logger, cancellationToken);
if (hasErrors) {
return $"Created file {filePath} ({projectStatus}), but compilation issues were detected:\n\n{errorMessages}";
}
}
return $"Created file {filePath} {projectStatus}";
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) {
logger.LogError(ex, "Failed to create file due to IO or access restrictions: {FilePath}", filePath);
throw new McpException($"Failed to create file due to IO or access restrictions: {ex.Message}");
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Unexpected error creating file: {FilePath}", filePath);
throw new McpException($"Failed to create file: {ex.Message}");
}
}, logger, nameof(CreateRoslynDocument), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(OverwriteRoslynDocument), Idempotent = true, ReadOnly = false, Destructive = true, OpenWorld = false),
Description($"Overwrites an existing document file with the specified content. You must use {ToolHelpers.SharpToolPrefix}{nameof(ReadRawFromRoslynDocument)} first.")]
public static async Task<string> OverwriteRoslynDocument(
ISolutionManager solutionManager,
IDocumentOperationsService documentOperations,
ICodeModificationService codeModificationService,
ILogger<DocumentToolsLogCategory> logger,
[Description("The absolute path to the file to overwrite.")] string filePath,
[Description("The content to write to the file. For C#, omit indentation to save tokens. Code will be auto-formatted.")] string content,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger);
ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteRoslynDocument));
content = content.TrimBackslash();
logger.LogInformation("Overwriting document at {FilePath}", filePath);
// Check if path is writable
if (!documentOperations.IsPathWritable(filePath)) {
throw new McpException($"Cannot write to file at {filePath}. Path is not writable.");
}
string finalCommitMessage = $"Update {Path.GetFileName(filePath)}: " + commitMessage;
try {
// Read the original content for diff
string originalContent = "";
if (documentOperations.FileExists(filePath)) {
(originalContent, _) = await documentOperations.ReadFileAsync(filePath, false, cancellationToken);
}
// Determine if it's a code file that should be handled by Roslyn
bool isCodeFile = documentOperations.IsCodeFile(filePath);
// Check if the file is already in the solution
Document? existingDocument = null;
if (isCodeFile) {
existingDocument = solutionManager.CurrentSolution?.Projects
.SelectMany(p => p.Documents)
.FirstOrDefault(d => d.FilePath != null &&
d.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase));
}
string resultMessage;
if (existingDocument != null) {
logger.LogInformation("Document found in solution, updating through workspace API: {FilePath}", filePath);
// Create a new document text with the updated content
var newText = SourceText.From(content);
var newDocument = existingDocument.WithText(newText);
var solution = newDocument.Project.Solution;
// Apply the changes to the workspace using the code modification service with the commit message
await codeModificationService.ApplyChangesAsync(solution, cancellationToken, finalCommitMessage);
// Check for compilation errors
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, newDocument, logger, cancellationToken);
resultMessage = hasErrors ?
$"Overwrote file in project {existingDocument.Project.Name}, but compilation issues were detected:\n\n{errorMessages}" :
$"Overwrote file in project {existingDocument.Project.Name}";
} else {
// For non-solution files or non-code files, use standard file writing
var success = await documentOperations.WriteFileAsync(filePath, content, true, cancellationToken, finalCommitMessage);
if (!success) {
throw new McpException($"Failed to overwrite file at {filePath} for unknown reasons.");
}
if (!isCodeFile) {
resultMessage = $"Overwrote non-code file {filePath}";
} else {
var projectStatus = "but was not detected by any project";
// Check if the file was added to a project after writing
var document = solutionManager.CurrentSolution?.Projects
.SelectMany(p => p.Documents)
.FirstOrDefault(d => d.FilePath != null &&
d.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase));
if (document != null) {
projectStatus = $"and was detected in project {document.Project.Name}";
// Check for compilation errors for code files
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, document, logger, cancellationToken);
if (hasErrors) {
resultMessage = $"Overwrote file {filePath} ({projectStatus}), but compilation issues were detected:\n\n{errorMessages}";
return AddDiffToResult(resultMessage, originalContent, content);
}
}
resultMessage = $"Overwrote file {filePath} {projectStatus}";
}
}
// Generate and append the diff to the result
return AddDiffToResult(resultMessage, originalContent, content);
} catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) {
logger.LogError(ex, "File not found for overwriting: {FilePath}", filePath);
throw new McpException($"File not found for overwriting: {filePath}");
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) {
logger.LogError(ex, "Failed to overwrite file due to IO or access restrictions: {FilePath}", filePath);
throw new McpException($"Failed to overwrite file due to IO or access restrictions: {ex.Message}");
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Unexpected error overwriting file: {FilePath}", filePath);
throw new McpException($"Failed to overwrite file: {ex.Message}");
}
}, logger, nameof(OverwriteRoslynDocument), cancellationToken);
}
private static string AddDiffToResult(string resultMessage, string oldContent, string newContent) {
// Use the centralized diff generation from ContextInjectors
string diffResult = ContextInjectors.CreateCodeDiff(oldContent, newContent);
return $"{resultMessage}\n{diffResult}";
}
// Helper method to determine if a file is a supported code file that should be checked for compilation errors
private static bool IsSupportedCodeFile(string filePath) {
if (string.IsNullOrEmpty(filePath)) {
return false;
}
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch {
".cs" => true, // C# files
".csx" => true, // C# script files
".vb" => true, // Visual Basic files
".fs" => true, // F# files
".fsx" => true, // F# script files
".fsi" => true, // F# signature files
_ => false
};
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ReadTypesFromRoslynDocument), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Returns a comprehensive tree of types (classes, interfaces, structs, etc.) and their members from a specified file. Use this to enter the more powerful 'type' domain from the 'file' domain.")]
public static async Task<object> ReadTypesFromRoslynDocument(
ISolutionManager solutionManager,
IDocumentOperationsService documentOperations,
ICodeAnalysisService codeAnalysisService,
ILogger<DocumentToolsLogCategory> logger,
[Description("The absolute path to the file to analyze.")] string filePath,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateFilePath(filePath, logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadTypesFromRoslynDocument));
var pathInfo = documentOperations.GetPathInfo(filePath);
if (!pathInfo.Exists) {
throw new McpException($"File not found: {filePath}");
}
if (!pathInfo.IsReferencedBySolution) {
throw new McpException($"File is not part of the solution: {filePath}");
}
var documentId = solutionManager.CurrentSolution?.GetDocumentIdsWithFilePath(filePath).FirstOrDefault();
if (documentId == null) {
throw new McpException($"Could not locate document in solution: {filePath}");
}
var sourceDoc = solutionManager.CurrentSolution?.GetDocument(documentId);
if (sourceDoc == null) {
throw new McpException($"Could not load document from solution: {filePath}");
}
var syntaxRoot = await sourceDoc.GetSyntaxRootAsync(cancellationToken);
if (syntaxRoot == null) {
throw new McpException($"Could not parse syntax tree for document: {filePath}");
}
var semanticModel = await sourceDoc.GetSemanticModelAsync(cancellationToken);
if (semanticModel == null) {
throw new McpException($"Could not get semantic model for document: {filePath}");
}
var typeNodes = syntaxRoot.DescendantNodes()
.OfType<TypeDeclarationSyntax>();
var result = new List<object>();
foreach (var typeNode in typeNodes) {
// Process only top-level types. Nested types are handled by BuildRoslynSubtypeTreeAsync.
if (typeNode.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax) {
var declaredSymbol = semanticModel.GetDeclaredSymbol(typeNode, cancellationToken);
if (declaredSymbol is INamedTypeSymbol declaredNamedTypeSymbol) {
// It's crucial that the symbol passed to BuildRoslynSubtypeTreeAsync is from a compilation
// that has all necessary references, which FindRoslynNamedTypeSymbolAsync tries to ensure.
string fullyQualifiedName = declaredNamedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var resolvedNamedTypeSymbol = await solutionManager.FindRoslynNamedTypeSymbolAsync(fullyQualifiedName, cancellationToken);
INamedTypeSymbol symbolToProcess = resolvedNamedTypeSymbol ?? declaredNamedTypeSymbol;
// Ensure the symbol is not an error type and has a containing assembly.
// Symbols from GetDeclaredSymbol on a document not fully processed by a compilation might lack this.
if (symbolToProcess.TypeKind != TypeKind.Error && symbolToProcess.ContainingAssembly != null) {
result.Add(await AnalysisTools.BuildRoslynSubtypeTreeAsync(symbolToProcess, codeAnalysisService, cancellationToken));
} else if (resolvedNamedTypeSymbol == null && declaredNamedTypeSymbol.TypeKind != TypeKind.Error && declaredNamedTypeSymbol.ContainingAssembly != null) {
// Fallback to declared symbol if it's valid but resolution failed
logger.LogDebug("Using originally declared symbol for {SymbolName} as re-resolution failed but declared symbol appears valid.", fullyQualifiedName);
result.Add(await AnalysisTools.BuildRoslynSubtypeTreeAsync(declaredNamedTypeSymbol, codeAnalysisService, cancellationToken));
} else {
logger.LogWarning("Skipping symbol {SymbolName} from file {FilePath} as it could not be properly resolved to a valid named type symbol with an assembly context. Resolved: {ResolvedIsNull}, Declared TypeKind: {DeclaredTypeKind}, Declared AssemblyNull: {DeclaredAssemblyIsNull}",
fullyQualifiedName,
filePath,
resolvedNamedTypeSymbol == null,
declaredNamedTypeSymbol.TypeKind,
declaredNamedTypeSymbol.ContainingAssembly == null);
}
}
}
}
return ToolHelpers.ToJson(result);
}, logger, nameof(ReadTypesFromRoslynDocument), cancellationToken);
}
}
```