This is page 3 of 3. Use http://codebase.md/kooshi/sharptoolsmcp?lines=false&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
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/ModificationTools.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using SharpTools.Tools.Interfaces;
using SharpTools.Tools.Mcp;
using SharpTools.Tools.Services;
namespace SharpTools.Tools.Mcp.Tools;
// Marker class for ILogger<T> category specific to ModificationTools
public class ModificationToolsLogCategory { }
[McpServerToolType]
public static class ModificationTools {
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(AddMember), Idempotent = false, Destructive = false, OpenWorld = false, ReadOnly = false)]
[Description("Adds one or more new member definitions (Property, Field, Method, inner Class, etc.) to a specified type. Code is parsed, inserted, and formatted. Definition can include xml documentation and attributes. Writing small components produces cleaner code, so you can use this to break up large components, in addition to adding new functionality.")]
public static async Task<string> AddMember(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
IComplexityAnalysisService complexityAnalysisService,
ISemanticSimilarityService semanticSimilarityService,
ILogger<ModificationToolsLogCategory> logger,
[Description("FQN of the parent type or method.")] string fullyQualifiedTargetName,
[Description("The C# code to add.")] string codeSnippet,
[Description("If the target is a partial type, specifies which file to add to. Set to 'auto' to determine automatically.")] string fileNameHint,
[Description("Suggest a line number to insert the member near. '-1' to determine automatically.")] int lineNumberHint,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTargetName, "fullyQualifiedTargetName", logger);
ErrorHandlingHelpers.ValidateStringParameter(codeSnippet, "codeSnippet", logger);
codeSnippet = codeSnippet.TrimBackslash();
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AddMember));
logger.LogInformation("Executing '{AddMember}' for target: {TargetName}", nameof(AddMember), fullyQualifiedTargetName);
// Get the target symbol
var targetSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedTargetName, cancellationToken);
SyntaxReference? targetSyntaxRef = null;
if (!string.IsNullOrEmpty(fileNameHint) && fileNameHint != "auto") {
targetSyntaxRef = targetSymbol.DeclaringSyntaxReferences.FirstOrDefault(sr =>
sr.SyntaxTree.FilePath != null && sr.SyntaxTree.FilePath.Contains(fileNameHint));
if (targetSyntaxRef == null) {
throw new McpException($"File hint '{fileNameHint}' did not match any declaring syntax reference for symbol '{fullyQualifiedTargetName}'.");
}
} else {
targetSyntaxRef = targetSymbol.DeclaringSyntaxReferences.FirstOrDefault();
}
if (targetSyntaxRef == null) {
throw new McpException($"Could not find a suitable syntax reference for symbol '{fullyQualifiedTargetName}'.");
}
if (solutionManager.CurrentSolution == null) {
throw new McpException("Current solution is unexpectedly null after validation checks.");
}
var syntaxNode = await targetSyntaxRef.GetSyntaxAsync(cancellationToken);
var document = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(solutionManager.CurrentSolution, syntaxNode);
if (targetSymbol is not INamedTypeSymbol typeSymbol) {
throw new McpException($"Target '{fullyQualifiedTargetName}' is not a type, cannot add member.");
}
// Parse the code snippet
MemberDeclarationSyntax? memberSyntax;
try {
memberSyntax = SyntaxFactory.ParseMemberDeclaration(codeSnippet);
if (memberSyntax == null) {
throw new McpException("Failed to parse code snippet as a valid member declaration.");
}
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to parse code snippet as member declaration");
throw new McpException($"Invalid C# syntax in code snippet: {ex.Message}");
}
// Verify the member name doesn't already exist in the type
string memberName = GetMemberName(memberSyntax);
logger.LogInformation("Adding member with name: {MemberName}", memberName);
// Check for duplicate members
if (!IsMemberAllowed(typeSymbol, memberSyntax, memberName, cancellationToken)) {
throw new McpException($"A member with the name '{memberName}' already exists in '{fullyQualifiedTargetName}'" +
(memberSyntax is MethodDeclarationSyntax ? " with the same parameter signature." : "."));
}
try {
// Use the lineNumberHint parameter when calling AddMemberAsync
var newSolution = await modificationService.AddMemberAsync(document.Id, typeSymbol, memberSyntax, lineNumberHint, cancellationToken);
string finalCommitMessage = $"Add {memberName} to {typeSymbol.Name}: " + commitMessage;
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
// Check for compilation errors after adding the code
var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
if (updatedDocument is null) {
logger.LogError("Updated document for {TargetName} is null after applying changes", fullyQualifiedTargetName);
throw new McpException($"Failed to retrieve updated document for {fullyQualifiedTargetName} after applying changes.");
}
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDocument, logger, cancellationToken);
// Perform complexity and similarity analysis on the added member
string analysisResults = string.Empty;
// Get the updated type symbol to find the newly added member
var updatedSemanticModel = await updatedDocument.GetSemanticModelAsync(cancellationToken);
if (updatedSemanticModel != null) {
// Find the type symbol in the updated document by FQN instead of using old syntax reference
var updatedTypeSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedTargetName, cancellationToken) as INamedTypeSymbol;
if (updatedTypeSymbol != null) {
// Find the added member by name
var addedSymbol = updatedTypeSymbol.GetMembers(memberName).FirstOrDefault();
if (addedSymbol != null) {
analysisResults = await MemberAnalysisHelper.AnalyzeAddedMemberAsync(
addedSymbol, complexityAnalysisService, semanticSimilarityService, logger, cancellationToken);
}
}
}
string baseMessage = $"Successfully added member to {fullyQualifiedTargetName} in {document.FilePath ?? "unknown file"}.\n\n" +
((!hasErrors) ? "<errorCheck>No compilation issues detected.</errorCheck>" :
($"{errorMessages}\n" + //Code added is not necessary in Copilot, as it can see the invocation $"\nCode added:\n{codeSnippet}\n\n" +
$"If you choose to fix these issues, you must use {ToolHelpers.SharpToolPrefix + nameof(OverwriteMember)} to replace the member with a new definition."));
return string.IsNullOrWhiteSpace(analysisResults) ? baseMessage : $"{baseMessage}\n\n{analysisResults}";
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to add member to {TypeName}", fullyQualifiedTargetName);
throw new McpException($"Failed to add member to {fullyQualifiedTargetName}: {ex.Message}");
}
}, logger, nameof(AddMember), cancellationToken);
}
private static string GetMemberName(MemberDeclarationSyntax memberSyntax) {
return memberSyntax switch {
MethodDeclarationSyntax method => method.Identifier.Text,
ConstructorDeclarationSyntax ctor => ctor.Identifier.Text,
DestructorDeclarationSyntax dtor => dtor.Identifier.Text,
OperatorDeclarationSyntax op => op.OperatorToken.Text,
ConversionOperatorDeclarationSyntax conv => conv.Type.ToString(),
PropertyDeclarationSyntax property => property.Identifier.Text,
FieldDeclarationSyntax field => field.Declaration.Variables.First().Identifier.Text,
EnumDeclarationSyntax enumDecl => enumDecl.Identifier.Text,
TypeDeclarationSyntax type => type.Identifier.Text,
DelegateDeclarationSyntax del => del.Identifier.Text,
EventDeclarationSyntax evt => evt.Identifier.Text,
EventFieldDeclarationSyntax evtField => evtField.Declaration.Variables.First().Identifier.Text,
IndexerDeclarationSyntax indexer => "this[]", // Indexers don't have names but use the 'this' keyword
_ => throw new NotSupportedException($"Unsupported member type: {memberSyntax.GetType().Name}")
};
}
// Helper method to check if the member is allowed to be added
private static bool IsMemberAllowed(INamedTypeSymbol typeSymbol, MemberDeclarationSyntax newMember, string memberName, CancellationToken cancellationToken) {
// Special handling for method overloads
if (newMember is MethodDeclarationSyntax newMethod) {
// Get all existing methods with the same name
var existingMethods = typeSymbol.GetMembers(memberName)
.OfType<IMethodSymbol>()
.Where(m => !m.IsImplicitlyDeclared && m.MethodKind == MethodKind.Ordinary)
.ToList();
if (!existingMethods.Any()) {
return true; // No method with the same name exists
}
// Convert parameters of the new method to comparable format
var newMethodParams = newMethod.ParameterList.Parameters
.Select(p => new {
Type = p.Type?.ToString() ?? "unknown",
IsRef = p.Modifiers.Any(m => m.IsKind(SyntaxKind.RefKeyword)),
IsOut = p.Modifiers.Any(m => m.IsKind(SyntaxKind.OutKeyword))
})
.ToList();
// Check if any existing method has the same parameter signature
foreach (var existingMethod in existingMethods) {
if (existingMethod.Parameters.Length != newMethodParams.Count) {
continue; // Different parameter count, not a duplicate
}
bool signatureMatches = true;
for (int i = 0; i < existingMethod.Parameters.Length; i++) {
var existingParam = existingMethod.Parameters[i];
var newParam = newMethodParams[i];
// Compare parameter types and ref/out modifiers
if (existingParam.Type.ToDisplayString() != newParam.Type ||
existingParam.RefKind == RefKind.Ref != newParam.IsRef ||
existingParam.RefKind == RefKind.Out != newParam.IsOut) {
signatureMatches = false;
break;
}
}
if (signatureMatches) {
return false; // Found a method with the same signature
}
}
return true; // No matching signature found
} else {
// For non-method members, simply check if a member with the same name exists
return !typeSymbol.GetMembers(memberName).Any(m => !m.IsImplicitlyDeclared);
}
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(OverwriteMember), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
[Description("Replaces the definition of an existing member or type with new C# code, or deletes it. Code is parsed and formatted. Code can contain multiple new members, update the existing member, and/or replace it with a new one.")]
public static async Task<string> OverwriteMember(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<ModificationToolsLogCategory> logger,
[Description("FQN of the member or type to rewrite.")] string fullyQualifiedMemberName,
[Description("The new C# code for the member or type. *If this member has attributes or XML documentation, they MUST be included here.* To Delete the target instead, set this to `// Delete {memberName}`.")] string newMemberCode,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger);
ErrorHandlingHelpers.ValidateStringParameter(newMemberCode, nameof(newMemberCode), logger);
newMemberCode = newMemberCode.TrimBackslash();
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteMember));
logger.LogInformation("Executing '{OverwriteMember}' for: {SymbolName}", nameof(OverwriteMember), fullyQualifiedMemberName);
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken);
if (!symbol.DeclaringSyntaxReferences.Any()) {
throw new McpException($"Symbol '{fullyQualifiedMemberName}' has no declaring syntax references.");
}
var syntaxRef = symbol.DeclaringSyntaxReferences.First();
var oldNode = await syntaxRef.GetSyntaxAsync(cancellationToken);
if (solutionManager.CurrentSolution is null) {
throw new McpException("Current solution is unexpectedly null after validation checks.");
}
var document = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(solutionManager.CurrentSolution, oldNode);
if (oldNode is not MemberDeclarationSyntax && oldNode is not TypeDeclarationSyntax) {
throw new McpException($"Symbol '{fullyQualifiedMemberName}' does not represent a replaceable member or type.");
}
// Get a simple name for the symbol for the commit message
string symbolName = symbol.Name;
bool isDelete = newMemberCode.StartsWith("// Delete", StringComparison.OrdinalIgnoreCase);
string finalCommitMessage = (isDelete ? $"Delete {symbolName}" : $"Update {symbolName}") + ": " + commitMessage;
if (isDelete) {
var commentTrivia = SyntaxFactory.Comment(newMemberCode);
var emptyNode = SyntaxFactory.EmptyStatement()
.WithLeadingTrivia(commentTrivia)
.WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
try {
var newSolution = await modificationService.ReplaceNodeAsync(document.Id, oldNode, emptyNode, cancellationToken);
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
if (updatedDocument != null) {
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDocument, logger, cancellationToken);
if (!hasErrors)
errorMessages = "<errorCheck>No compilation issues detected.</errorCheck>";
return $"Successfully deleted symbol {fullyQualifiedMemberName}.\n\n{errorMessages}";
}
return $"Successfully deleted symbol {fullyQualifiedMemberName}";
} catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
logger.LogError(ex, "Failed to delete symbol {SymbolName}", fullyQualifiedMemberName);
throw new McpException($"Failed to delete symbol {fullyQualifiedMemberName}: {ex.Message}");
}
}
SyntaxNode? newNode;
try {
var parsedCode = SyntaxFactory.ParseCompilationUnit(newMemberCode);
newNode = parsedCode.Members.FirstOrDefault();
if (newNode is null) {
throw new McpException("Failed to parse new code as a valid member or type declaration. The parsed result was empty.");
}
// Validate that the parsed node is of an expected type if the original was a TypeDeclaration
if (oldNode is TypeDeclarationSyntax && newNode is not TypeDeclarationSyntax) {
throw new McpException($"The new code for '{fullyQualifiedMemberName}' was parsed as a {newNode.Kind()}, but a TypeDeclaration was expected to replace the existing TypeDeclaration.");
}
// Validate that the parsed node is of an expected type if the original was a MemberDeclaration (but not a TypeDeclaration, which is a subtype)
else if (oldNode is MemberDeclarationSyntax && oldNode is not TypeDeclarationSyntax && newNode is not MemberDeclarationSyntax) {
throw new McpException($"The new code for '{fullyQualifiedMemberName}' was parsed as a {newNode.Kind()}, but a MemberDeclaration was expected to replace the existing MemberDeclaration.");
}
} catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
logger.LogError(ex, "Failed to parse replacement code for {SymbolName}", fullyQualifiedMemberName);
throw new McpException($"Invalid C# syntax in replacement code: {ex.Message}");
}
if (newNode is null) { // Should be caught by earlier checks, but as a safeguard.
throw new McpException("Critical error: Failed to parse new code and newNode is null.");
}
try {
var newSolution = await modificationService.ReplaceNodeAsync(document.Id, oldNode, newNode, cancellationToken);
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
if (solutionManager.CurrentSolution is null) {
throw new McpException("Current solution is unexpectedly null after applying changes.");
}
// Generate diff using the centralized ContextInjectors
var diffResult = ContextInjectors.CreateCodeDiff(oldNode.ToFullString(), newNode.ToFullString());
var updatedDocument = solutionManager.CurrentSolution.GetDocument(document.Id);
if (updatedDocument is null) {
logger.LogError("Updated document for {SymbolName} is null after applying changes", fullyQualifiedMemberName);
throw new McpException($"Failed to retrieve updated document for {fullyQualifiedMemberName} after applying changes.");
}
var (hasErrors, errorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDocument, logger, cancellationToken);
if (!hasErrors)
errorMessages = "<errorCheck>No compilation issues detected.</errorCheck>";
return $"Successfully replaced symbol {fullyQualifiedMemberName}.\n\n{diffResult}\n\n{errorMessages}";
} catch (Exception ex) when (ex is not McpException && ex is not OperationCanceledException) {
logger.LogError(ex, "Failed to replace symbol {SymbolName}", fullyQualifiedMemberName);
throw new McpException($"Failed to replace symbol {fullyQualifiedMemberName}: {ex.Message}");
}
}, logger, nameof(OverwriteMember), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(RenameSymbol), Idempotent = true, Destructive = true, OpenWorld = false, ReadOnly = false),
Description("Renames a symbol (variable, method, property, type) and updates all references. Changes are formatted.")]
public static async Task<string> RenameSymbol(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<ModificationToolsLogCategory> logger,
[Description("FQN of the symbol to rename.")] string fullyQualifiedSymbolName,
[Description("The new name for the symbol.")] string newName,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
ErrorHandlingHelpers.ValidateStringParameter(newName, "newName", logger);
// Validate that the new name is a valid C# identifier
if (!IsValidCSharpIdentifier(newName)) {
throw new McpException($"'{newName}' is not a valid C# identifier for renaming.");
}
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(RenameSymbol));
logger.LogInformation("Executing '{RenameSymbol}' for {SymbolName} to {NewName}", nameof(RenameSymbol), fullyQualifiedSymbolName, newName);
// Get the symbol to rename
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
// Check if symbol is renamable
if (symbol.IsImplicitlyDeclared) {
throw new McpException($"Cannot rename implicitly declared symbol '{fullyQualifiedSymbolName}'.");
}
string finalCommitMessage = $"Rename {symbol.Name} to {newName}: " + commitMessage;
try {
// Perform the rename operation
var newSolution = await modificationService.RenameSymbolAsync(symbol, newName, cancellationToken);
// Check if the operation actually made changes
var changeset = newSolution.GetChanges(solutionManager.CurrentSolution!);
var changedDocumentCount = changeset.GetProjectChanges().Sum(p => p.GetChangedDocuments().Count());
if (changedDocumentCount == 0) {
logger.LogWarning("Rename operation for {SymbolName} to {NewName} produced no changes",
fullyQualifiedSymbolName, newName);
}
// Apply the changes with the commit message
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
// Check for compilation errors after renaming the symbol using centralized ContextInjectors
// Get the first few affected documents to check
var affectedDocumentIds = changeset.GetProjectChanges()
.SelectMany(pc => pc.GetChangedDocuments())
.Take(5) // Limit to first 5 documents to avoid excessive checking
.ToList();
StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
// Check each affected document for compilation errors
foreach (var docId in affectedDocumentIds) {
if (solutionManager.CurrentSolution != null) {
var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
if (updatedDoc != null) {
var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDoc, logger, cancellationToken);
if (docHasErrors) {
errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
errorBuilder.AppendLine(docErrorMessages);
errorBuilder.AppendLine();
} else {
errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
}
}
}
}
errorBuilder.AppendLine("</errorCheck>");
return $"Symbol '{symbol.Name}' (originally '{fullyQualifiedSymbolName}') successfully renamed to '{newName}' and references updated in {changedDocumentCount} documents.\n\n{errorBuilder}";
} catch (InvalidOperationException ex) {
logger.LogError(ex, "Invalid rename operation for {SymbolName} to {NewName}", fullyQualifiedSymbolName, newName);
throw new McpException($"Cannot rename symbol '{fullyQualifiedSymbolName}' to '{newName}': {ex.Message}");
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to rename symbol {SymbolName} to {NewName}", fullyQualifiedSymbolName, newName);
throw new McpException($"Failed to rename symbol '{fullyQualifiedSymbolName}' to '{newName}': {ex.Message}");
}
}, logger, nameof(RenameSymbol), cancellationToken);
}
// Helper method to check if a string is a valid C# identifier
private static bool IsValidCSharpIdentifier(string name) {
return SyntaxFacts.IsValidIdentifier(name);
}
//Disabled for now
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ReplaceAllReferences), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
[Description("Surgically replaces all references to a symbol with new C# code across the solution. Perfect for systematic API upgrades - e.g., replacing all Console.WriteLine() calls with Logger.Info(). Use filename filters (*.cs, Controller*.cs) to scope changes to specific files.")]
public static async Task<string> ReplaceAllReferences(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<ModificationToolsLogCategory> logger,
[Description("FQN of the symbol whose references should be replaced.")] string fullyQualifiedSymbolName,
[Description("The C# code replace references with.")] string replacementCode,
[Description("Only replace symbols in files with this pattern. Supports globbing (`*`).")] string filenameFilter,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
ErrorHandlingHelpers.ValidateStringParameter(replacementCode, "replacementCode", logger);
replacementCode = replacementCode.TrimBackslash();
// Note: filenameFilter can be empty or null, as this indicates "replace in all files"
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReplaceAllReferences));
logger.LogInformation("Executing '{ReplaceAllReferences}' for {SymbolName} with text '{ReplacementCode}', filter: {Filter}",
nameof(ReplaceAllReferences), fullyQualifiedSymbolName, replacementCode, filenameFilter ?? "none");
// Get the symbol whose references will be replaced
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
// Create a shortened version of the replacement code for the commit message
string shortReplacementCode = replacementCode.Length > 30
? replacementCode.Substring(0, 30) + "..."
: replacementCode;
string finalCommitMessage = $"Replace references to {symbol.Name} with {shortReplacementCode}: " + commitMessage;
// Validate that the replacement code can be parsed as a valid C# expression
try {
var expressionSyntax = SyntaxFactory.ParseExpression(replacementCode);
if (expressionSyntax == null) {
logger.LogWarning("Replacement code '{ReplacementCode}' may not be a valid C# expression", replacementCode);
}
} catch (Exception ex) {
logger.LogWarning(ex, "Replacement code '{ReplacementCode}' could not be parsed as a C# expression", replacementCode);
// We don't throw here, because some valid replacements might not be valid expressions on their own
}
// Create a predicate filter if a filename filter is provided
Func<SyntaxNode, bool>? predicateFilter = null;
if (!string.IsNullOrEmpty(filenameFilter)) {
Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
string normalizedFilter = filenameFilter.Replace('\\', '/');
matcher.AddInclude(normalizedFilter);
string root = Path.GetPathRoot(solutionManager.CurrentSolution?.FilePath) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
try {
predicateFilter = node => {
try {
var location = node.GetLocation();
if (location == null || string.IsNullOrWhiteSpace(location.SourceTree?.FilePath)) {
return false;
}
string filePath = location.SourceTree.FilePath;
return matcher.Match(root, filePath).HasMatches;
} catch (Exception ex) {
logger.LogWarning(ex, "Error applying filename filter to node");
return false; // Skip nodes that cause errors in the filter
}
};
} catch (Exception ex) {
logger.LogError(ex, "Failed to create filename filter '{Filter}'", filenameFilter);
throw new McpException($"Failed to create filename filter '{filenameFilter}': {ex.Message}");
}
}
try {
// Replace all references to the symbol with the new code
var newSolution = await modificationService.ReplaceAllReferencesAsync(
symbol, replacementCode, cancellationToken, predicateFilter);
// Count changes before applying them
if (solutionManager.CurrentSolution == null) {
throw new McpException("Current solution is null after replacement operation.");
}
var originalSolution = solutionManager.CurrentSolution;
var solutionChanges = newSolution.GetChanges(originalSolution);
var changedDocumentsCount = solutionChanges.GetProjectChanges()
.SelectMany(pc => pc.GetChangedDocuments())
.Count();
if (changedDocumentsCount == 0) {
logger.LogWarning("No documents were changed when replacing references to '{SymbolName}'",
fullyQualifiedSymbolName);
// We can't directly check for references without applying changes
// Just give a general message about no changes being made
if (!string.IsNullOrEmpty(filenameFilter)) {
// If the filter is limiting results
return $"No references to '{symbol.Name}' found in files matching '{filenameFilter}'. No changes were made.";
} else {
// General message about no changes
return $"References to '{symbol.Name}' were found but no changes were made. The replacement code might be identical to the original.";
}
}
// Apply the changes
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage);
// Check for compilation errors in changed documents
var changedDocIds = solutionChanges.GetProjectChanges()
.SelectMany(pc => pc.GetChangedDocuments())
.Take(5) // Limit to first 5 documents to avoid excessive checking
.ToList();
StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
// Check each affected document for compilation errors
foreach (var docId in changedDocIds) {
if (solutionManager.CurrentSolution != null) {
var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
if (updatedDoc != null) {
var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDoc, logger, cancellationToken);
if (docHasErrors) {
errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
errorBuilder.AppendLine(docErrorMessages);
errorBuilder.AppendLine();
} else {
errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
}
}
}
}
errorBuilder.AppendLine("</errorCheck>");
var filterMessage = string.IsNullOrEmpty(filenameFilter) ? "" : $" (with filter '{filenameFilter}')";
return $"Successfully replaced references to '{symbol.Name}'{filterMessage} with '{replacementCode}' in {changedDocumentsCount} document(s).\n\n{errorBuilder}";
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to replace references to symbol '{SymbolName}' with '{ReplacementCode}'",
fullyQualifiedSymbolName, replacementCode);
throw new McpException($"Failed to replace references: {ex.Message}");
}
}, logger, nameof(ReplaceAllReferences), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(Undo), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
[Description($"Reverts the last applied change to the solution. You can undo all consecutive changes you have made. Returns a diff of the change that was undone.")]
public static async Task<string> Undo(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<ModificationToolsLogCategory> logger,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(Undo));
logger.LogInformation("Executing '{UndoLastChange}'", nameof(Undo));
var (success, message) = await modificationService.UndoLastChangeAsync(cancellationToken);
if (!success) {
logger.LogWarning("Undo operation failed: {Message}", message);
throw new McpException($"Failed to undo the last change. {message}");
}
logger.LogInformation("Undo operation succeeded");
return message;
}, logger, nameof(Undo), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(FindAndReplace), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
[Description("Every developer's favorite. Use this for all small edits (code tweaks, usings, namespaces, interface implementations, attributes, etc.) instead of rewriting large members or types.")]
public static async Task<string> FindAndReplace(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
IDocumentOperationsService documentOperations,
ILogger<ModificationToolsLogCategory> logger,
[Description("Regex operating in multiline mode, so `^` and `$` match per line. Always use `\\s*` at the beginnings of lines for unknown indentation. Make sure to escape your escapes for json.")] string regexPattern,
[Description("Replacement text, which can include regex groups ($1, ${name}, etc.)")] string replacementText,
[Description("Target, which can be either a FQN (replaces text within a declaration) or a filepath supporting globbing (`*`) (replaces all instances across files)")] string target,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(regexPattern, "regexPattern", logger);
ErrorHandlingHelpers.ValidateStringParameter(target, "targetString", logger);
//normalize newlines in pattern
regexPattern = regexPattern
.Replace("\r\n", "\n")
.Replace("\r", "\n")
.Replace("\n", @"\n")
.Replace(@"\r\n", @"\n")
.Replace(@"\r", @"\n");
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindAndReplace));
logger.LogInformation("Executing '{FindAndReplace}' with pattern: '{Pattern}', replacement: {Replacement}, target: {Target}",
nameof(FindAndReplace), regexPattern, replacementText, target);
// Validate the regex pattern
try {
// Create the regex with multiline option to test it
_ = new Regex(regexPattern, RegexOptions.Multiline);
} catch (ArgumentException ex) {
throw new McpException($"Invalid regular expression pattern: {ex.Message}");
}
try {
// Get the original solution for later comparison
var originalSolution = solutionManager.CurrentSolution ?? throw new McpException("Current solution is null before find and replace operation.");
// Track all modified files for both code and non-code files
var modifiedFiles = new List<string>();
var changedDocuments = new List<DocumentId>();
var nonCodeFilesModified = new List<string>();
var nonCodeDiffs = new Dictionary<string, string>();
// First, check if the target is a file path pattern (contains wildcard or is a direct file path)
if (target.Contains("*") || target.Contains("?") || (File.Exists(target) && !documentOperations.IsCodeFile(target))) {
logger.LogInformation("Target appears to be a file path pattern or non-code file: {Target}", target);
// Normalize the target path to use forward slashes consistently
string normalizedTarget = target.Replace('\\', '/');
// Create matcher for the pattern
Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
matcher.AddInclude(normalizedTarget);
string root = Path.GetPathRoot(originalSolution.FilePath) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
// Get all files in solution directory matching the pattern
var solutionDirectory = Path.GetDirectoryName(originalSolution.FilePath);
if (string.IsNullOrEmpty(solutionDirectory)) {
throw new McpException("Could not determine solution directory");
}
// Handle direct file path (no wildcards)
if (!target.Contains("*") && !target.Contains("?") && File.Exists(target)) {
// Direct file path, process just this file
var pathInfo = documentOperations.GetPathInfo(target);
if (!pathInfo.IsWithinSolutionDirectory) {
throw new McpException($"File {target} exists but is outside the solution directory. Cannot modify for safety reasons.");
}
// Check if it's a non-code file
if (!documentOperations.IsCodeFile(target)) {
var (changed, diff) = await ProcessNonCodeFile(target, regexPattern, replacementText, documentOperations, nonCodeFilesModified, cancellationToken);
if (changed) {
nonCodeDiffs.Add(target, diff);
}
}
} else {
// Use glob pattern to find matching files
DirectoryInfo dirInfo = new DirectoryInfo(solutionDirectory);
List<FileInfo> allFiles = dirInfo.GetFiles("*.*", SearchOption.AllDirectories).ToList();
string rootDir = Path.GetPathRoot(solutionDirectory) ?? Path.GetPathRoot(Environment.CurrentDirectory)!;
foreach (var file in allFiles) {
if (matcher.Match(rootDir, file.FullName).HasMatches) {
var pathInfo = documentOperations.GetPathInfo(file.FullName);
// Skip files in unsafe directories or outside solution
if (!pathInfo.IsWithinSolutionDirectory || !string.IsNullOrEmpty(pathInfo.WriteRestrictionReason)) {
logger.LogWarning("Skipping file due to restrictions: {FilePath}, Reason: {Reason}",
file.FullName, pathInfo.WriteRestrictionReason ?? "Outside solution directory");
continue;
}
// Process non-code files directly
if (!documentOperations.IsCodeFile(file.FullName)) {
var (changed, diff) = await ProcessNonCodeFile(file.FullName, regexPattern, replacementText, documentOperations, nonCodeFilesModified, cancellationToken);
if (changed) {
nonCodeDiffs.Add(file.FullName, diff);
}
}
}
}
}
}
// Now process code files through the Roslyn workspace
var newSolution = await modificationService.FindAndReplaceAsync(
target, regexPattern, replacementText, cancellationToken, RegexOptions.Multiline);
// Get changed code documents
var solutionChanges = newSolution.GetChanges(originalSolution);
foreach (var projectChange in solutionChanges.GetProjectChanges()) {
changedDocuments.AddRange(projectChange.GetChangedDocuments());
}
// If no code or non-code files were modified, return early
if (changedDocuments.Count == 0 && nonCodeFilesModified.Count == 0) {
logger.LogWarning("No documents were changed during find and replace operation");
throw new McpException($"No matches found for pattern '{regexPattern}' in target '{target}', or matches were found but replacement produced identical text. No changes were made.");
}
// Add code document file paths to the modifiedFiles list
foreach (var docId in changedDocuments) {
var document = originalSolution.GetDocument(docId);
if (document?.FilePath != null) {
modifiedFiles.Add(document.FilePath);
}
}
// Add non-code files to the list
modifiedFiles.AddRange(nonCodeFilesModified);
string finalCommitMessage = $"Find and replace on {target}: {commitMessage}";
// Apply the changes to code files
if (changedDocuments.Count > 0) {
await modificationService.ApplyChangesAsync(newSolution, cancellationToken, finalCommitMessage, nonCodeFilesModified);
}
// Commit non-code files (if we only modified non-code files)
if (nonCodeFilesModified.Count > 0 && changedDocuments.Count == 0) {
// Get solution path
var solutionPath = originalSolution.FilePath;
if (string.IsNullOrEmpty(solutionPath)) {
logger.LogDebug("Solution path is not available, skipping Git operations for non-code files");
} else {
await documentOperations.ProcessGitOperationsAsync(nonCodeFilesModified, cancellationToken, finalCommitMessage);
}
}
// Check for compilation errors in changed code documents
var changedDocIds = changedDocuments.Take(5).ToList(); // Limit to first 5 documents
StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
// Check each affected document for compilation errors
foreach (var docId in changedDocIds) {
if (solutionManager.CurrentSolution != null) {
var updatedDoc = solutionManager.CurrentSolution.GetDocument(docId);
if (updatedDoc != null) {
var (docHasErrors, docErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(
solutionManager, updatedDoc, logger, cancellationToken);
if (docHasErrors) {
errorBuilder.AppendLine($"Issues in file {updatedDoc.FilePath ?? "unknown"}:");
errorBuilder.AppendLine(docErrorMessages);
errorBuilder.AppendLine();
} else {
errorBuilder.AppendLine($"No compilation issues in file {updatedDoc.FilePath ?? "unknown"}.");
}
}
}
}
errorBuilder.AppendLine("</errorCheck>");
// Generate multi-document diff for code files
string diffOutput = changedDocuments.Count > 0
? await ContextInjectors.CreateMultiDocumentDiff(
originalSolution,
newSolution,
changedDocuments,
5,
cancellationToken)
: "";
// For non-code files, build a similar diff output format
StringBuilder nonCodeDiffBuilder = new StringBuilder();
if (nonCodeDiffs.Count > 0) {
nonCodeDiffBuilder.AppendLine();
nonCodeDiffBuilder.AppendLine("Non-code file changes:");
int nonCodeFileCount = 0;
foreach (var diffEntry in nonCodeDiffs) {
if (nonCodeFileCount >= 5) {
nonCodeDiffBuilder.AppendLine($"...and {nonCodeDiffs.Count - 5} more non-code files");
break;
}
nonCodeDiffBuilder.AppendLine();
nonCodeDiffBuilder.AppendLine($"Document: {diffEntry.Key}");
nonCodeDiffBuilder.AppendLine(diffEntry.Value);
nonCodeFileCount++;
}
}
return $"Successfully replaced pattern '{regexPattern}' with '{replacementText}' in {modifiedFiles.Count} file(s).\n\n{errorBuilder}\n\n{diffOutput}{nonCodeDiffBuilder}";
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to replace pattern '{Pattern}' with '{Replacement}' in '{Target}'",
regexPattern, replacementText, target);
throw new McpException($"Failed to perform find and replace: {ex.Message}");
}
}, logger, nameof(FindAndReplace), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(MoveMember), Idempotent = false, Destructive = true, OpenWorld = false, ReadOnly = false)]
[Description("Moves a member (property, field, method, nested type, etc.) from one type/namespace to another. The member is removed from the source location and added to the destination.")]
public static async Task<string> MoveMember(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<ModificationToolsLogCategory> logger,
[Description("FQN of the member to move.")] string fullyQualifiedMemberName,
[Description("FQN of the destination type or namespace where the member should be moved.")] string fullyQualifiedDestinationTypeOrNamespaceName,
string commitMessage,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger);
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedDestinationTypeOrNamespaceName, nameof(fullyQualifiedDestinationTypeOrNamespaceName), logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(MoveMember));
logger.LogInformation("Executing '{MoveMember}' moving {MemberName} to {DestinationName}",
nameof(MoveMember), fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName);
if (solutionManager.CurrentSolution == null) {
throw new McpException("Current solution is unexpectedly null after validation checks.");
}
Solution currentSolution = solutionManager.CurrentSolution;
var sourceMemberSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken);
if (sourceMemberSymbol is not (IFieldSymbol or IPropertySymbol or IMethodSymbol or IEventSymbol or INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct or TypeKind.Interface or TypeKind.Enum or TypeKind.Delegate })) {
throw new McpException($"Symbol '{fullyQualifiedMemberName}' is not a movable member type. Only fields, properties, methods, events, and nested types can be moved.");
}
var destinationSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedDestinationTypeOrNamespaceName, cancellationToken);
if (destinationSymbol is not (INamedTypeSymbol or INamespaceSymbol)) {
throw new McpException($"Destination '{fullyQualifiedDestinationTypeOrNamespaceName}' must be a type or namespace.");
}
var sourceSyntaxRef = sourceMemberSymbol.DeclaringSyntaxReferences.FirstOrDefault();
if (sourceSyntaxRef == null) {
throw new McpException($"Could not find syntax reference for member '{fullyQualifiedMemberName}'.");
}
var sourceMemberNode = await sourceSyntaxRef.GetSyntaxAsync(cancellationToken);
if (sourceMemberNode is not MemberDeclarationSyntax memberDeclaration) {
throw new McpException($"Source member '{fullyQualifiedMemberName}' is not a valid member declaration.");
}
Document sourceDocument = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(currentSolution, sourceMemberNode);
Document destinationDocument;
INamedTypeSymbol? destinationTypeSymbol = null;
INamespaceSymbol? destinationNamespaceSymbol = null;
if (destinationSymbol is INamedTypeSymbol typeSym) {
destinationTypeSymbol = typeSym;
var destSyntaxRef = typeSym.DeclaringSyntaxReferences.FirstOrDefault();
if (destSyntaxRef == null) {
throw new McpException($"Could not find syntax reference for destination type '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
}
var destNode = await destSyntaxRef.GetSyntaxAsync(cancellationToken);
destinationDocument = ToolHelpers.GetDocumentFromSyntaxNodeOrThrow(currentSolution, destNode);
} else if (destinationSymbol is INamespaceSymbol nsSym) {
destinationNamespaceSymbol = nsSym;
var projectForDestination = currentSolution.GetDocument(sourceDocument.Id)!.Project;
var existingDoc = await FindExistingDocumentWithNamespaceAsync(projectForDestination, nsSym, cancellationToken);
if (existingDoc != null) {
destinationDocument = existingDoc;
} else {
var newDoc = await CreateDocumentForNamespaceAsync(projectForDestination, nsSym, cancellationToken);
destinationDocument = newDoc;
currentSolution = newDoc.Project.Solution; // Update currentSolution after adding a document
}
} else {
throw new McpException("Invalid destination symbol type.");
}
if (sourceDocument.Id == destinationDocument.Id && sourceMemberSymbol.ContainingSymbol.Equals(destinationSymbol, SymbolEqualityComparer.Default)) {
throw new McpException($"Source and destination are the same. Member '{fullyQualifiedMemberName}' is already in '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
}
string memberName = GetMemberName(memberDeclaration);
INamedTypeSymbol? updatedDestinationTypeSymbol = null;
if (destinationTypeSymbol != null) {
// Re-resolve destinationTypeSymbol from the potentially updated currentSolution
var destinationDocumentFromCurrentSolution = currentSolution.GetDocument(destinationDocument.Id)
?? throw new McpException($"Destination document '{destinationDocument.FilePath}' not found in current solution for symbol re-resolution.");
var tempDestSymbol = await SymbolFinder.FindSymbolAtPositionAsync(destinationDocumentFromCurrentSolution, destinationTypeSymbol.Locations.First().SourceSpan.Start, cancellationToken);
updatedDestinationTypeSymbol = tempDestSymbol as INamedTypeSymbol;
if (updatedDestinationTypeSymbol == null) {
throw new McpException($"Could not re-resolve destination type symbol '{destinationTypeSymbol.ToDisplayString()}' in the current solution state at file '{destinationDocumentFromCurrentSolution.FilePath}'. Original location span: {destinationTypeSymbol.Locations.First().SourceSpan}");
}
}
if (updatedDestinationTypeSymbol != null && !IsMemberAllowed(updatedDestinationTypeSymbol, memberDeclaration, memberName, cancellationToken)) {
throw new McpException($"A member with the name '{memberName}' already exists in destination type '{fullyQualifiedDestinationTypeOrNamespaceName}'.");
}
try {
var actualDestinationDocument = currentSolution.GetDocument(destinationDocument.Id)
?? throw new McpException($"Destination document '{destinationDocument.FilePath}' not found in current solution before adding member.");
if (updatedDestinationTypeSymbol != null) {
currentSolution = await modificationService.AddMemberAsync(actualDestinationDocument.Id, updatedDestinationTypeSymbol, memberDeclaration, -1, cancellationToken);
} else {
if (destinationNamespaceSymbol == null) throw new McpException("Destination namespace symbol is null when expected for namespace move.");
currentSolution = await AddMemberToNamespaceAsync(actualDestinationDocument, destinationNamespaceSymbol, memberDeclaration, modificationService, cancellationToken);
}
// Re-acquire source document and node from the *new* currentSolution
var sourceDocumentInCurrentSolution = currentSolution.GetDocument(sourceDocument.Id)
?? throw new McpException("Source document not found in current solution after adding member to destination.");
var syntaxRootOfSourceInCurrentSolution = await sourceDocumentInCurrentSolution.GetSyntaxRootAsync(cancellationToken)
?? throw new McpException("Could not get syntax root for source document in current solution.");
// Attempt to find the node again. Its span might have changed if the destination was in the same file.
var sourceMemberNodeInCurrentTree = syntaxRootOfSourceInCurrentSolution.FindNode(sourceMemberNode.Span, findInsideTrivia: true, getInnermostNodeForTie: true);
if (sourceMemberNodeInCurrentTree == null || !(sourceMemberNodeInCurrentTree is MemberDeclarationSyntax)) {
// Fallback: Try to find by kind and name if span-based lookup failed (e.g. due to formatting changes or other modifications)
sourceMemberNodeInCurrentTree = syntaxRootOfSourceInCurrentSolution
.DescendantNodes()
.OfType<MemberDeclarationSyntax>()
.FirstOrDefault(m => m.Kind() == memberDeclaration.Kind() && GetMemberName(m) == memberName);
if (sourceMemberNodeInCurrentTree == null) {
logger.LogWarning("Could not precisely re-locate source member node by original span or by kind/name after destination add. Original span: {Span}. Member kind: {Kind}, Name: {Name}. File: {File}", sourceMemberNode.Span, memberDeclaration.Kind(), memberName, sourceDocumentInCurrentSolution.FilePath);
// As a last resort, if the original node is still part of the new tree (by reference), use it.
// This is risky if the tree has been significantly changed, but better than failing if it's just minor formatting.
if (syntaxRootOfSourceInCurrentSolution.DescendantNodes().Contains(sourceMemberNode)) {
sourceMemberNodeInCurrentTree = sourceMemberNode;
logger.LogWarning("Fallback: Using original source member node reference for removal. This might be risky if tree changed significantly.");
} else {
throw new McpException($"Critically failed to re-locate source member node '{memberName}' in '{sourceDocumentInCurrentSolution.FilePath}' for removal after modifications. Original span {sourceMemberNode.Span}. This usually indicates significant tree changes that broke span tracking or the member was unexpectedly altered or removed.");
}
} else {
logger.LogInformation("Re-located source member node by kind and name for removal. Original span: {OriginalSpan}, New span: {NewSpan}", sourceMemberNode.Span, sourceMemberNodeInCurrentTree.Span);
}
}
currentSolution = await RemoveMemberFromParentAsync(sourceDocumentInCurrentSolution, sourceMemberNodeInCurrentTree, modificationService, cancellationToken);
string finalCommitMessage = $"Move {memberName} to {fullyQualifiedDestinationTypeOrNamespaceName}: {commitMessage}";
await modificationService.ApplyChangesAsync(currentSolution, cancellationToken, finalCommitMessage);
// After ApplyChangesAsync, the solutionManager.CurrentSolution should be the most up-to-date.
// Re-fetch documents from there for final error checking.
var finalSourceDocumentAfterApply = solutionManager.CurrentSolution?.GetDocument(sourceDocument.Id);
var finalDestinationDocumentAfterApply = solutionManager.CurrentSolution?.GetDocument(destinationDocument.Id);
StringBuilder errorBuilder = new StringBuilder("<errorCheck>");
if (finalSourceDocumentAfterApply != null) {
var (sourceHasErrors, sourceErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(solutionManager, finalSourceDocumentAfterApply, logger, cancellationToken);
errorBuilder.AppendLine(sourceHasErrors
? $"Issues in source file {finalSourceDocumentAfterApply.FilePath ?? "unknown"}:\n{sourceErrorMessages}\n"
: $"No compilation errors detected in source file {finalSourceDocumentAfterApply.FilePath ?? "unknown"}.");
}
if (finalDestinationDocumentAfterApply != null && (!finalSourceDocumentAfterApply?.Id.Equals(finalDestinationDocumentAfterApply.Id) ?? true)) {
var (destHasErrors, destErrorMessages) = await ContextInjectors.CheckCompilationErrorsAsync(solutionManager, finalDestinationDocumentAfterApply, logger, cancellationToken);
errorBuilder.AppendLine(destHasErrors
? $"Issues in destination file {finalDestinationDocumentAfterApply.FilePath ?? "unknown"}:\n{destErrorMessages}\n"
: $"No compilation errors detected in destination file {finalDestinationDocumentAfterApply.FilePath ?? "unknown"}.");
}
errorBuilder.AppendLine("</errorCheck>");
var sourceFilePathDisplay = finalSourceDocumentAfterApply?.FilePath ?? sourceDocument.FilePath ?? "unknown source file";
var destinationFilePathDisplay = finalDestinationDocumentAfterApply?.FilePath ?? destinationDocument.FilePath ?? "unknown destination file";
var locationInfo = sourceFilePathDisplay == destinationFilePathDisplay
? $"within {sourceFilePathDisplay}"
: $"from {sourceFilePathDisplay} to {destinationFilePathDisplay}";
return $"Successfully moved member '{memberName}' to '{fullyQualifiedDestinationTypeOrNamespaceName}' {locationInfo}.\n\n{errorBuilder}";
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Failed to move member {MemberName} to {DestinationName}", fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName);
throw new McpException($"Failed to move member '{fullyQualifiedMemberName}' to '{fullyQualifiedDestinationTypeOrNamespaceName}': {ex.Message}", ex);
}
}, logger, nameof(MoveMember), cancellationToken);
}
/// <summary>
/// Finds an existing document in the project that contains the specified namespace.
/// </summary>
private static async Task<Document?> FindExistingDocumentWithNamespaceAsync(Project project, INamespaceSymbol namespaceSymbol, CancellationToken cancellationToken) {
var namespaceName = namespaceSymbol.ToDisplayString();
foreach (var document in project.Documents) {
if (document.FilePath?.EndsWith(".cs") != true) continue;
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root is CompilationUnitSyntax compilationUnit) {
// Check if this document already contains the target namespace
var hasNamespace = compilationUnit.Members
.OfType<NamespaceDeclarationSyntax>()
.Any(n => n.Name.ToString() == namespaceName);
if (hasNamespace || (namespaceSymbol.IsGlobalNamespace && compilationUnit.Members.Any())) {
return document;
}
}
}
return null;
}
/// <summary>
/// Creates a new document for the specified namespace.
/// </summary>
private static Task<Document> CreateDocumentForNamespaceAsync(Project project, INamespaceSymbol namespaceSymbol, CancellationToken cancellationToken) {
var namespaceName = namespaceSymbol.ToDisplayString();
var fileName = string.IsNullOrEmpty(namespaceName) || namespaceSymbol.IsGlobalNamespace
? "GlobalNamespace.cs"
: $"{namespaceName.Split('.').Last()}.cs";
// Ensure the file name doesn't conflict with existing files
var baseName = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
var counter = 1;
var projectDirectory = Path.GetDirectoryName(project.FilePath) ?? throw new InvalidOperationException("Project directory not found");
var fullPath = Path.Combine(projectDirectory, fileName);
while (project.Documents.Any(d => string.Equals(d.FilePath, fullPath, StringComparison.OrdinalIgnoreCase))) {
fileName = $"{baseName}{counter}{extension}";
fullPath = Path.Combine(projectDirectory, fileName);
counter++;
}
// Create basic content for the new file
var content = namespaceSymbol.IsGlobalNamespace
? "// Global namespace file\n"
: $"namespace {namespaceName} {{\n // Namespace content\n}}\n";
var newDocument = project.AddDocument(fileName, content, filePath: fullPath);
return Task.FromResult(newDocument);
}
/// <summary>
/// Adds a member to the specified namespace in the given document.
/// </summary>
private static async Task<Solution> AddMemberToNamespaceAsync(Document document, INamespaceSymbol namespaceSymbol, MemberDeclarationSyntax memberDeclaration, ICodeModificationService modificationService, CancellationToken cancellationToken) {
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root is not CompilationUnitSyntax compilationUnit) {
throw new McpException("Destination document does not have a valid compilation unit.");
}
var editor = await DocumentEditor.CreateAsync(document, cancellationToken);
if (namespaceSymbol.IsGlobalNamespace) {
// Add to global namespace (compilation unit)
editor.AddMember(compilationUnit, memberDeclaration);
} else {
// Find or create the target namespace
var namespaceName = namespaceSymbol.ToDisplayString();
var targetNamespace = compilationUnit.Members
.OfType<NamespaceDeclarationSyntax>()
.FirstOrDefault(n => n.Name.ToString() == namespaceName);
if (targetNamespace == null) {
// Create the namespace and add the member to it
targetNamespace = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName(namespaceName))
.AddMembers(memberDeclaration);
editor.AddMember(compilationUnit, targetNamespace);
} else {
// Add member to existing namespace
editor.AddMember(targetNamespace, memberDeclaration);
}
}
var changedDocument = editor.GetChangedDocument();
var formattedDocument = await modificationService.FormatDocumentAsync(changedDocument, cancellationToken);
return formattedDocument.Project.Solution;
}
/// <summary>
/// Properly removes a member from its parent by deleting it from the parent's member collection.
/// </summary>
private static async Task<Solution> RemoveMemberFromParentAsync(Document document, SyntaxNode memberNode, ICodeModificationService modificationService, CancellationToken cancellationToken) {
if (memberNode is not MemberDeclarationSyntax memberDeclaration) {
throw new McpException($"Node is not a member declaration: {memberNode.GetType().Name}");
}
var root = await document.GetSyntaxRootAsync(cancellationToken);
if (root == null) {
throw new McpException("Could not get syntax root from document.");
}
SyntaxNode newRoot;
if (memberNode.Parent is CompilationUnitSyntax compilationUnit) {
// Handle top-level members in the compilation unit
var newMembers = compilationUnit.Members.Remove(memberDeclaration);
newRoot = compilationUnit.WithMembers(newMembers);
} else if (memberNode.Parent is NamespaceDeclarationSyntax namespaceDecl) {
// Handle members in a namespace
var newMembers = namespaceDecl.Members.Remove(memberDeclaration);
var newNamespace = namespaceDecl.WithMembers(newMembers);
newRoot = root.ReplaceNode(namespaceDecl, newNamespace);
} else if (memberNode.Parent is TypeDeclarationSyntax typeDecl) {
// Handle members in a type declaration (class, struct, interface, etc.)
var newMembers = typeDecl.Members.Remove(memberDeclaration);
var newType = typeDecl.WithMembers(newMembers);
newRoot = root.ReplaceNode(typeDecl, newType);
} else {
throw new McpException($"Cannot remove member from parent of type {memberNode.Parent?.GetType().Name ?? "null"}.");
}
var newDocument = document.WithSyntaxRoot(newRoot);
var formattedDocument = await modificationService.FormatDocumentAsync(newDocument, cancellationToken);
return formattedDocument.Project.Solution;
}
private static async Task<(bool changed, string diff)> ProcessNonCodeFile(
string filePath,
string regexPattern,
string replacementText,
IDocumentOperationsService documentOperations,
List<string> modifiedFiles,
CancellationToken cancellationToken) {
try {
var (originalContent, _) = await documentOperations.ReadFileAsync(filePath, false, cancellationToken);
var regex = new Regex(regexPattern, RegexOptions.Multiline);
string newContent = regex.Replace(originalContent.NormalizeEndOfLines(), replacementText);
// Only write if content changed
if (newContent != originalContent) {
// Note: we don't pass commit message here as we'll handle Git at a higher level
// for all modified non-code files at once
await documentOperations.WriteFileAsync(filePath, newContent, true, cancellationToken, string.Empty);
modifiedFiles.Add(filePath);
// Generate diff
string diff = ContextInjectors.CreateCodeDiff(originalContent, newContent);
return (true, diff);
}
return (false, string.Empty);
} catch (Exception ex) {
throw new McpException($"Error processing non-code file {filePath}: {ex.Message}");
}
}
}
```
--------------------------------------------------------------------------------
/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs:
--------------------------------------------------------------------------------
```csharp
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
using ModelContextProtocol;
using SharpTools.Tools.Services;
using System.Text.Json;
namespace SharpTools.Tools.Mcp.Tools;
// Marker class for ILogger<T> category specific to AnalysisTools
public class AnalysisToolsLogCategory { }
[McpServerToolType]
public static partial class AnalysisTools {
//Disabled for now, seems unnecessary
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(GetAllSubtypes), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Recursively lists all nested types, methods, properties, fields, and enums within a given parent type. Ideal for gaining a complete mental model of a type hierarchy at a glance.")]
public static async Task<object> GetAllSubtypes(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The fully qualified name of the parent type (e.g., MyNamespace.MyClass).")] string fullyQualifiedParentTypeName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedParentTypeName, "fullyQualifiedParentTypeName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetAllSubtypes));
logger.LogInformation("Executing {GetAllSubtypes} for: {TypeName}", nameof(GetAllSubtypes), fullyQualifiedParentTypeName);
try {
var roslynSymbol = await ToolHelpers.GetRoslynNamedTypeSymbolOrThrowAsync(solutionManager, fullyQualifiedParentTypeName, cancellationToken);
return ToolHelpers.ToJson(await BuildRoslynSubtypeTreeAsync(roslynSymbol, codeAnalysisService, cancellationToken));
} catch (McpException ex) {
logger.LogDebug(ex, "Roslyn symbol not found for {TypeName}, trying reflection.", fullyQualifiedParentTypeName);
// Fall through to reflection if Roslyn symbol not found or other McpException related to Roslyn.
}
// If Roslyn symbol wasn't found or an McpException occurred, try reflection.
// GetReflectionTypeOrThrowAsync will throw if the type is not found in reflection either.
var reflectionType = await ToolHelpers.GetReflectionTypeOrThrowAsync(solutionManager, fullyQualifiedParentTypeName, cancellationToken);
return ToolHelpers.ToJson(BuildReflectionSubtypeTree(reflectionType, cancellationToken));
}, logger, nameof(GetAllSubtypes), cancellationToken);
}
internal static async Task<object> BuildRoslynSubtypeTreeAsync(INamedTypeSymbol typeSymbol, ICodeAnalysisService codeAnalysisService, CancellationToken cancellationToken) {
var membersByKind = new Dictionary<string, List<object>>();
foreach (var member in typeSymbol.GetMembers()) {
cancellationToken.ThrowIfCancellationRequested();
if (member.IsImplicitlyDeclared || member.Kind == SymbolKind.ErrorType || ToolHelpers.IsPropertyAccessor(member)) {
continue;
}
var locationInfo = GetDeclarationLocationInfo(member);
var memberLocation = locationInfo.FirstOrDefault();
var kind = ToolHelpers.GetSymbolKindString(member);
// Create an entry for this kind if it doesn't exist
if (!membersByKind.ContainsKey(kind)) {
membersByKind[kind] = new List<object>();
}
var memberInfo = new {
signature = ToolHelpers.GetRoslynSymbolModifiersString(member) + " " + CodeAnalysisService.GetFormattedSignatureAsync(member, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(member),
line = memberLocation?.StartLine,
members = member is INamedTypeSymbol nestedType ?
await BuildRoslynSubtypeTreeAsync(nestedType, codeAnalysisService, cancellationToken) : null
};
membersByKind[kind].Add(memberInfo);
}
// Sort members within each kind by signature
foreach (var kind in membersByKind.Keys.ToList()) {
membersByKind[kind] = membersByKind[kind]
.OrderBy(m => ((dynamic)m).line)
.OrderBy(m => ((dynamic)m).signature)
.ToList();
}
// Sort kinds in a logical order: Nested Types, Fields, Properties, Events, Methods
var orderedKinds = membersByKind.Keys
.OrderBy(k => k switch {
"Class" => 1,
"Interface" => 1,
"Struct" => 1,
"Enum" => 1,
"Field" => 2,
"Property" => 3,
"Event" => 4,
"Method" => 5,
_ => 99
})
.ThenBy(k => k)
.ToDictionary(k => k, k => membersByKind[k]);
var locations = GetDeclarationLocationInfo(typeSymbol);
// For partial classes, we may have multiple locations
object? location = locations.Count > 1 ? locations : locations.FirstOrDefault();
return new {
kind = ToolHelpers.GetSymbolKindString(typeSymbol),
signature = ToolHelpers.GetRoslynTypeSpecificModifiersString(typeSymbol) + " " +
typeSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(typeSymbol),
location = location,
membersByKind = orderedKinds
};
}
private static object BuildReflectionSubtypeTree(Type type, CancellationToken cancellationToken) {
var members = new List<object>();
foreach (var memberInfo in type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.Static |
BindingFlags.DeclaredOnly)) {
cancellationToken.ThrowIfCancellationRequested();
// Skip property/event accessors to reduce noise
if (memberInfo is MethodInfo mi && mi.IsSpecialName &&
(mi.Name.StartsWith("get_") || mi.Name.StartsWith("set_") ||
mi.Name.StartsWith("add_") || mi.Name.StartsWith("remove_"))) {
continue;
}
var memberItem = new {
kind = ToolHelpers.GetReflectionMemberTypeKindString(memberInfo),
signature = ToolHelpers.GetReflectionMemberModifiersString(memberInfo) + " " + memberInfo.ToString(),
members = memberInfo is Type nestedType ? BuildReflectionSubtypeTree(nestedType, cancellationToken) : null
};
members.Add(memberItem);
}
members = members.OrderBy(m => ((dynamic)m).kind)
.ThenBy(m => ((dynamic)m).signature)
.GroupBy(m => ((dynamic)m).kind)
.Select(g => (object)new {
kind = g.Key,
members = g.Select(m => new {
((dynamic)m).signature,
((dynamic)m).members
}).ToList()
})
.ToList();
return new {
kind = ToolHelpers.GetReflectionTypeKindString(type),
signature = ToolHelpers.GetReflectionTypeModifiersString(type) + " " + type.FullName,
members
};
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(GetMembers), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Lists the full signatures of members of a specified type, including XML documentation. Essential for rapidly understanding a type's API, but does not give you the implementations. Use this like Intellisense when you're writing code which depends on the target class.")]
public static async Task<object> GetMembers(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The fully qualified name of the type.")] string fullyQualifiedTypeName,
[Description("If true, includes private members; otherwise, only public/internal/protected members.")] bool includePrivateMembers,
CancellationToken cancellationToken = default) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, nameof(fullyQualifiedTypeName), logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetMembers));
logger.LogInformation("Executing '{GetMembers}' for: {TypeName} (IncludePrivate: {IncludePrivate})",
nameof(GetMembers), fullyQualifiedTypeName, includePrivateMembers);
try {
var roslynSymbol = await ToolHelpers.GetRoslynNamedTypeSymbolOrThrowAsync(solutionManager, fullyQualifiedTypeName, cancellationToken);
string typeName = ToolHelpers.RemoveGlobalPrefix(roslynSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal));
var membersByLocation = new Dictionary<string, Dictionary<string, List<string>>>();
var defaultLocation = "Unknown Location";
foreach (var member in roslynSymbol.GetMembers()) {
cancellationToken.ThrowIfCancellationRequested();
if (member.IsImplicitlyDeclared || ToolHelpers.IsPropertyAccessor(member)) {
continue;
}
bool shouldInclude = includePrivateMembers ||
member.DeclaredAccessibility == Accessibility.Public ||
member.DeclaredAccessibility == Accessibility.Internal ||
member.DeclaredAccessibility == Accessibility.Protected ||
member.DeclaredAccessibility == Accessibility.ProtectedAndInternal ||
member.DeclaredAccessibility == Accessibility.ProtectedOrInternal;
if (shouldInclude) {
try {
var locationInfo = GetDeclarationLocationInfo(member);
var location = locationInfo.FirstOrDefault();
var locationKey = location != null
? location.FilePath
: defaultLocation;
var kind = ToolHelpers.GetSymbolKindString(member);
string xmlDocs = await codeAnalysisService.GetXmlDocumentationAsync(member, cancellationToken) ?? string.Empty;
string signature = ToolHelpers.GetRoslynSymbolModifiersString(member)
+ " " + (CodeAnalysisService.GetFormattedSignatureAsync(member, false)).Replace(typeName + ".", string.Empty).Trim()
+ "//FQN: " + FuzzyFqnLookupService.GetSearchableString(member);
if (!string.IsNullOrEmpty(xmlDocs)) {
signature = xmlDocs + "\n" + signature;
}
string memberInfo = signature;
if (!membersByLocation.ContainsKey(locationKey)) {
membersByLocation[locationKey] = new Dictionary<string, List<string>>();
}
var membersByKind = membersByLocation[locationKey];
if (!membersByKind.ContainsKey(kind)) {
membersByKind[kind] = new List<string>();
}
membersByKind[kind].Add(memberInfo);
} catch (Exception ex) {
logger.LogWarning(ex, "Error retrieving details for member {MemberName} in type {TypeName}",
member.Name, fullyQualifiedTypeName);
var memberInfo = ToolHelpers.GetRoslynSymbolModifiersString(member) + " " + member.ToDisplayString();
var kind = ToolHelpers.GetSymbolKindString(member);
if (!membersByLocation.ContainsKey(defaultLocation)) {
membersByLocation[defaultLocation] = new Dictionary<string, List<string>>();
}
var membersByKind = membersByLocation[defaultLocation];
if (!membersByKind.ContainsKey(kind)) {
membersByKind[kind] = new List<string>();
}
membersByKind[kind].Add(memberInfo);
}
}
}
var typeLocations = GetDeclarationLocationInfo(roslynSymbol);
return ToolHelpers.ToJson(new {
typeName = typeName,
note = $"Use {ToolHelpers.SharpToolPrefix}{nameof(ViewDefinition)} to view the full source code of the types or members.",
locations = typeLocations,
includesPrivateMembers = includePrivateMembers,
membersByLocation = membersByLocation
});
} catch (McpException ex) {
logger.LogDebug(ex, "Roslyn symbol not found for {TypeName} or error occurred, trying reflection.", fullyQualifiedTypeName);
// Fall through to reflection if Roslyn symbol not found or other McpException related to Roslyn.
}
var reflectionType = await ToolHelpers.GetReflectionTypeOrThrowAsync(solutionManager, fullyQualifiedTypeName, cancellationToken);
return GetReflectionTypeMembersAsync(reflectionType, includePrivateMembers, logger, cancellationToken);
}, logger, nameof(GetMembers), cancellationToken);
}
private static object GetReflectionTypeMembersAsync(
Type reflectionType,
bool includePrivateMembers,
ILogger<AnalysisToolsLogCategory> logger,
CancellationToken cancellationToken) {
var apiMembers = new List<object>();
try {
foreach (var memberInfo in reflectionType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.Static |
BindingFlags.DeclaredOnly)) {
cancellationToken.ThrowIfCancellationRequested();
// Skip property and event accessors which are exposed as separate methods
if (memberInfo is MethodInfo mi && mi.IsSpecialName &&
(mi.Name.StartsWith("get_") || mi.Name.StartsWith("set_") ||
mi.Name.StartsWith("add_") || mi.Name.StartsWith("remove_"))) {
continue;
}
// Determine if the member should be included based on its accessibility
bool shouldInclude;
string accessibilityString = "";
try {
switch (memberInfo) {
case FieldInfo fi:
shouldInclude = includePrivateMembers || fi.IsPublic || fi.IsAssembly || fi.IsFamily || fi.IsFamilyOrAssembly;
accessibilityString = ToolHelpers.GetReflectionMemberModifiersString(fi);
break;
case MethodBase mb:
shouldInclude = includePrivateMembers || mb.IsPublic || mb.IsAssembly || mb.IsFamily || mb.IsFamilyOrAssembly;
accessibilityString = ToolHelpers.GetReflectionMemberModifiersString(mb);
break;
case PropertyInfo pi:
var getter = pi.GetGetMethod(true);
shouldInclude = includePrivateMembers; // Default for properties with no accessor
if (getter != null) {
shouldInclude = includePrivateMembers || getter.IsPublic || getter.IsAssembly || getter.IsFamily || getter.IsFamilyOrAssembly;
}
accessibilityString = ToolHelpers.GetReflectionMemberModifiersString(pi);
break;
case EventInfo ei:
var adder = ei.GetAddMethod(true);
shouldInclude = includePrivateMembers; // Default for events with no accessor
if (adder != null) {
shouldInclude = includePrivateMembers || adder.IsPublic || adder.IsAssembly || adder.IsFamily || adder.IsFamilyOrAssembly;
}
accessibilityString = ToolHelpers.GetReflectionMemberModifiersString(ei);
break;
case Type nt: // Nested Type
shouldInclude = includePrivateMembers || nt.IsPublic || nt.IsNestedPublic || nt.IsNestedAssembly ||
nt.IsNestedFamily || nt.IsNestedFamORAssem;
accessibilityString = ToolHelpers.GetReflectionMemberModifiersString(nt);
break;
default:
shouldInclude = includePrivateMembers;
break;
}
if (shouldInclude) {
apiMembers.Add(new {
name = memberInfo.Name,
kind = ToolHelpers.GetReflectionMemberTypeKindString(memberInfo),
modifiers = accessibilityString,
signature = memberInfo.ToString(),
});
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error processing reflection member {MemberName}", memberInfo.Name);
// Add with partial information
if (includePrivateMembers) {
apiMembers.Add(new {
name = memberInfo.Name,
kind = "Unknown",
modifiers = "Error",
signature = $"{memberInfo.Name} (Error: {ex.Message})",
});
}
}
}
} catch (Exception ex) {
logger.LogError(ex, "Error retrieving members for reflection type {TypeName}", reflectionType.FullName);
throw new McpException($"Failed to retrieve members for type '{reflectionType.FullName}': {ex.Message}");
}
return ToolHelpers.ToJson(new {
typeName = reflectionType.FullName,
source = "Reflection",
includesPrivateMembers = includePrivateMembers,
members = apiMembers.OrderBy(m => ((dynamic)m).kind).ThenBy(m => ((dynamic)m).name).ToList()
});
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ViewDefinition), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Displays the verbatim source code from the declaration of a target symbol (class, method, property, etc.) with indentation omitted to save tokens. Essential to fully understand a specific implementation without opening files.")]
public static async Task<string> ViewDefinition(
ISolutionManager solutionManager,
ILogger<AnalysisToolsLogCategory> logger,
ICodeAnalysisService codeAnalysisService,
ISourceResolutionService sourceResolutionService,
[Description("The fully qualified name of the symbol (type, method, property, etc.).")] string fullyQualifiedSymbolName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewDefinition));
logger.LogInformation("Executing '{ViewDefinition}' for: {SymbolName}", nameof(ViewDefinition), fullyQualifiedSymbolName);
// Get the symbol or throw an exception if not found
var roslynSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
var solution = solutionManager.CurrentSolution;
var locations = roslynSymbol.Locations.Where(l => l.IsInSource).ToList();
if (!locations.Any()) {
// No source locations found in the solution, try to resolve from external sources
logger.LogInformation("No source locations found for '{SymbolName}' in the current solution, attempting external source resolution", fullyQualifiedSymbolName);
var sourceResult = await sourceResolutionService.ResolveSourceAsync(roslynSymbol, cancellationToken);
if (sourceResult != null) {
// Add relevant reference context based on the symbol type
var externalReferenceContext = roslynSymbol switch {
Microsoft.CodeAnalysis.INamedTypeSymbol type => await ContextInjectors.CreateTypeReferenceContextAsync(codeAnalysisService, logger, type, cancellationToken),
Microsoft.CodeAnalysis.IMethodSymbol method => await ContextInjectors.CreateCallGraphContextAsync(codeAnalysisService, logger, method, cancellationToken),
_ => string.Empty
};
// Remove leading whitespace from each line of the resolved source
var sourceLines = sourceResult.Source.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
for (int i = 0; i < sourceLines.Length; i++) {
sourceLines[i] = TrimLeadingWhitespace(sourceLines[i]);
}
var formattedSource = string.Join(Environment.NewLine, sourceLines);
return $"<definition>\n<referencingTypes>\n{externalReferenceContext}\n</referencingTypes>\n<code file='{sourceResult.FilePath}' source='External - {sourceResult.ResolutionMethod}'>\n{formattedSource}\n</code>\n</definition>";
}
throw new McpException($"No source definition found for '{fullyQualifiedSymbolName}'. The symbol might be defined in metadata (compiled assembly) only and couldn't be decompiled.");
}
// Check if this is a partial type with multiple declarations
var isPartialType = roslynSymbol is Microsoft.CodeAnalysis.INamedTypeSymbol namedTypeSymbol &&
roslynSymbol.DeclaringSyntaxReferences.Count() > 1;
if (isPartialType) {
// Handle partial types by collecting all partial declarations
return await HandlePartialTypeDefinitionAsync(roslynSymbol, solution, codeAnalysisService, logger, cancellationToken);
} else {
// Handle single declaration (non-partial or single-part symbols)
return await HandleSingleDefinitionAsync(roslynSymbol, solution, locations.First(), codeAnalysisService, logger, cancellationToken);
}
}, logger, nameof(ViewDefinition), cancellationToken);
}
private static string TrimLeadingWhitespace(string line) {
int index = 0;
while (index < line.Length && char.IsWhiteSpace(line[index])) {
index++;
}
return index < line.Length ? line.Substring(index) : string.Empty;
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ListImplementations), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Gets the locations and FQNs of all implementations of an interface or abstract method, and lists derived classes for a base class. Crucial for navigating polymorphic code and understanding implementation patterns.")]
public static async Task<object> ListImplementations(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The fully qualified name of the interface, abstract method, or base class.")] string fullyQualifiedSymbolName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ListImplementations));
logger.LogInformation("Executing '{ViewImplementations}' for: {SymbolName}",
nameof(ListImplementations), fullyQualifiedSymbolName);
var implementations = new List<object>();
var roslynSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
try {
if (roslynSymbol is INamedTypeSymbol namedTypeSymbol) {
if (namedTypeSymbol.TypeKind == TypeKind.Interface) {
var implementingSymbols = await codeAnalysisService.FindImplementationsAsync(namedTypeSymbol, cancellationToken);
foreach (var impl in implementingSymbols.OfType<INamedTypeSymbol>()) {
cancellationToken.ThrowIfCancellationRequested();
implementations.Add(new {
kind = ToolHelpers.GetSymbolKindString(impl),
signature = CodeAnalysisService.GetFormattedSignatureAsync(impl, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(impl),
location = GetDeclarationLocationInfo(impl).FirstOrDefault()
});
}
} else if (namedTypeSymbol.IsAbstract || namedTypeSymbol.TypeKind == TypeKind.Class) {
var derivedClasses = await codeAnalysisService.FindDerivedClassesAsync(namedTypeSymbol, cancellationToken);
foreach (var derived in derivedClasses) {
cancellationToken.ThrowIfCancellationRequested();
implementations.Add(new {
kind = ToolHelpers.GetSymbolKindString(derived),
signature = CodeAnalysisService.GetFormattedSignatureAsync(derived, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(derived),
location = GetDeclarationLocationInfo(derived).FirstOrDefault()
});
}
}
} else if (roslynSymbol is IMethodSymbol methodSymbol && (methodSymbol.IsAbstract || methodSymbol.IsVirtual)) {
var overrides = await codeAnalysisService.FindOverridesAsync(methodSymbol, cancellationToken);
foreach (var over in overrides.OfType<IMethodSymbol>()) {
cancellationToken.ThrowIfCancellationRequested();
implementations.Add(new {
kind = ToolHelpers.GetSymbolKindString(over),
signature = CodeAnalysisService.GetFormattedSignatureAsync(over, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(over),
location = GetDeclarationLocationInfo(over).FirstOrDefault()
});
}
} else if (roslynSymbol is IPropertySymbol propSymbol && (propSymbol.IsAbstract || propSymbol.IsVirtual)) {
var overrides = await codeAnalysisService.FindOverridesAsync(propSymbol, cancellationToken);
foreach (var over in overrides.OfType<IPropertySymbol>()) {
cancellationToken.ThrowIfCancellationRequested();
implementations.Add(new {
kind = ToolHelpers.GetSymbolKindString(over),
signature = CodeAnalysisService.GetFormattedSignatureAsync(over, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(over),
location = GetDeclarationLocationInfo(over).FirstOrDefault()
});
}
} else if (roslynSymbol is IEventSymbol eventSymbol && (eventSymbol.IsAbstract || eventSymbol.IsVirtual)) {
var overrides = await codeAnalysisService.FindOverridesAsync(eventSymbol, cancellationToken);
foreach (var over in overrides.OfType<IEventSymbol>()) {
cancellationToken.ThrowIfCancellationRequested();
implementations.Add(new {
kind = ToolHelpers.GetSymbolKindString(over),
signature = CodeAnalysisService.GetFormattedSignatureAsync(over, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(over),
location = GetDeclarationLocationInfo(over).FirstOrDefault()
});
}
} else {
throw new McpException($"Symbol '{fullyQualifiedSymbolName}' is not an interface, abstract/virtual member, or class.");
}
implementations = implementations
.OrderBy(i => ((dynamic)i).signature)
.ToList();
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error finding implementations for symbol {SymbolName}", fullyQualifiedSymbolName);
throw new McpException($"Error finding implementations for symbol '{fullyQualifiedSymbolName}': {ex.Message}");
}
return ToolHelpers.ToJson(new {
kind = ToolHelpers.GetSymbolKindString(roslynSymbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(roslynSymbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(roslynSymbol),
location = GetDeclarationLocationInfo(roslynSymbol).FirstOrDefault(),
implementations
});
}, logger, nameof(ListImplementations), cancellationToken);
}
private static async Task<string> HandlePartialTypeDefinitionAsync(
ISymbol roslynSymbol,
Solution? solution,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
CancellationToken cancellationToken) {
var namedTypeSymbol = (Microsoft.CodeAnalysis.INamedTypeSymbol)roslynSymbol;
var partialDeclarations = new List<object>();
var allSourceCode = new List<string>();
var allFiles = new List<string>();
// Generate reference context for the type
string referenceContext = await ContextInjectors.CreateTypeReferenceContextAsync(codeAnalysisService, logger, namedTypeSymbol, cancellationToken);
foreach (var syntaxRef in roslynSymbol.DeclaringSyntaxReferences) {
if (syntaxRef.SyntaxTree?.FilePath == null) {
continue;
}
var document = solution?.GetDocument(syntaxRef.SyntaxTree);
if (document == null) {
logger.LogWarning("Could not find document for partial declaration in file: {FilePath}", syntaxRef.SyntaxTree.FilePath);
continue;
}
var node = await syntaxRef.GetSyntaxAsync(cancellationToken);
// Find the appropriate parent node that represents the full definition
SyntaxNode? definitionNode = node;
while (definitionNode != null &&
!(definitionNode is MemberDeclarationSyntax) &&
!(definitionNode is TypeDeclarationSyntax)) {
definitionNode = definitionNode.Parent;
}
if (definitionNode == null) {
logger.LogWarning("Could not find definition syntax for partial declaration in file: {FilePath}", syntaxRef.SyntaxTree.FilePath);
continue;
}
var result = definitionNode.ToString();
var filePath = document.FilePath ?? "unknown location";
var lineInfo = definitionNode.GetLocation().GetLineSpan();
// Remove leading whitespace from each line
var lines = result.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
for (int i = 0; i < lines.Length; i++) {
lines[i] = TrimLeadingWhitespace(lines[i]);
}
result = string.Join(Environment.NewLine, lines);
allFiles.Add($"{filePath} (Lines {lineInfo.StartLinePosition.Line + 1} - {lineInfo.EndLinePosition.Line + 1})");
allSourceCode.Add($"<code partialDefinitionFile='{filePath}' lines='{lineInfo.StartLinePosition.Line + 1} - {lineInfo.EndLinePosition.Line + 1}'>\n{result}\n</code>");
}
var combinedSource = string.Join("\n\n", allSourceCode);
var fileList = string.Join(", ", allFiles);
return $"<definition>\n{referenceContext}\n// Partial type found in {allFiles.Count} files: {fileList}\n\n{combinedSource}\n</definition>";
}
private static async Task<string> HandleSingleDefinitionAsync(
ISymbol roslynSymbol,
Solution? solution,
Location sourceLocation,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
CancellationToken cancellationToken) {
if (sourceLocation.SourceTree == null) {
throw new McpException($"Source tree not available for symbol '{roslynSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)}'.");
}
var document = solution?.GetDocument(sourceLocation.SourceTree);
if (document == null) {
throw new McpException($"Could not find document for symbol '{roslynSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)}'.");
}
var symbolSyntax = await sourceLocation.SourceTree.GetRootAsync(cancellationToken);
var node = symbolSyntax.FindNode(sourceLocation.SourceSpan);
if (node == null) {
throw new McpException($"Could not find syntax node for symbol '{roslynSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)}'.");
}
// Find the appropriate parent node that represents the full definition
SyntaxNode? definitionNode = node;
if (roslynSymbol is Microsoft.CodeAnalysis.IMethodSymbol || roslynSymbol is Microsoft.CodeAnalysis.IPropertySymbol ||
roslynSymbol is Microsoft.CodeAnalysis.IFieldSymbol || roslynSymbol is Microsoft.CodeAnalysis.IEventSymbol ||
roslynSymbol is Microsoft.CodeAnalysis.INamedTypeSymbol) {
while (definitionNode != null &&
!(definitionNode is MemberDeclarationSyntax) &&
!(definitionNode is TypeDeclarationSyntax)) {
definitionNode = definitionNode.Parent;
}
}
if (definitionNode == null) {
throw new McpException($"Could not find definition syntax for symbol '{roslynSymbol.ToDisplayString(ToolHelpers.FullyQualifiedFormatWithoutGlobal)}'.");
}
var result = definitionNode.ToString();
var filePath = document.FilePath ?? "unknown location";
var lineInfo = definitionNode.GetLocation().GetLineSpan();
// Generate reference context based on symbol type
string referenceContext = roslynSymbol switch {
Microsoft.CodeAnalysis.INamedTypeSymbol type => await ContextInjectors.CreateTypeReferenceContextAsync(codeAnalysisService, logger, type, cancellationToken),
Microsoft.CodeAnalysis.IMethodSymbol method => await ContextInjectors.CreateCallGraphContextAsync(codeAnalysisService, logger, method, cancellationToken),
_ => string.Empty // TODO: consider adding context for properties, fields, events, etc.
};
// Remove leading whitespace from each line
var lines = result.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
for (int i = 0; i < lines.Length; i++) {
lines[i] = TrimLeadingWhitespace(lines[i]);
}
result = string.Join(Environment.NewLine, lines);
return $"<definition>\n{referenceContext}\n<code file='{filePath}' lines='{lineInfo.StartLinePosition.Line + 1} - {lineInfo.EndLinePosition.Line + 1}'>\n{result}\n</code>\n</definition>";
}
public class LocationInfo {
public string FilePath { get; set; } = string.Empty;
public int StartLine { get; set; }
public int EndLine { get; set; }
}
private static List<LocationInfo> GetDeclarationLocationInfo(ISymbol symbol) {
var locations = new List<LocationInfo>();
foreach (var syntaxRef in symbol.DeclaringSyntaxReferences) {
if (syntaxRef.SyntaxTree?.FilePath == null) {
continue;
}
var node = syntaxRef.GetSyntax();
var fullSpan = node switch {
TypeDeclarationSyntax typeNode => typeNode.GetLocation().GetLineSpan(),
MethodDeclarationSyntax methodNode => methodNode.GetLocation().GetLineSpan(),
PropertyDeclarationSyntax propertyNode => propertyNode.GetLocation().GetLineSpan(),
EventDeclarationSyntax eventNode => eventNode.GetLocation().GetLineSpan(),
LocalFunctionStatementSyntax localFuncNode => localFuncNode.GetLocation().GetLineSpan(),
ConstructorDeclarationSyntax ctorNode => ctorNode.GetLocation().GetLineSpan(),
DestructorDeclarationSyntax dtorNode => dtorNode.GetLocation().GetLineSpan(),
OperatorDeclarationSyntax opNode => opNode.GetLocation().GetLineSpan(),
ConversionOperatorDeclarationSyntax convNode => convNode.GetLocation().GetLineSpan(),
FieldDeclarationSyntax fieldNode => fieldNode.GetLocation().GetLineSpan(),
DelegateDeclarationSyntax delegateNode => delegateNode.GetLocation().GetLineSpan(),
// For any other symbol, use its direct location span
_ => node.GetLocation().GetLineSpan()
};
locations.Add(new LocationInfo {
FilePath = syntaxRef.SyntaxTree.FilePath,
StartLine = fullSpan.StartLinePosition.Line + 1,
EndLine = fullSpan.EndLinePosition.Line + 1
});
}
// If we couldn't get any locations from DeclaringSyntaxReferences (rare), fall back to Locations
if (!locations.Any()) {
foreach (var location in symbol.Locations.Where(l => l.IsInSource)) {
if (location.SourceTree?.FilePath == null) {
continue;
}
var lineSpan = location.GetLineSpan();
locations.Add(new LocationInfo {
FilePath = location.SourceTree.FilePath,
StartLine = lineSpan.StartLinePosition.Line + 1,
EndLine = lineSpan.EndLinePosition.Line + 1
});
}
}
return locations;
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(FindReferences), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Finds all references to a specified symbol with surrounding context. Indentation is omitted to save space. Critical for understanding symbol usage patterns across the codebase before editing the target.")]
public static async Task<object> FindReferences(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The FQN of the symbol.")] string fullyQualifiedSymbolName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindReferences));
logger.LogInformation("Executing '{FindReferences}' for: {SymbolName}",
nameof(FindReferences), fullyQualifiedSymbolName);
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedSymbolName, cancellationToken);
var referencedSymbols = await codeAnalysisService.FindReferencesAsync(symbol, cancellationToken);
var references = new List<object>();
var maxToShow = 20;
int count = 0;
try {
foreach (var refGroup in referencedSymbols) {
foreach (var location in refGroup.Locations) {
cancellationToken.ThrowIfCancellationRequested();
if (count >= maxToShow) break;
if (location.Document != null && location.Location.IsInSource) {
try {
var sourceTree = location.Location.SourceTree;
if (sourceTree == null) {
logger.LogWarning("Null source tree for reference location in {FilePath}",
location.Document.FilePath ?? "unknown file");
continue;
}
var sourceText = await sourceTree.GetTextAsync(cancellationToken);
var lineSpan = location.Location.GetLineSpan();
var contextLines = new List<string>();
const int linesAround = 2;
for (int i = Math.Max(0, lineSpan.StartLinePosition.Line - linesAround);
i <= Math.Min(sourceText.Lines.Count - 1, lineSpan.EndLinePosition.Line + linesAround);
i++) {
contextLines.Add(TrimLeadingWhitespace(sourceText.Lines[i].ToString()));
}
string parentMember = "N/A";
try {
var syntaxRoot = await sourceTree.GetRootAsync(cancellationToken);
var token = syntaxRoot.FindToken(location.Location.SourceSpan.Start);
if (token.Parent != null) {
var memberDecl = token.Parent
.AncestorsAndSelf()
.OfType<MemberDeclarationSyntax>()
.FirstOrDefault();
if (memberDecl != null) {
var semanticModel = await solutionManager.GetSemanticModelAsync(location.Document.Id, cancellationToken);
var parentSymbol = semanticModel?.GetDeclaredSymbol(memberDecl, cancellationToken);
if (parentSymbol != null) {
parentMember = CodeAnalysisService.GetFormattedSignatureAsync(parentSymbol, false) +
$" //FQN: {FuzzyFqnLookupService.GetSearchableString(parentSymbol)}";
}
}
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error getting parent member for reference in {FilePath}",
location.Document.FilePath ?? "unknown file");
}
references.Add(new {
location = new {
filePath = location.Document.FilePath,
startLine = lineSpan.StartLinePosition.Line + 1,
endLine = lineSpan.EndLinePosition.Line + 1,
},
context = string.Join(Environment.NewLine, contextLines),
parentMember
});
count++;
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing reference location in {FilePath}",
location.Document.FilePath ?? "unknown file");
}
}
}
if (count >= maxToShow) break;
}
var totalReferences = referencedSymbols.Sum(rs => rs.Locations.Count());
return ToolHelpers.ToJson(new {
kind = ToolHelpers.GetSymbolKindString(symbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(symbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(symbol),
location = GetDeclarationLocationInfo(symbol).FirstOrDefault(),
totalReferences,
displayedReferences = count,
references = references.OrderBy(r => ((dynamic)r).location.filePath)
.ThenBy(r => ((dynamic)r).location.startLine)
.ToList()
});
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error collecting references for symbol {SymbolName}", fullyQualifiedSymbolName);
if (references.Count > 0) {
logger.LogInformation("Returning partial references ({Count}) for {SymbolName}",
references.Count, fullyQualifiedSymbolName);
return ToolHelpers.ToJson(new {
kind = ToolHelpers.GetSymbolKindString(symbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(symbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(symbol),
location = GetDeclarationLocationInfo(symbol).FirstOrDefault(),
totalReferences = references.Count,
displayedReferences = references.Count,
references = references.OrderBy(r => ((dynamic)r).location.filePath)
.ThenBy(r => ((dynamic)r).location.startLine)
.ToList(),
partialResults = true,
errorMessage = $"Only partial results available due to error: {ex.Message}"
});
}
throw new McpException($"Failed to find references for symbol '{fullyQualifiedSymbolName}': {ex.Message}");
}
}, logger, nameof(FindReferences), cancellationToken);
}
//Disabled for now, didn't seem useful, should be partially included in view definition
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ViewInheritanceChain), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Shows the inheritance hierarchy for a class or interface (base types and derived types). Essential for understanding type relationships and architecture.")]
public static async Task<object> ViewInheritanceChain(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The fully qualified name of the type.")] string fullyQualifiedTypeName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, "fullyQualifiedTypeName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewInheritanceChain));
logger.LogInformation("Executing '{ViewInheritanceChain}' for: {TypeName}",
nameof(ViewInheritanceChain), fullyQualifiedTypeName);
var baseTypes = new List<object>();
var derivedTypes = new List<object>();
bool hasPartialResults = false;
string? errorMessage = null;
try {
var roslynSymbol = await ToolHelpers.GetRoslynNamedTypeSymbolOrThrowAsync(solutionManager, fullyQualifiedTypeName, cancellationToken);
try {
// Get base types
INamedTypeSymbol? currentType = roslynSymbol.BaseType;
while (currentType != null) {
cancellationToken.ThrowIfCancellationRequested();
baseTypes.Add(new {
kind = ToolHelpers.GetSymbolKindString(currentType),
signature = CodeAnalysisService.GetFormattedSignatureAsync(currentType, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(currentType),
location = GetDeclarationLocationInfo(currentType).FirstOrDefault()
});
currentType = currentType.BaseType;
}
// Get interfaces
try {
foreach (var iface in roslynSymbol.Interfaces) {
cancellationToken.ThrowIfCancellationRequested();
baseTypes.Add(new {
kind = ToolHelpers.GetSymbolKindString(iface),
signature = CodeAnalysisService.GetFormattedSignatureAsync(iface, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(iface),
location = GetDeclarationLocationInfo(iface).FirstOrDefault(),
isInterface = true
});
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error retrieving interfaces for type {TypeName}", fullyQualifiedTypeName);
hasPartialResults = true;
errorMessage = $"Could not retrieve all interfaces: {ex.Message}";
}
// Get derived types
try {
if (roslynSymbol.TypeKind == TypeKind.Class || roslynSymbol.TypeKind == TypeKind.Interface) {
bool isInterface = roslynSymbol.TypeKind == TypeKind.Interface;
var derived = isInterface
? await codeAnalysisService.FindImplementationsAsync(roslynSymbol, cancellationToken)
: await codeAnalysisService.FindDerivedClassesAsync(roslynSymbol, cancellationToken);
foreach (var derivedSymbol in derived.OfType<INamedTypeSymbol>()) {
cancellationToken.ThrowIfCancellationRequested();
derivedTypes.Add(new {
kind = ToolHelpers.GetSymbolKindString(derivedSymbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(derivedSymbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(derivedSymbol),
location = GetDeclarationLocationInfo(derivedSymbol).FirstOrDefault()
});
}
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error retrieving derived types for {TypeName}", fullyQualifiedTypeName);
hasPartialResults = true;
errorMessage = errorMessage == null
? $"Could not retrieve all derived types: {ex.Message}"
: $"{errorMessage}; Could not retrieve all derived types: {ex.Message}";
}
baseTypes = baseTypes.OrderBy(t => ((dynamic)t).signature).ToList();
derivedTypes = derivedTypes.OrderBy(t => ((dynamic)t).signature).ToList();
return ToolHelpers.ToJson(new {
kind = ToolHelpers.GetSymbolKindString(roslynSymbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(roslynSymbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(roslynSymbol),
location = GetDeclarationLocationInfo(roslynSymbol).FirstOrDefault(),
baseTypes,
derivedTypes,
hasPartialResults,
errorMessage
});
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error analyzing inheritance chain for Roslyn type {TypeName}", fullyQualifiedTypeName);
throw new McpException($"Error analyzing inheritance chain: {ex.Message}");
}
} catch (McpException ex) {
logger.LogDebug(ex, "Roslyn symbol not found for {TypeName} or error occurred, trying reflection.", fullyQualifiedTypeName);
// Fall through to reflection if Roslyn symbol not found or other McpException related to Roslyn.
}
// Try reflection type if Roslyn symbol processing failed
try {
var reflectionType = await ToolHelpers.GetReflectionTypeOrThrowAsync(solutionManager, fullyQualifiedTypeName, cancellationToken);
try {
// Get base types
var currentBase = reflectionType.BaseType;
while (currentBase != null && currentBase != typeof(object)) {
cancellationToken.ThrowIfCancellationRequested();
baseTypes.Add(new {
kind = ToolHelpers.GetReflectionTypeKindString(currentBase),
signature = ToolHelpers.GetReflectionTypeModifiersString(currentBase) + " " + currentBase.FullName,
assemblyName = currentBase.Assembly.GetName().Name
});
currentBase = currentBase.BaseType;
}
if (reflectionType.BaseType == typeof(object) ||
(reflectionType.IsClass && reflectionType.BaseType == null && reflectionType != typeof(object))) {
baseTypes.Add(new {
kind = "Class",
signature = "public class System.Object",
assemblyName = typeof(object).Assembly.GetName().Name
});
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error retrieving base types for reflection type {TypeName}", fullyQualifiedTypeName);
hasPartialResults = true;
errorMessage = $"Could not retrieve all base types: {ex.Message}";
}
try {
// Get interfaces
foreach (var iface in reflectionType.GetInterfaces()) {
cancellationToken.ThrowIfCancellationRequested();
baseTypes.Add(new {
kind = "Interface",
signature = ToolHelpers.GetReflectionTypeModifiersString(iface) + " " + iface.FullName,
assemblyName = iface.Assembly.GetName().Name,
isInterface = true
});
}
} catch (Exception ex) {
logger.LogWarning(ex, "Error retrieving interfaces for reflection type {TypeName}", fullyQualifiedTypeName);
hasPartialResults = true;
errorMessage = errorMessage == null
? $"Could not retrieve all interfaces: {ex.Message}"
: $"{errorMessage}; Could not retrieve all interfaces: {ex.Message}";
}
derivedTypes.Add(new {
kind = "Note",
signature = "Derived type discovery for pure reflection types is limited in this tool version."
});
baseTypes = baseTypes.OrderBy(t => ((dynamic)t).signature).ToList();
return ToolHelpers.ToJson(new {
kind = reflectionType.IsInterface ? "Interface" : (reflectionType.IsEnum ? "Enum" : "Class"),
signature = ToolHelpers.GetReflectionTypeModifiersString(reflectionType) + " " + reflectionType.FullName,
assemblyName = reflectionType.Assembly.GetName().Name,
baseTypes,
derivedTypes,
hasPartialResults,
errorMessage
});
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error analyzing inheritance chain for reflection type {TypeName}", fullyQualifiedTypeName);
throw new McpException($"Error analyzing inheritance chain via reflection: {ex.Message}");
}
}, logger, nameof(ViewInheritanceChain), cancellationToken);
}
//Disabled for now as it should be included in ViewDefinition
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ViewCallGraph), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Displays methods that call a specific method (incoming) and methods called by it (outgoing). Critical for understanding control flow and method relationships across the codebase.")]
public static async Task<object> ViewCallGraph(
ISolutionManager solutionManager,
ICodeAnalysisService codeAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The FQN of the method.")] string fullyQualifiedMethodName,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMethodName, "fullyQualifiedMethodName", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewCallGraph));
logger.LogInformation("Executing '{ViewCallGraph}' for: {MethodName}",
nameof(ViewCallGraph), fullyQualifiedMethodName);
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMethodName, cancellationToken);
if (symbol is not IMethodSymbol methodSymbol) {
throw new McpException($"Symbol '{fullyQualifiedMethodName}' is not a method.");
}
var incomingCalls = new List<object>();
var outgoingCalls = new List<object>();
bool hasPartialResults = false;
string? errorMessage = null;
try {
// Get incoming calls (callers)
var callers = await codeAnalysisService.FindCallersAsync(methodSymbol, cancellationToken);
foreach (var callerInfo in callers) {
cancellationToken.ThrowIfCancellationRequested();
try {
var callLocations = callerInfo.Locations
.Where(l => l.IsInSource && l.SourceTree != null)
.Select(l => {
var lineSpan = l.GetLineSpan();
return new {
line = lineSpan.StartLinePosition.Line + 1,
};
})
.ToList();
incomingCalls.Add(new {
kind = ToolHelpers.GetSymbolKindString(callerInfo.CallingSymbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(callerInfo.CallingSymbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(callerInfo.CallingSymbol),
location = GetDeclarationLocationInfo(callerInfo.CallingSymbol).FirstOrDefault(),
callLocations
});
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing caller {CallerSymbol} for method {MethodName}",
callerInfo.CallingSymbol.Name, fullyQualifiedMethodName);
hasPartialResults = true;
}
}
// Get outgoing calls (callees)
var outgoingSymbols = await codeAnalysisService.FindOutgoingCallsAsync(methodSymbol, cancellationToken);
foreach (var callee in outgoingSymbols) {
cancellationToken.ThrowIfCancellationRequested();
try {
outgoingCalls.Add(new {
kind = ToolHelpers.GetSymbolKindString(callee),
calleeSignature = CodeAnalysisService.GetFormattedSignatureAsync(callee, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(callee),
location = GetDeclarationLocationInfo(callee).FirstOrDefault()
});
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing callee {CalleeSymbol} for method {MethodName}",
callee.Name, fullyQualifiedMethodName);
hasPartialResults = true;
}
}
incomingCalls = incomingCalls
.OrderBy(c => ((dynamic)c).signature)
.ToList();
outgoingCalls = outgoingCalls
.OrderBy(c => ((dynamic)c).signature)
.ToList();
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error finding call graph for method {MethodName}", fullyQualifiedMethodName);
hasPartialResults = true;
errorMessage = $"Could not retrieve complete call graph: {ex.Message}";
}
return ToolHelpers.ToJson(new {
kind = ToolHelpers.GetSymbolKindString(methodSymbol),
signature = CodeAnalysisService.GetFormattedSignatureAsync(methodSymbol, false),
fullyQualifiedName = FuzzyFqnLookupService.GetSearchableString(methodSymbol),
location = GetDeclarationLocationInfo(methodSymbol).FirstOrDefault(),
incomingCalls,
outgoingCalls,
hasPartialResults,
errorMessage
});
}, logger, nameof(ViewCallGraph), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(SearchDefinitions), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Dual-engine pattern search across source code AND compiled assemblies for public APIs. Perfect for finding all implementations of a pattern - e.g., finding all async methods with 'ConfigureAwait', or all classes implementing IDisposable. Searches declarations, signatures, and type hierarchies.")]
public static async Task<object> SearchDefinitions(
ISolutionManager solutionManager,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The regex pattern to match against full declaration text (multiline) and symbol names.")] string regexPattern,
CancellationToken cancellationToken) {
// Maximum number of search results to return
const int MaxSearchResults = 20;
static bool IsGeneratedCode(string signature) {
return signature.Contains("+<") // Generated closures
|| signature.Contains("<>") // Generated closures and async state machines
|| signature.Contains("+d__") // Async state machines
|| signature.Contains("__Generated") // Generated code marker
|| signature.Contains("<Clone>") // Generated clone methods
|| signature.Contains("<BackingField>") // Generated backing fields
|| signature.Contains(".+") // Generated nested types
|| signature.Contains("$") // Generated interop types
|| signature.Contains("__Backing__") // Generated backing fields
|| (signature.Contains("_") && signature.Contains("<")) // Generated async methods
|| (signature.Contains("+") && signature.Contains("`")); // Generic factory-created types
}
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ErrorHandlingHelpers.ValidateStringParameter(regexPattern, "regexPattern", logger);
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(SearchDefinitions));
logger.LogInformation("Executing '{SearchDefinitions}' with pattern: {RegexPattern}",
nameof(SearchDefinitions), regexPattern);
Regex regex;
try {
regex = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline);
} catch (ArgumentException ex) {
throw new McpException($"Invalid regular expression pattern: {ex.Message}");
}
var matches = new ConcurrentBag<dynamic>();
bool hasPartialResults = false;
var errors = new ConcurrentBag<string>();
int projectsProcessed = 0;
int projectsSkipped = 0;
var matchedNodeSpans = new ConcurrentDictionary<string, HashSet<TextSpan>>();
int totalMatchesFound = 0;
try {
var projectTasks = solutionManager.GetProjects().Select(project => Task.Run(async () => {
try {
cancellationToken.ThrowIfCancellationRequested();
var compilation = await solutionManager.GetCompilationAsync(project.Id, cancellationToken);
if (compilation == null) {
Interlocked.Increment(ref projectsSkipped);
logger.LogWarning("Skipping project {ProjectName}, compilation is null", project.Name);
return;
}
foreach (var syntaxTree in compilation.SyntaxTrees) {
try {
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
cancellationToken.ThrowIfCancellationRequested();
var semanticModel = compilation.GetSemanticModel(syntaxTree);
var sourceText = await syntaxTree.GetTextAsync(cancellationToken);
string filePath = syntaxTree.FilePath ?? "unknown file";
var root = await syntaxTree.GetRootAsync(cancellationToken);
var matchedNodesInFile = new Dictionary<SyntaxNode, List<Match>>();
// First pass: Find all nodes with regex matches
foreach (var node in root.DescendantNodes()
.Where(n => n is MemberDeclarationSyntax or VariableDeclaratorSyntax)) {
cancellationToken.ThrowIfCancellationRequested();
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
try {
string declText = node.ToString();
var declMatches = regex.Matches(declText);
if (declMatches.Count > 0) {
matchedNodesInFile.Add(node, declMatches.Cast<Match>().ToList());
if (!matchedNodeSpans.TryGetValue(filePath, out var spans)) {
spans = new HashSet<TextSpan>();
matchedNodeSpans[filePath] = spans;
}
spans.Add(node.Span);
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogTrace(ex, "Error examining node in {FilePath}", filePath);
hasPartialResults = true;
}
}
// Second pass: Process only nodes that don't have a matched child
foreach (var (node, nodeMatches) in matchedNodesInFile) {
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
try {
if (matchedNodeSpans.TryGetValue(filePath, out var spans) &&
node.DescendantNodes().Any(child => spans.Contains(child.Span) && child != node)) {
continue;
}
ISymbol? symbol = node switch {
MemberDeclarationSyntax mds => semanticModel.GetDeclaredSymbol(mds, cancellationToken),
VariableDeclaratorSyntax vds => semanticModel.GetDeclaredSymbol(vds, cancellationToken),
_ => null
};
if (symbol != null) {
var signature = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (IsGeneratedCode(signature)) {
continue;
}
// Get containing type
var containingType = symbol.ContainingType;
var containingSymbol = symbol.ContainingSymbol;
string parentFqn;
if (containingType != null) {
// For members inside a type
parentFqn = FuzzyFqnLookupService.GetSearchableString(containingType);
if (IsGeneratedCode(parentFqn)) continue;
} else if (containingSymbol != null && containingSymbol.Kind == SymbolKind.Namespace) {
// For top-level types in a namespace
parentFqn = FuzzyFqnLookupService.GetSearchableString(containingSymbol);
} else {
// Fallback for other cases
parentFqn = "global";
}
// Process each match in the declaration
foreach (Match match in nodeMatches) {
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
int matchStartPos = node.SpanStart + match.Index;
var matchLinePos = sourceText.Lines.GetLinePosition(matchStartPos);
int matchLineNumber = matchLinePos.Line + 1;
var matchLine = sourceText.Lines[matchLinePos.Line].ToString().Trim();
if (Interlocked.Increment(ref totalMatchesFound) <= MaxSearchResults) {
matches.Add(new {
kind = ToolHelpers.GetSymbolKindString(symbol),
parentFqn = parentFqn,
signature = signature,
match = matchLine,
location = new {
filePath,
line = matchLineNumber
}
});
} else {
hasPartialResults = true;
break;
}
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogTrace(ex, "Error processing syntax node in {FilePath}", filePath);
hasPartialResults = true;
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing syntax tree {FilePath}",
syntaxTree.FilePath ?? "unknown file");
hasPartialResults = true;
}
}
Interlocked.Increment(ref projectsProcessed);
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
Interlocked.Increment(ref projectsSkipped);
logger.LogWarning(ex, "Error processing project {ProjectName}", project.Name);
hasPartialResults = true;
errors.Add($"Error in project {project.Name}: {ex.Message}");
}
}, cancellationToken));
await Task.WhenAll(projectTasks);
} catch (Exception ex) when (!(ex is McpException || ex is OperationCanceledException)) {
logger.LogError(ex, "Error searching Roslyn symbols with pattern {Pattern}", regexPattern);
hasPartialResults = true;
errors.Add($"Error searching source code symbols: {ex.Message}");
}
// Process reflection types in parallel with timeout
var reflectionSearchTask = Task.Run(async () => {
try {
// If already at limit, skip reflection search
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
return;
}
//help in common case where it is looking for a class definition, but reflections don't have the 'class' keyword
string reflectionPattern = ClassRegex().Replace(regexPattern, string.Empty);
var reflectionRegex = new Regex(reflectionPattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.NonBacktracking);
var allTypes = await solutionManager.SearchReflectionTypesAsync(".*", cancellationToken);
var typesToProcess = allTypes.ToList();
var parallelism = Math.Max(1, Environment.ProcessorCount / 2);
var partitionCount = Math.Min(typesToProcess.Count, parallelism);
if (partitionCount > 0) {
var partitionSize = typesToProcess.Count / partitionCount;
var partitionTasks = new List<Task>();
for (int i = 0; i < partitionCount; i++) {
int startIdx = i * partitionSize;
int endIdx = (i == partitionCount - 1) ? typesToProcess.Count : (i + 1) * partitionSize;
partitionTasks.Add(Task.Run(() => {
for (int j = startIdx; j < endIdx && !cancellationToken.IsCancellationRequested; j++) {
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
var type = typesToProcess[j];
try {
if (IsGeneratedCode(type.FullName ?? type.Name)) {
continue;
}
bool hasMatchedMembers = false;
var matchedMembers = new List<(string name, object match)>();
var bindingFlags = BindingFlags.Public | BindingFlags.Instance |
BindingFlags.Static | BindingFlags.DeclaredOnly;
foreach (var memberInfo in type.GetMembers(bindingFlags)) {
if (cancellationToken.IsCancellationRequested) break;
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
if (memberInfo is MethodInfo mi && mi.IsSpecialName &&
(mi.Name.StartsWith("get_") || mi.Name.StartsWith("set_") ||
mi.Name.StartsWith("add_") || mi.Name.StartsWith("remove_"))) {
continue;
}
if (reflectionRegex.IsMatch(memberInfo.ToString() ?? memberInfo.Name)) {
string signature = memberInfo.ToString()!;
if (IsGeneratedCode(signature)) {
continue;
}
hasMatchedMembers = true;
if (memberInfo is MethodInfo method) {
var parameters = string.Join(", ",
method.GetParameters().Select(p => p.ParameterType.Name));
signature = $"{type.FullName}.{memberInfo.Name}({parameters})";
}
var assemblyLocation = type.Assembly.Location;
if (Interlocked.Increment(ref totalMatchesFound) <= MaxSearchResults) {
matches.Add(new {
kind = ToolHelpers.GetReflectionMemberTypeKindString(memberInfo),
parentFqn = type.FullName ?? type.Name,
signature = signature,
match = memberInfo.Name,
location = new {
filePath = assemblyLocation,
line = 0 // No line numbers for reflection matches
}
});
} else {
hasPartialResults = true;
break;
}
}
}
if (reflectionRegex.IsMatch(type.FullName ?? type.Name) && !hasMatchedMembers) {
// Check if we've already exceeded the result limit
if (Interlocked.CompareExchange(ref totalMatchesFound, 0, 0) >= MaxSearchResults) {
hasPartialResults = true;
break;
}
var assemblyLocation = type.Assembly.Location;
if (Interlocked.Increment(ref totalMatchesFound) <= MaxSearchResults) {
matches.Add(new {
kind = ToolHelpers.GetReflectionTypeKindString(type),
parentFqn = type.Namespace ?? "global",
signature = type.FullName ?? type.Name,
match = type.FullName ?? type.Name,
location = new {
filePath = assemblyLocation,
line = 0 // No line numbers for reflection matches
}
});
} else {
hasPartialResults = true;
break;
}
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogWarning(ex, "Error processing reflection type {TypeName}",
type.FullName ?? type.Name);
hasPartialResults = true;
}
}
}, cancellationToken));
}
await Task.WhenAll(partitionTasks);
}
} catch (Exception ex) when (!(ex is OperationCanceledException)) {
logger.LogError(ex, "Error searching reflection members with pattern {Pattern}", regexPattern);
hasPartialResults = true;
errors.Add($"Error searching reflection members: {ex.Message}");
}
}, cancellationToken);
try {
await Task.WhenAny(reflectionSearchTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
if (!reflectionSearchTask.IsCompleted) {
logger.LogWarning("Reflection search timed out after 5 seconds. Returning partial results.");
hasPartialResults = true;
errors.Add("Reflection search timed out after 5 seconds, returning partial results.");
} else if (reflectionSearchTask.IsFaulted && reflectionSearchTask.Exception != null) {
throw reflectionSearchTask.Exception.InnerException ?? reflectionSearchTask.Exception;
}
} catch (OperationCanceledException) {
throw;
}
// Group matches first by file, then by parent type/namespace, then by kind
var groupedMatches = matches
.OrderBy(m => ((dynamic)m).location.filePath)
.ThenBy(m => ((dynamic)m).parentFqn)
.ThenBy(m => ((dynamic)m).kind)
.GroupBy(m => ((dynamic)m).location.filePath)
.ToDictionary(
g => g.Key,
g => g.GroupBy(m => ((dynamic)m).parentFqn)
.ToDictionary(
pg => ToolHelpers.RemoveGlobalPrefix(pg.Key),
pg => pg.GroupBy(m => ((dynamic)m).kind)
.ToDictionary(
kg => kg.Key,
kg => kg
.Where(m => !IsGeneratedCode(((dynamic)m).match))
.DistinctBy(m => ((dynamic)m).match)
.Select(m => new {
match = ((dynamic)m).match,
line = ((dynamic)m).location.line > 0 ? ((dynamic)m).location.line : null,
}).OrderBy(m => m.line).ToList()
)
)
);
// Prepare a message for omitted results if we hit the limit
string? resultsLimitMessage = null;
if (hasPartialResults) {
resultsLimitMessage = $"Some search results omitted for brevity, try narrowing your search if you didn't find what you needed.";
logger.LogInformation("Search results limited");
}
return ToolHelpers.ToJson(new {
pattern = regexPattern,
matchesByFile = groupedMatches,
resultsLimitMessage,
errors = errors.Any() ? errors.ToList() : null,
totalMatchesFound
});
}, logger, nameof(SearchDefinitions), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ManageUsings), Idempotent = true, ReadOnly = false, Destructive = true, OpenWorld = false)]
[Description("Reads or writes using directives in a document.")]
public static async Task<object> ManageUsings(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("'read' or 'write'. For 'read', set codeToWrite to 'None'.")] string operation,
[Description("For 'read', must be 'None'. For 'write', provide all using directives that should exist in the file. This will replace all existing usings.")] string codeToWrite,
[Description("The absolute path to the file to manage usings in")] string filePath,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(operation, "operation", logger);
ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger);
ErrorHandlingHelpers.ValidateFileExists(filePath, logger);
if (operation != "read" && operation != "write") {
throw new McpException($"Invalid operation '{operation}'. Must be 'read' or 'write'.");
}
if (operation == "read" && codeToWrite != "None") {
throw new McpException("For read operations, codeToWrite must be 'None'");
}
if (operation == "write" && (codeToWrite == "None" || string.IsNullOrEmpty(codeToWrite))) {
throw new McpException("For write operations, codeToWrite must contain the complete list of using directives");
}
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageUsings));
var solution = solutionManager.CurrentSolution ?? throw new McpException("Current solution is null.");
var document = solution.Projects
.SelectMany(p => p.Documents)
.FirstOrDefault(d => d.FilePath == filePath) ?? throw new McpException($"File '{filePath}' not found in solution.");
var root = await document.GetSyntaxRootAsync(cancellationToken) ?? throw new McpException($"Could not get syntax root for file '{filePath}'.");
// Find the global usings file for the project
var globalUsingsFile = document.Project.Documents
.FirstOrDefault(d => d.Name.Equals("GlobalUsings.cs", StringComparison.OrdinalIgnoreCase));
if (operation == "read") {
var usingDirectives = root.DescendantNodes()
.OfType<UsingDirectiveSyntax>()
.Select(u => u.ToFullString().Trim())
.ToList();
// Handle global usings separately
var globalUsings = new List<string>();
if (globalUsingsFile != null) {
var globalRoot = await globalUsingsFile.GetSyntaxRootAsync(cancellationToken);
if (globalRoot != null) {
globalUsings = globalRoot.DescendantNodes()
.OfType<UsingDirectiveSyntax>()
.Select(u => u.ToFullString().Trim())
.ToList();
}
}
return ToolHelpers.ToJson(new {
file = filePath,
usings = string.Join("\n", usingDirectives),
globalUsings = string.Join("\n", globalUsings)
});
}
// Write operation
bool isGlobalUsings = document.FilePath!.EndsWith("GlobalUsings.cs", StringComparison.OrdinalIgnoreCase);
// Parse and normalize directives
var directives = codeToWrite.Split('\n')
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => isGlobalUsings && !line.StartsWith("global ") ? $"global {line}" : line)
.ToList();
// Create compilation unit with new directives
CompilationUnitSyntax? newRoot;
try {
var tempCode = string.Join("\n", directives);
newRoot = isGlobalUsings
? CSharpSyntaxTree.ParseText(tempCode).GetRoot() as CompilationUnitSyntax
: ((CompilationUnitSyntax)root).WithUsings(SyntaxFactory.List(
CSharpSyntaxTree.ParseText(tempCode + "\nnamespace N { class C { } }")
.GetRoot()
.DescendantNodes()
.OfType<UsingDirectiveSyntax>()
));
if (newRoot == null) {
throw new FormatException("Failed to create valid syntax tree.");
}
} catch (Exception ex) {
throw new McpException($"Failed to parse using directives: {ex.Message}");
}
// Apply changes
var newDocument = document.WithSyntaxRoot(newRoot);
var formatted = await modificationService.FormatDocumentAsync(newDocument, cancellationToken);
await modificationService.ApplyChangesAsync(formatted.Project.Solution, cancellationToken, "Manage Usings");
// Verify changes were successful
string diffResult = ContextInjectors.CreateCodeDiff(
root.ToFullString(),
newRoot.ToFullString());
if (diffResult.Trim() == "// No changes detected.") {
return "Using update was successful but no difference was detected.";
}
return "Successfully updated usings. Diff:\n" + diffResult;
}, logger, nameof(ManageUsings), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(ManageAttributes), Idempotent = true, ReadOnly = false, Destructive = true, OpenWorld = false)]
[Description("Reads or writes all attributes on a declaration.")]
public static async Task<object> ManageAttributes(
ISolutionManager solutionManager,
ICodeModificationService modificationService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("'read' or 'write'. For 'read', set codeToWrite to 'None'.")] string operation,
[Description("For 'read', must be 'None'. For 'write', specify all attributes that should exist on the target declaration. This will replace all existing attributes.")] string codeToWrite,
[Description("The FQN of the target declaration to manage attributes for")] string targetDeclaration,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(operation, "operation", logger);
ErrorHandlingHelpers.ValidateStringParameter(targetDeclaration, "targetDeclaration", logger);
if (operation != "read" && operation != "write") {
throw new McpException($"Invalid operation '{operation}'. Must be 'read' or 'write'.");
}
if (operation == "read" && codeToWrite != "None") {
throw new McpException("For read operations, codeToWrite must be 'None'");
}
if (operation == "write" && codeToWrite == "None") {
throw new McpException("For write operations, codeToWrite must contain the attributes to set");
}
// Ensure solution is loaded and get target symbol
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageAttributes));
var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, targetDeclaration, cancellationToken);
if (!symbol.DeclaringSyntaxReferences.Any()) {
throw new McpException($"Symbol '{targetDeclaration}' has no declaring syntax references.");
}
var syntaxRef = symbol.DeclaringSyntaxReferences.First();
var node = await syntaxRef.GetSyntaxAsync(cancellationToken);
if (operation == "read") {
// Get only the attributes on this node, not nested ones
var attributeLists = node switch {
MemberDeclarationSyntax mDecl => mDecl.AttributeLists,
StatementSyntax stmt => stmt.AttributeLists,
_ => SyntaxFactory.List<AttributeListSyntax>()
};
var attributes = string.Join("\n", attributeLists.Select(al => al.ToString().Trim()));
var lineSpan = node.GetLocation().GetLineSpan();
if (string.IsNullOrEmpty(attributes)) {
attributes = "No attributes found.";
}
return ToolHelpers.ToJson(new {
file = syntaxRef.SyntaxTree.FilePath,
line = lineSpan.StartLinePosition.Line + 1,
attributes
});
}
// Write operation
if (!(node is MemberDeclarationSyntax memberDecl)) {
throw new McpException("Target declaration is not a valid member declaration.");
}
SyntaxList<AttributeListSyntax> newAttributeLists;
try {
// Parse the attributes by wrapping in minimal valid syntax
var tempCode = $"{(codeToWrite.Length == 0 ? "" : codeToWrite + "\n")}public class C {{ }}";
newAttributeLists = CSharpSyntaxTree.ParseText(tempCode)
.GetRoot()
.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.First()
.AttributeLists;
} catch (Exception ex) {
throw new McpException($"Failed to parse attributes: {ex.Message}");
}
// Create updated declaration with new attributes
var newMember = memberDecl.WithAttributeLists(newAttributeLists);
var document = solutionManager.CurrentSolution?.GetDocument(syntaxRef.SyntaxTree)
?? throw new McpException("Could not find document for syntax tree.");
// Apply the changes
var newSolution = await modificationService.ReplaceNodeAsync(document.Id, memberDecl, newMember, cancellationToken);
var formatted = await modificationService.FormatDocumentAsync(
newSolution.GetDocument(document.Id)!, cancellationToken);
await modificationService.ApplyChangesAsync(formatted.Project.Solution, cancellationToken, "Manage Attributes");
// Return updated state to verify the change
string diffResult = ContextInjectors.CreateCodeDiff(
memberDecl.AttributeLists.ToFullString(),
newMember.AttributeLists.ToFullString());
if (diffResult.Trim() == "// No changes detected.") {
return "Attribute update was successful but no difference was detected.";
}
return "Successfully updated attributes. Diff:\n" + diffResult;
}, logger, nameof(ManageAttributes), cancellationToken);
}
[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(AnalyzeComplexity), Idempotent = true, ReadOnly = true, Destructive = false, OpenWorld = false)]
[Description("Deep analysis of code complexity metrics including cyclomatic complexity, cognitive complexity, method stats, coupling, and inheritance depth. Scans methods, classes, or entire projects to identify maintenance risks and guide refactoring decisions.")]
public static async Task<string> AnalyzeComplexity(
ISolutionManager solutionManager,
IComplexityAnalysisService complexityAnalysisService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The scope to analyze: 'method', 'class', or 'project'")] string scope,
[Description("The fully qualified name of the method/class, or project name to analyze")] string target,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
// Validate parameters
ErrorHandlingHelpers.ValidateStringParameter(scope, nameof(scope), logger);
ErrorHandlingHelpers.ValidateStringParameter(target, nameof(target), logger);
if (!new[] { "method", "class", "project" }.Contains(scope.ToLower())) {
throw new McpException($"Invalid scope '{scope}'. Must be 'method', 'class', or 'project'.");
}
// Ensure solution is loaded
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AnalyzeComplexity));
// Track metrics for the final report
var metrics = new Dictionary<string, object>();
var recommendations = new List<string>();
switch (scope.ToLower()) {
case "method":
var methodSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, target, cancellationToken) as IMethodSymbol;
if (methodSymbol == null)
throw new McpException($"Target '{target}' is not a method.");
await complexityAnalysisService.AnalyzeMethodAsync(methodSymbol, metrics, recommendations, cancellationToken);
break;
case "class":
var typeSymbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, target, cancellationToken) as INamedTypeSymbol;
if (typeSymbol == null)
throw new McpException($"Target '{target}' is not a class or interface.");
await complexityAnalysisService.AnalyzeTypeAsync(typeSymbol, metrics, recommendations, false, cancellationToken);
break;
case "project":
var project = solutionManager.GetProjectByName(target);
if (project == null)
throw new McpException($"Project '{target}' not found.");
await complexityAnalysisService.AnalyzeProjectAsync(project, metrics, recommendations, false, cancellationToken);
break;
}
// Format the results nicely
return ToolHelpers.ToJson(new {
scope,
target,
metrics,
recommendations = recommendations.Distinct().OrderBy(r => r).ToList()
});
}, logger, nameof(AnalyzeComplexity), cancellationToken);
}
// Disabled for now, not super useful
//[McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(FindPotentialDuplicates), Idempotent = true, Destructive = false, OpenWorld = false, ReadOnly = true)]
[Description("Finds groups of semantically similar methods within the solution based on a similarity threshold.")]
public static async Task<string> FindPotentialDuplicates(
ISolutionManager solutionManager,
ISemanticSimilarityService semanticSimilarityService,
ILogger<AnalysisToolsLogCategory> logger,
[Description("The minimum similarity score (0.0 to 1.0) for methods to be considered similar. (start with 0.75)")] double similarityThreshold,
CancellationToken cancellationToken) {
return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => {
ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindPotentialDuplicates));
logger.LogInformation("Executing '{ToolName}' with threshold {Threshold}", nameof(FindPotentialDuplicates), similarityThreshold);
if (similarityThreshold < 0.0 || similarityThreshold > 1.0) {
throw new McpException("Similarity threshold must be between 0.0 and 1.0.");
}
var timeout = TimeSpan.FromSeconds(30);
var cancellationTokenSource = new CancellationTokenSource(timeout);
cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenSource.Token).Token;
var similarityResults = await semanticSimilarityService.FindSimilarMethodsAsync(similarityThreshold, cancellationToken);
if (!similarityResults.Any()) {
return "No semantically similar method groups found with the given threshold.";
}
return ToolHelpers.ToJson(similarityResults);
}, logger, nameof(FindPotentialDuplicates), cancellationToken);
}
[GeneratedRegex(@"\s*\bclass\b\s*")]
private static partial Regex ClassRegex();
}
```