From 37ead963ce0599d8620dc0e704bd8ca2e9ffc645 Mon Sep 17 00:00:00 2001 From: Jaben Cargman Date: Fri, 21 Aug 2026 21:03:51 -0400 Subject: [PATCH 1/2] Keep the workspace in sync with the disk; add UnloadSolution MSBuildWorkspace never watches the file system, so a document edited outside SharpTools (editor, git, another tool) was invisible and the next write rewrote it from the stale buffer. Every tool now syncs first: documents are stat'ed and only the ones that changed are re-read; files added or removed, or a changed project file, trigger a reload. Results carry an note naming what was re-read. Writes are diffed against the solution the tool actually started from and rebased onto the workspace, so untouched files are never rewritten and an edit to a document that changed underneath fails with a retry message instead of losing the other change. Adds SharpTool_UnloadSolution and an xunit project covering the sync and conflict paths. Supersedes the FileSystemWatcher/full-reload approach proposed in kooshi/SharpToolsMCP#11. --- README.md | 5 + .../Services/SolutionManagerDiskSyncTests.cs | 207 ++++++++++ .../SharpTools.Tools.Tests.csproj | 16 + .../Interfaces/ISolutionManager.cs | 4 + SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs | 9 +- SharpTools.Tools/Mcp/ToolHelpers.cs | 18 +- SharpTools.Tools/Mcp/Tools/AnalysisTools.cs | 24 +- SharpTools.Tools/Mcp/Tools/DocumentTools.cs | 8 +- .../Mcp/Tools/ModificationTools.cs | 14 +- SharpTools.Tools/Mcp/Tools/SolutionTools.cs | 20 +- .../Services/CodeModificationService.cs | 24 +- SharpTools.Tools/Services/DiskSyncResult.cs | 10 + SharpTools.Tools/Services/OperationScope.cs | 30 ++ .../Services/SemanticSimilarityService.cs | 4 +- .../Services/SolutionManager.DiskSync.cs | 386 ++++++++++++++++++ SharpTools.Tools/Services/SolutionManager.cs | 16 +- SharpTools.sln | 48 ++- 17 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs create mode 100644 SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj create mode 100644 SharpTools.Tools/Services/DiskSyncResult.cs create mode 100644 SharpTools.Tools/Services/OperationScope.cs create mode 100644 SharpTools.Tools/Services/SolutionManager.DiskSync.cs diff --git a/README.md b/README.md index ef246c3..456ef2c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ I intend to maintain and improve it for as long as I am using it, and I welcome * **Concise AI Feedback Loop:** * Confirms changes with precise diffs instead of full code blocks. * Provides immediate, in-tool compilation error reports after modifications. +* **Stays in Step with the File System:** + * Before every operation, documents edited outside SharpTools (your editor, git, other tools) are re-read, and the tool result reports them in an `` note so the agent knows to look again. + * Files added or removed, or project files changed, trigger a solution reload automatically. + * SharpTools only ever writes the documents an operation actually changed, so outside edits are never overwritten; an operation that raced with another change to the same document fails and asks to be retried. * **Proactive Code Quality Analysis:** * Detects and warns about high code complexity (cyclomatic, cognitive). * Identifies semantically similar code to flag potential duplicates upon member addition. @@ -67,6 +71,7 @@ SharpTools exposes a variety of "SharpTool_*" functions via MCP. Here's a brief * `SharpTool_LoadSolution`: Initializes the workspace with a given `.sln` file. This is the primary entry point. * `SharpTool_LoadProject`: Provides a detailed structural overview of a specific project within the loaded solution, including namespaces and types, to aid AI understanding of the project's layout. +* `SharpTool_UnloadSolution`: Releases the loaded solution, its workspace and caches. `SharpTool_LoadSolution` must be called again afterwards. ### Analysis Tools diff --git a/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs b/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs new file mode 100644 index 0000000..5ce5226 --- /dev/null +++ b/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs @@ -0,0 +1,207 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpTools.Tools.Services; +using Xunit; + +namespace SharpTools.Tools.Tests.Services; + +// Each test gets a throwaway two-file solution on disk and a SolutionManager loaded against it. +public sealed class SolutionManagerDiskSyncTests : IAsyncLifetime { + private static readonly string[] Preamble = ["namespace Probe;", ""]; + private readonly string _root = Path.Combine(Path.GetTempPath(), "sharptools-tests", Guid.NewGuid().ToString("N")); + private readonly SolutionManager _manager = new(NullLogger.Instance, new FuzzyFqnLookupService(NullLogger.Instance)); + private string _a = null!; + private string _b = null!; + + static SolutionManagerDiskSyncTests() { + MsBuildLocatorBootstrapper.EnsureRegistered(_ => { }, _ => { }); + } + + public async Task InitializeAsync() { + var lib = Path.Combine(_root, "Lib"); + Directory.CreateDirectory(lib); + File.WriteAllText(Path.Combine(_root, "Probe.slnx"), "\r\n \r\n\r\n"); + // A global.json further up the temp path can pin an older SDK; insist on one that can target net8.0. + File.WriteAllText(Path.Combine(_root, "global.json"), "{ \"sdk\": { \"version\": \"8.0.100\", \"rollForward\": \"latestMajor\" } }"); + File.WriteAllText(Path.Combine(lib, "Lib.csproj"), "\r\n \r\n net8.0\r\n \r\n\r\n"); + _a = Path.Combine(lib, "A.cs"); + _b = Path.Combine(lib, "B.cs"); + File.WriteAllText(_a, Source("public class A { public int One() => 1; }")); + File.WriteAllText(_b, Source("public class B { public int Two() => 2; }")); + Run("dotnet", "restore", _root); + await _manager.LoadSolutionAsync(Path.Combine(_root, "Probe.slnx"), CancellationToken.None); + } + + public Task DisposeAsync() { + _manager.Dispose(); + try { + Directory.Delete(_root, recursive: true); + } catch (IOException) { + // MSBuild occasionally keeps a handle a little longer; leaving temp files behind is harmless. + } + return Task.CompletedTask; + } + + [Fact] + public async Task OutsideEdit_IsVisibleAfterSync() { + Touch(_a, Source("public class A { public int One() => 11; }")); + + var sync = await _manager.SyncWithDiskAsync(CancellationToken.None); + + Assert.False(sync.Reloaded); + Assert.Equal([_a], sync.RefreshedFiles, StringComparer.OrdinalIgnoreCase); + Assert.Contains("=> 11", await TextOf(_a)); + } + + [Fact] + public async Task SecondSync_ReportsNothing() { + Touch(_a, Source("public class A { public int One() => 11; }")); + await _manager.SyncWithDiskAsync(CancellationToken.None); + + var sync = await _manager.SyncWithDiskAsync(CancellationToken.None); + + Assert.False(sync.Any); + } + + [Fact] + public async Task OutsideEdit_SurvivesWriteToSameFile() { + Touch(_a, Source("// outside", "public class A { public int One() => 1; }")); + await _manager.SyncWithDiskAsync(CancellationToken.None); + + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + + var onDisk = File.ReadAllText(_a); + Assert.Contains("// outside", onDisk); + Assert.Contains("=> 100;", onDisk); + } + + [Fact] + public async Task UntouchedStaleFile_IsNotRewritten() { + Touch(_b, Source("// outside", "public class B { public int Two() => 2; }")); + var before = File.GetLastWriteTimeUtc(_b); + await _manager.SyncWithDiskAsync(CancellationToken.None); + + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + + Assert.Equal(before, File.GetLastWriteTimeUtc(_b)); + Assert.Contains("// outside", File.ReadAllText(_b)); + Assert.Contains("// outside", await TextOf(_b)); + Assert.Contains("=> 100;", File.ReadAllText(_a)); + } + + [Fact] + public async Task NewSourceFile_TriggersReload() { + File.WriteAllText(Path.Combine(_root, "Lib", "C.cs"), Source("public class C { }")); + + var sync = await _manager.SyncWithDiskAsync(CancellationToken.None); + + Assert.True(sync.Reloaded); + Assert.Contains("C.cs", sync.ReloadReason); + Assert.Contains(_manager.CurrentSolution!.Projects.Single().Documents, d => d.Name == "C.cs"); + } + + [Fact] + public async Task ProjectFileChange_TriggersReload() { + var csproj = Path.Combine(_root, "Lib", "Lib.csproj"); + Touch(csproj, File.ReadAllText(csproj).Replace("", " latest\r\n ")); + + var sync = await _manager.SyncWithDiskAsync(CancellationToken.None); + + Assert.True(sync.Reloaded); + Assert.Contains("Lib.csproj", sync.ReloadReason); + } + + [Fact] + public async Task Write_DoesNotLookLikeAnOutsideEdit() { + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + + var sync = await _manager.SyncWithDiskAsync(CancellationToken.None); + + Assert.False(sync.Any); + } + + [Fact] + public async Task SequentialEdits_ToSameDocument_AreApplied() { + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + await Edit(_a, text => text.Replace("=> 100;", "=> 300;")); + await Edit(_a, text => text.Replace("public class A", "public sealed class A")); + + var onDisk = File.ReadAllText(_a); + Assert.Contains("=> 300;", onDisk); + Assert.Contains("public sealed class A", onDisk); + Assert.False((await _manager.SyncWithDiskAsync(CancellationToken.None)).Any); + } + + [Fact] + public async Task StaleSolution_EditingADriftedDocument_IsRejected() { + var stale = _manager.CurrentSolution!; + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + + var fromStale = stale.WithDocumentText(DocumentId(stale, _a), SourceText.From(Source("public class A { public int One() => 200; }"))); + + await Assert.ThrowsAsync(() => _manager.ApplyChangesAsync(fromStale, CancellationToken.None, baseSolution: stale)); + Assert.Contains("=> 100;", File.ReadAllText(_a)); + } + + [Fact] + public async Task StaleSolution_EditingAnotherDocument_IsApplied() { + var stale = _manager.CurrentSolution!; + await Edit(_a, text => text.Replace("=> 1;", "=> 100;")); + + var fromStale = stale.WithDocumentText(DocumentId(stale, _b), SourceText.From(Source("public class B { public int Two() => 22; }"))); + await _manager.ApplyChangesAsync(fromStale, CancellationToken.None, baseSolution: stale); + + Assert.Contains("=> 22;", File.ReadAllText(_b)); + Assert.Contains("=> 100;", File.ReadAllText(_a)); + Assert.Contains("=> 100;", await TextOf(_a)); + } + + [Fact] + public async Task StaleSolution_EditingAnOutsideEditedDocument_IsRejected() { + var stale = _manager.CurrentSolution!; + Touch(_a, Source("public class A { public int One() => 11; }")); + await _manager.SyncWithDiskAsync(CancellationToken.None); + + var fromStale = stale.WithDocumentText(DocumentId(stale, _a), SourceText.From(Source("public class A { public int One() => 200; }"))); + + await Assert.ThrowsAsync(() => _manager.ApplyChangesAsync(fromStale, CancellationToken.None, baseSolution: stale)); + Assert.Contains("=> 11;", File.ReadAllText(_a)); + } + + private async Task Edit(string path, Func change) { + var solution = _manager.CurrentSolution!; + var id = DocumentId(solution, path); + var text = await solution.GetDocument(id)!.GetTextAsync(); + await _manager.ApplyChangesAsync(solution.WithDocumentText(id, SourceText.From(change(text.ToString()), text.Encoding)), CancellationToken.None, baseSolution: solution); + } + + private async Task TextOf(string path) => + (await _manager.CurrentSolution!.GetDocument(DocumentId(_manager.CurrentSolution!, path))!.GetTextAsync()).ToString(); + + private static DocumentId DocumentId(Solution solution, string path) => + solution.GetDocumentIdsWithFilePath(path).Single(); + + private static string Source(params string[] lines) => string.Join("\r\n", Preamble.Concat(lines)) + "\r\n"; + + // Make sure the mtime actually moves even on coarse file systems. + private static void Touch(string path, string content) { + File.WriteAllText(path, content); + File.SetLastWriteTimeUtc(path, File.GetLastWriteTimeUtc(path).AddSeconds(2)); + } + + private static void Run(string file, string args, string workingDirectory) { + var startInfo = new ProcessStartInfo(file, args) { WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true }; + // MSBuildLocator pins this process to an SDK the test runtime can host (not necessarily a current one) via + // MSBuild* environment variables; the child dotnet must resolve its own SDK from global.json instead. + foreach (var key in startInfo.Environment.Keys.Where(k => k.StartsWith("MSBUILD", StringComparison.OrdinalIgnoreCase) || k.Equals("DOTNET_HOST_PATH", StringComparison.OrdinalIgnoreCase)).ToList()) { + startInfo.Environment.Remove(key); + } + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + if (process.ExitCode != 0) { + throw new InvalidOperationException($"{file} {args} failed: {process.StandardError.ReadToEnd()}{process.StandardOutput.ReadToEnd()}"); + } + } +} diff --git a/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj new file mode 100644 index 0000000..52fc2cc --- /dev/null +++ b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + diff --git a/SharpTools.Tools/Interfaces/ISolutionManager.cs b/SharpTools.Tools/Interfaces/ISolutionManager.cs index 0ac3e50..10aedaa 100644 --- a/SharpTools.Tools/Interfaces/ISolutionManager.cs +++ b/SharpTools.Tools/Interfaces/ISolutionManager.cs @@ -20,4 +20,8 @@ public interface ISolutionManager : IDisposable { Task GetCompilationAsync(ProjectId projectId, CancellationToken cancellationToken); Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken); void RefreshCurrentSolution(); + /// Re-reads documents modified on disk outside SharpTools; reloads the solution when files were added/removed or a project file changed. + Task SyncWithDiskAsync(CancellationToken cancellationToken); + /// Writes the documents changed relative to the solution it was derived from (, else the first read in the current operation) to the workspace and disk. Fails if any of them changed underneath in the meantime. Returns the affected file paths. + Task> ApplyChangesAsync(Solution modifiedSolution, CancellationToken cancellationToken, Solution? baseSolution = null); } \ No newline at end of file diff --git a/SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs b/SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs index bbde2db..90dc9c4 100644 --- a/SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs +++ b/SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs @@ -20,9 +20,16 @@ public static async Task ExecuteWithErrorHandlingAsync( string operationName, CancellationToken cancellationToken, [CallerMemberName] string callerName = "") { + var syncNotice = new StrongBox(); + ToolHelpers.DiskSyncNotice.Value = syncNotice; + using var operationScope = OperationScope.Begin(); try { cancellationToken.ThrowIfCancellationRequested(); - return await operation(); + var result = await operation(); + if (syncNotice.Value is { Any: true } sync && result is string text) { + return (T)(object)(text + ToolHelpers.FormatDiskSyncNotice(sync)); + } + return result; } catch (OperationCanceledException) { logger.LogWarning("{Operation} operation in {Caller} was cancelled", operationName, callerName); throw new McpException($"The operation '{operationName}' was cancelled by the user or system."); diff --git a/SharpTools.Tools/Mcp/ToolHelpers.cs b/SharpTools.Tools/Mcp/ToolHelpers.cs index b90707f..890bc92 100644 --- a/SharpTools.Tools/Mcp/ToolHelpers.cs +++ b/SharpTools.Tools/Mcp/ToolHelpers.cs @@ -8,6 +8,9 @@ namespace SharpTools.Tools.Mcp; internal static class ToolHelpers { public const string SharpToolPrefix = "SharpTool_"; + // Set by ErrorHandlingHelpers around each tool invocation so the disk-sync outcome can be appended to the tool's result. + internal static readonly AsyncLocal?> DiskSyncNotice = new(); + public static void EnsureSolutionLoaded(ISolutionManager solutionManager) { if (!solutionManager.IsSolutionLoaded) { throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' first."); @@ -15,13 +18,24 @@ public static void EnsureSolutionLoaded(ISolutionManager solutionManager) { } /// - /// Safely ensures that a solution is loaded, with detailed error information. + /// Ensures a solution is loaded and brings it in step with the file system before the tool reads or writes anything. /// - public static void EnsureSolutionLoadedWithDetails(ISolutionManager solutionManager, ILogger logger, string operationName) { + public static async Task EnsureSolutionLoadedWithDetailsAsync(ISolutionManager solutionManager, ILogger logger, string operationName, CancellationToken cancellationToken) { if (!solutionManager.IsSolutionLoaded) { logger.LogError("Attempted to execute {Operation} without a loaded solution", operationName); throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' before calling '{operationName}'."); } + var sync = await solutionManager.SyncWithDiskAsync(cancellationToken); + if (sync.Any && DiskSyncNotice.Value is { } box) { + box.Value = sync; + } + } + + public static string FormatDiskSyncNotice(DiskSyncResult sync) { + if (sync.Reloaded) { + return $"\n\nThe solution was reloaded from disk because {sync.ReloadReason}. Symbols and line numbers you saw earlier may have moved."; + } + return $"\n\nThese files were modified outside SharpTools since they were last read and have been re-synced from disk; re-read them before editing: {string.Join("; ", sync.RefreshedFiles)}"; } private const string FqnHelpMessage = $" Try `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.SearchDefinitions)}`, `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.GetMembers)}`, or `{ToolHelpers.SharpToolPrefix}{nameof(Tools.DocumentTools.ReadTypesFromRoslynDocument)}` to find what you need."; public static async Task GetRoslynSymbolOrThrowAsync( diff --git a/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs b/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs index b73a245..a342a3a 100644 --- a/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs +++ b/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs @@ -22,7 +22,7 @@ public static async Task GetAllSubtypes( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedParentTypeName, "fullyQualifiedParentTypeName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetAllSubtypes)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(GetAllSubtypes), cancellationToken); logger.LogInformation("Executing {GetAllSubtypes} for: {TypeName}", nameof(GetAllSubtypes), fullyQualifiedParentTypeName); @@ -161,7 +161,7 @@ public static async Task GetMembers( CancellationToken cancellationToken = default) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, nameof(fullyQualifiedTypeName), logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetMembers)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(GetMembers), cancellationToken); logger.LogInformation("Executing '{GetMembers}' for: {TypeName} (IncludePrivate: {IncludePrivate})", nameof(GetMembers), fullyQualifiedTypeName, includePrivateMembers); @@ -357,7 +357,7 @@ public static async Task ViewDefinition( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewDefinition)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ViewDefinition), cancellationToken); logger.LogInformation("Executing '{ViewDefinition}' for: {SymbolName}", nameof(ViewDefinition), fullyQualifiedSymbolName); @@ -423,7 +423,7 @@ public static async Task ListImplementations( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ListImplementations)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ListImplementations), cancellationToken); logger.LogInformation("Executing '{ViewImplementations}' for: {SymbolName}", nameof(ListImplementations), fullyQualifiedSymbolName); @@ -698,7 +698,7 @@ public static async Task FindReferences( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindReferences)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(FindReferences), cancellationToken); logger.LogInformation("Executing '{FindReferences}' for: {SymbolName}", nameof(FindReferences), fullyQualifiedSymbolName); @@ -830,7 +830,7 @@ public static async Task ViewInheritanceChain( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, "fullyQualifiedTypeName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewInheritanceChain)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ViewInheritanceChain), cancellationToken); logger.LogInformation("Executing '{ViewInheritanceChain}' for: {TypeName}", nameof(ViewInheritanceChain), fullyQualifiedTypeName); @@ -1005,7 +1005,7 @@ public static async Task ViewCallGraph( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMethodName, "fullyQualifiedMethodName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewCallGraph)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ViewCallGraph), cancellationToken); logger.LogInformation("Executing '{ViewCallGraph}' for: {MethodName}", nameof(ViewCallGraph), fullyQualifiedMethodName); @@ -1121,7 +1121,7 @@ static bool IsGeneratedCode(string signature) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(regexPattern, "regexPattern", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(SearchDefinitions)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(SearchDefinitions), cancellationToken); logger.LogInformation("Executing '{SearchDefinitions}' with pattern: {RegexPattern}", nameof(SearchDefinitions), regexPattern); @@ -1518,7 +1518,7 @@ public static async Task ManageUsings( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageUsings)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ManageUsings), cancellationToken); var solution = solutionManager.CurrentSolution ?? throw new McpException("Current solution is null."); var document = solution.Projects @@ -1632,7 +1632,7 @@ public static async Task ManageAttributes( } // Ensure solution is loaded and get target symbol - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageAttributes)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ManageAttributes), cancellationToken); var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, targetDeclaration, cancellationToken); if (!symbol.DeclaringSyntaxReferences.Any()) { @@ -1727,7 +1727,7 @@ public static async Task AnalyzeComplexity( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AnalyzeComplexity)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(AnalyzeComplexity), cancellationToken); // Track metrics for the final report var metrics = new Dictionary(); @@ -1779,7 +1779,7 @@ public static async Task FindPotentialDuplicates( [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)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(FindPotentialDuplicates), cancellationToken); logger.LogInformation("Executing '{ToolName}' with threshold {Threshold}", nameof(FindPotentialDuplicates), similarityThreshold); if (similarityThreshold < 0.0 || similarityThreshold > 1.0) { diff --git a/SharpTools.Tools/Mcp/Tools/DocumentTools.cs b/SharpTools.Tools/Mcp/Tools/DocumentTools.cs index b8a359e..4f42819 100644 --- a/SharpTools.Tools/Mcp/Tools/DocumentTools.cs +++ b/SharpTools.Tools/Mcp/Tools/DocumentTools.cs @@ -31,7 +31,7 @@ public static async Task ReadRawFromRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadRawFromRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ReadRawFromRoslynDocument), cancellationToken); logger.LogInformation("Reading document at {FilePath}", filePath); @@ -86,7 +86,7 @@ public static async Task CreateRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(CreateRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(CreateRoslynDocument), cancellationToken); content = content.TrimBackslash(); logger.LogInformation("Creating new document at {FilePath}", filePath); @@ -168,7 +168,7 @@ public static async Task OverwriteRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(OverwriteRoslynDocument), cancellationToken); content = content.TrimBackslash(); logger.LogInformation("Overwriting document at {FilePath}", filePath); @@ -298,7 +298,7 @@ public static async Task ReadTypesFromRoslynDocument( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateFilePath(filePath, logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadTypesFromRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ReadTypesFromRoslynDocument), cancellationToken); var pathInfo = documentOperations.GetPathInfo(filePath); if (!pathInfo.Exists) { diff --git a/SharpTools.Tools/Mcp/Tools/ModificationTools.cs b/SharpTools.Tools/Mcp/Tools/ModificationTools.cs index c3d27f9..1ca4d3b 100644 --- a/SharpTools.Tools/Mcp/Tools/ModificationTools.cs +++ b/SharpTools.Tools/Mcp/Tools/ModificationTools.cs @@ -49,7 +49,7 @@ public static async Task AddMember( codeSnippet = codeSnippet.TrimBackslash(); // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AddMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(AddMember), cancellationToken); logger.LogInformation("Executing '{AddMember}' for target: {TargetName}", nameof(AddMember), fullyQualifiedTargetName); // Get the target symbol @@ -237,7 +237,7 @@ public static async Task OverwriteMember( ErrorHandlingHelpers.ValidateStringParameter(newMemberCode, nameof(newMemberCode), logger); newMemberCode = newMemberCode.TrimBackslash(); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(OverwriteMember), cancellationToken); logger.LogInformation("Executing '{OverwriteMember}' for: {SymbolName}", nameof(OverwriteMember), fullyQualifiedMemberName); var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken); @@ -370,7 +370,7 @@ public static async Task RenameSymbol( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(RenameSymbol)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(RenameSymbol), cancellationToken); logger.LogInformation("Executing '{RenameSymbol}' for {SymbolName} to {NewName}", nameof(RenameSymbol), fullyQualifiedSymbolName, newName); // Get the symbol to rename @@ -466,7 +466,7 @@ public static async Task ReplaceAllReferences( // Note: filenameFilter can be empty or null, as this indicates "replace in all files" // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReplaceAllReferences)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(ReplaceAllReferences), cancellationToken); logger.LogInformation("Executing '{ReplaceAllReferences}' for {SymbolName} with text '{ReplacementCode}', filter: {Filter}", nameof(ReplaceAllReferences), fullyQualifiedSymbolName, replacementCode, filenameFilter ?? "none"); @@ -602,7 +602,7 @@ public static async Task Undo( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(Undo)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(Undo), cancellationToken); logger.LogInformation("Executing '{UndoLastChange}'", nameof(Undo)); var (success, message) = await modificationService.UndoLastChangeAsync(cancellationToken); @@ -642,7 +642,7 @@ public static async Task FindAndReplace( .Replace(@"\r", @"\n"); // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindAndReplace)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(FindAndReplace), cancellationToken); logger.LogInformation("Executing '{FindAndReplace}' with pattern: '{Pattern}', replacement: {Replacement}, target: {Target}", nameof(FindAndReplace), regexPattern, replacementText, target); @@ -848,7 +848,7 @@ public static async Task MoveMember( ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger); ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedDestinationTypeOrNamespaceName, nameof(fullyQualifiedDestinationTypeOrNamespaceName), logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(MoveMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(MoveMember), cancellationToken); logger.LogInformation("Executing '{MoveMember}' moving {MemberName} to {DestinationName}", nameof(MoveMember), fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName); diff --git a/SharpTools.Tools/Mcp/Tools/SolutionTools.cs b/SharpTools.Tools/Mcp/Tools/SolutionTools.cs index 13726b7..2e47b74 100644 --- a/SharpTools.Tools/Mcp/Tools/SolutionTools.cs +++ b/SharpTools.Tools/Mcp/Tools/SolutionTools.cs @@ -360,6 +360,24 @@ public static string ExtractTargetFrameworkFromProjectFile(string projectFilePat return "Unknown"; } } + [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(UnloadSolution), Idempotent = true, Destructive = false, OpenWorld = false, ReadOnly = false)] + [Description($"Unloads the current solution and releases its workspace, caches, and reflection context. Use it to free memory or before switching solutions (`{ToolHelpers.SharpToolPrefix}{nameof(LoadSolution)}` also replaces a loaded solution). Every other SharpTool fails until `{ToolHelpers.SharpToolPrefix}{nameof(LoadSolution)}` is called again.")] + public static async Task UnloadSolution( + ISolutionManager solutionManager, + ILogger logger, + CancellationToken cancellationToken) { + + return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(() => { + if (!solutionManager.IsSolutionLoaded) { + return Task.FromResult("No solution is loaded."); + } + var solutionName = Path.GetFileName(solutionManager.CurrentSolution.FilePath) ?? "solution"; + logger.LogInformation("Executing '{UnloadSolution}' for {Solution}", nameof(UnloadSolution), solutionName); + solutionManager.UnloadSolution(); + return Task.FromResult($"Unloaded {solutionName}. Call `{ToolHelpers.SharpToolPrefix}{nameof(LoadSolution)}` before using other SharpTools."); + }, logger, nameof(UnloadSolution), cancellationToken); + } + [McpServerTool(Name = ToolHelpers.SharpToolPrefix + nameof(LoadProject), ReadOnly = true, OpenWorld = false, Destructive = false, Idempotent = false)] [Description($"Use this immediately after {nameof(LoadSolution)}. This injects a comprehensive understanding of the project structure into your context.")] public static async Task LoadProject( @@ -373,7 +391,7 @@ public static async Task LoadProject( ErrorHandlingHelpers.ValidateStringParameter(projectName, "projectName", logger); logger.LogInformation("Executing '{LoadProjectToolName}' tool for project: {ProjectName}", nameof(LoadProject), projectName); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(LoadProject)); + await ToolHelpers.EnsureSolutionLoadedWithDetailsAsync(solutionManager, logger, nameof(LoadProject), cancellationToken); int indexOfParen = projectName.IndexOf('('); string projectNameNormalized = indexOfParen == -1 ? projectName.Trim() diff --git a/SharpTools.Tools/Services/CodeModificationService.cs b/SharpTools.Tools/Services/CodeModificationService.cs index 4ea41c1..02a3c32 100644 --- a/SharpTools.Tools/Services/CodeModificationService.cs +++ b/SharpTools.Tools/Services/CodeModificationService.cs @@ -481,21 +481,17 @@ public async Task ApplyChangesAsync(Solution newSolution, CancellationToken canc solutionChanges.GetProjectChanges().SelectMany(pc => pc.GetChangedDocuments().Concat(pc.GetAddedDocuments()).Concat(pc.GetRemovedDocuments())).Count(), solutionChanges.GetProjectChanges().Count()); - if (workspace.TryApplyChanges(finalSolutionToApply)) { - _logger.LogInformation("Changes applied successfully to the workspace."); - - // If additional file paths are provided, add them to the changed file paths - if (additionalFilePaths != null) { - changedFilePaths.AddRange(additionalFilePaths.Where(fp => !string.IsNullOrEmpty(fp) && File.Exists(fp))); - } - // Git operations after successful changes - await ProcessGitOperationsAsync(solutionPath, changedFilePaths, commitMessage, cancellationToken); - - _solutionManager.RefreshCurrentSolution(); - } else { - _logger.LogError("Failed to apply changes to the workspace."); - throw new InvalidOperationException("Failed to apply changes to the workspace. Files might have been modified externally."); + // The solution manager rebases onto the workspace's own solution, so only documents this operation actually + // changed are written; files edited outside SharpTools in the meantime are left alone. + await _solutionManager.ApplyChangesAsync(finalSolutionToApply, cancellationToken); + _logger.LogInformation("Changes applied successfully to the workspace."); + + // If additional file paths are provided, add them to the changed file paths + if (additionalFilePaths != null) { + changedFilePaths.AddRange(additionalFilePaths.Where(fp => !string.IsNullOrEmpty(fp) && File.Exists(fp))); } + // Git operations after successful changes + await ProcessGitOperationsAsync(solutionPath, changedFilePaths, commitMessage, cancellationToken); } private async Task ProcessGitOperationsAsync(string solutionPath, List changedFilePaths, string commitMessage, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(solutionPath) || changedFilePaths.Count == 0) { diff --git a/SharpTools.Tools/Services/DiskSyncResult.cs b/SharpTools.Tools/Services/DiskSyncResult.cs new file mode 100644 index 0000000..eeb23bf --- /dev/null +++ b/SharpTools.Tools/Services/DiskSyncResult.cs @@ -0,0 +1,10 @@ +namespace SharpTools.Tools.Services; + +/// +/// Outcome of : which documents were re-read because they changed on disk +/// outside SharpTools, or whether the whole solution had to be reloaded (files added/removed, project file changed). +/// +public sealed record DiskSyncResult(IReadOnlyList RefreshedFiles, bool Reloaded, string? ReloadReason) { + public static readonly DiskSyncResult None = new(Array.Empty(), false, null); + public bool Any => Reloaded || RefreshedFiles.Count > 0; +} diff --git a/SharpTools.Tools/Services/OperationScope.cs b/SharpTools.Tools/Services/OperationScope.cs new file mode 100644 index 0000000..7590e90 --- /dev/null +++ b/SharpTools.Tools/Services/OperationScope.cs @@ -0,0 +1,30 @@ +namespace SharpTools.Tools.Services; + +// Tools derive their modified solution from the CurrentSolution they read first; remembering that read per invocation +// (AsyncLocal flows into everything the tool awaits) lets ApplyChangesAsync diff against the exact base instead of guessing. +public static class OperationScope { + private static readonly AsyncLocal?> Current = new(); + + public static IDisposable Begin() { + Current.Value = new StrongBox(); + return new Ender(); + } + + internal static void Observe(Solution? solution) { + if (solution != null && Current.Value is { Value: null } box) { + box.Value = solution; + } + } + + internal static Solution? ObservedBase => Current.Value?.Value; + + internal static void Forget() { + if (Current.Value is { } box) { + box.Value = null; + } + } + + private sealed class Ender : IDisposable { + public void Dispose() => Current.Value = null; + } +} diff --git a/SharpTools.Tools/Services/SemanticSimilarityService.cs b/SharpTools.Tools/Services/SemanticSimilarityService.cs index 1b842cd..78d6737 100644 --- a/SharpTools.Tools/Services/SemanticSimilarityService.cs +++ b/SharpTools.Tools/Services/SemanticSimilarityService.cs @@ -186,7 +186,7 @@ public SemanticSimilarityService( public async Task> FindSimilarMethodsAsync( double similarityThreshold, CancellationToken cancellationToken) { - ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarMethodsAsync)); + ToolHelpers.EnsureSolutionLoaded(_solutionManager); _logger.LogInformation("Starting semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism); var allMethodFeatures = new System.Collections.Concurrent.ConcurrentBag(); @@ -504,7 +504,7 @@ private double CalculateCosineSimilarity(Dictionary vec1, Dictionar public async Task> FindSimilarClassesAsync( double similarityThreshold, CancellationToken cancellationToken) { - ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarClassesAsync)); + ToolHelpers.EnsureSolutionLoaded(_solutionManager); _logger.LogInformation("Starting class semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism); var allClassFeatures = new System.Collections.Concurrent.ConcurrentBag(); diff --git a/SharpTools.Tools/Services/SolutionManager.DiskSync.cs b/SharpTools.Tools/Services/SolutionManager.DiskSync.cs new file mode 100644 index 0000000..bfa081b --- /dev/null +++ b/SharpTools.Tools/Services/SolutionManager.DiskSync.cs @@ -0,0 +1,386 @@ +using System.Diagnostics; + +namespace SharpTools.Tools.Services; + +// MSBuildWorkspace never watches the disk, so an outside edit (editor, git, another tool) was invisible and the next write +// rewrote the file from the stale buffer. This re-checks on demand at the start of every tool call instead of using a +// FileSystemWatcher (missed/duplicate events, buffer overflows): stat every document, re-read only what changed, reload +// only when files were added/removed or a project file changed. Writes are rebased onto the workspace's own solution. +public sealed partial class SolutionManager { + private static readonly StringComparer PathComparer = OperatingSystem.IsLinux() ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; + private static readonly HashSet IgnoredDirectoryNames = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", ".git", ".vs", ".idea", "node_modules", "TestResults" }; + private static readonly string[] ProjectLevelFileNames = { "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json" }; + + private const int HistoryLength = 16; + + private readonly SemaphoreSlim _stateLock = new(1, 1); + // Recent CurrentSolution snapshots, newest first. A tool's modified solution descends from one of these; diffing + // against that origin tells exactly which documents the tool touched and which merely drifted underneath it. + private readonly List _history = new(); + // Last seen (mtime, length) per document; a document is only re-read when this differs. + private readonly Dictionary _diskStamps = new(); + // Documents whose workspace text is stale relative to disk; re-applied over workspace.CurrentSolution after every apply. + private readonly Dictionary _diskOverrides = new(); + private Dictionary _projectFileStamps = new(PathComparer); + private HashSet _knownSourceFiles = new(PathComparer); + + private readonly record struct DiskStamp(DateTime LastWriteUtc, long Length); + + public async Task SyncWithDiskAsync(CancellationToken cancellationToken) { + await _stateLock.WaitAsync(cancellationToken); + try { + if (!IsSolutionLoaded) { + return DiskSyncResult.None; + } + var stopwatch = Stopwatch.StartNew(); + + var reloadReason = DetectStructuralChange(_currentSolution); + if (reloadReason != null) { + _logger.LogInformation("Reloading solution from disk: {Reason}", reloadReason); + await ReloadSolutionFromDiskAsync(cancellationToken); + return new DiskSyncResult(Array.Empty(), true, reloadReason); + } + + var solution = _currentSolution; + var documents = solution.Projects + .SelectMany(p => p.Documents) + .Where(d => d.FilePath != null) + .Select(d => (d.Id, Path: d.FilePath!)) + .ToList(); + var refreshed = new List(); + + foreach (var (id, path) in documents) { + cancellationToken.ThrowIfCancellationRequested(); + var stamp = Stat(path); + if (stamp is null) { + _logger.LogInformation("Reloading solution from disk: document deleted: {Path}", path); + await ReloadSolutionFromDiskAsync(cancellationToken); + return new DiskSyncResult(Array.Empty(), true, $"file deleted: {path}"); + } + if (_diskStamps.TryGetValue(id, out var known) && known == stamp.Value) { + continue; + } + + var document = solution.GetDocument(id)!; + var currentText = await document.GetTextAsync(cancellationToken); + var diskText = ReadSourceText(path, currentText.Encoding); + // Only trust the stamp if the file didn't change while we were reading it; otherwise retry next call. + if (Stat(path) == stamp) { + _diskStamps[id] = stamp.Value; + } + if (diskText.ContentEquals(currentText)) { + continue; + } + + solution = solution.WithDocumentText(id, diskText, PreservationMode.PreserveValue); + _diskOverrides[id] = diskText; + refreshed.Add(path); + } + + if (refreshed.Count > 0) { + SetCurrentSolution(solution); + _logger.LogInformation("Re-read {Count} document(s) modified outside SharpTools: {Files}", refreshed.Count, string.Join(", ", refreshed)); + } + _logger.LogDebug("Disk sync checked {Count} documents in {Elapsed} ms", documents.Count, stopwatch.ElapsedMilliseconds); + return new DiskSyncResult(refreshed, false, null); + } finally { + _stateLock.Release(); + } + } + + public async Task> ApplyChangesAsync(Solution modifiedSolution, CancellationToken cancellationToken, Solution? baseSolution = null) { + await _stateLock.WaitAsync(cancellationToken); + try { + if (!IsSolutionLoaded) { + throw new InvalidOperationException("No solution is loaded."); + } + var workspace = _workspace; + var baseline = _currentSolution; + + // A base from a previous workspace (the solution was reloaded since it was read) can't be diffed against. + var knownBase = baseSolution ?? OperationScope.ObservedBase; + var origin = knownBase != null && ReferenceEquals(knownBase.Workspace, workspace) + ? knownBase + : await FindOriginAsync(modifiedSolution, cancellationToken) ?? baseline; + var changes = modifiedSolution.GetChanges(origin); + if (changes.GetAddedProjects().Any() || changes.GetRemovedProjects().Any()) { + throw new NotSupportedException("Adding or removing projects is not supported."); + } + // Documents changed underneath the tool since it started (another operation, or an outside edit that was synced). + var drifted = ReferenceEquals(origin, baseline) + ? new HashSet() + : baseline.GetChanges(origin).GetProjectChanges().SelectMany(pc => pc.GetChangedDocuments().Concat(pc.GetAddedDocuments()).Concat(pc.GetRemovedDocuments())).ToHashSet(); + + var target = workspace.CurrentSolution; + var touched = new List<(DocumentId Id, string? Path)>(); + var removed = new List<(DocumentId Id, string? Path)>(); + + foreach (var projectChange in changes.GetProjectChanges()) { + foreach (var id in projectChange.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true)) { + var document = modifiedSolution.GetDocument(id)!; + if (drifted.Contains(id)) { + throw new InvalidOperationException($"'{document.FilePath}' was modified by another operation or outside SharpTools after this operation started. Re-read it and retry."); + } + var text = await document.GetTextAsync(cancellationToken); + target = target.WithDocumentText(id, text, PreservationMode.PreserveValue); + touched.Add((id, document.FilePath)); + } + foreach (var id in projectChange.GetAddedDocuments()) { + var document = modifiedSolution.GetDocument(id)!; + var text = await document.GetTextAsync(cancellationToken); + var loader = TextLoader.From(TextAndVersion.Create(text, VersionStamp.Create(), document.FilePath)); + target = target.AddDocument(DocumentInfo.Create(id, document.Name, document.Folders, document.SourceCodeKind, loader, document.FilePath)); + touched.Add((id, document.FilePath)); + } + foreach (var id in projectChange.GetRemovedDocuments()) { + if (drifted.Contains(id)) { + throw new InvalidOperationException($"'{origin.GetDocument(id)?.FilePath}' was modified by another operation or outside SharpTools after this operation started. Re-read it and retry."); + } + removed.Add((id, origin.GetDocument(id)?.FilePath)); + target = target.RemoveDocument(id); + } + foreach (var id in projectChange.GetChangedAdditionalDocuments()) { + var text = await modifiedSolution.GetAdditionalDocument(id)!.GetTextAsync(cancellationToken); + target = target.WithAdditionalDocumentText(id, text, PreservationMode.PreserveValue); + } + foreach (var id in projectChange.GetChangedAnalyzerConfigDocuments()) { + var text = await modifiedSolution.GetAnalyzerConfigDocument(id)!.GetTextAsync(cancellationToken); + target = target.WithAnalyzerConfigDocumentText(id, text, PreservationMode.PreserveValue); + } + } + + if (!workspace.TryApplyChanges(target)) { + throw new InvalidOperationException("Failed to apply changes to the workspace."); + } + + var written = new List(); + foreach (var (id, path) in touched) { + _diskOverrides.Remove(id); + if (path == null) { + continue; + } + written.Add(path); + if (Stat(path) is { } stamp) { + _diskStamps[id] = stamp; + } + if (IsSourceFile(path)) { + _knownSourceFiles.Add(path); + } + } + foreach (var (id, path) in removed) { + _diskOverrides.Remove(id); + _diskStamps.Remove(id); + if (path == null) { + continue; + } + written.Add(path); + if (!File.Exists(path)) { + _knownSourceFiles.Remove(path); + } + } + + SetCurrentSolution(ApplyOverrides(workspace.CurrentSolution)); + return written; + } finally { + _stateLock.Release(); + } + } + + // Fallback when no base was observed: the solution a caller started from is the history entry it differs from the + // least (it descends from exactly one of them, so against that one only its own edits show up as changes). Candidates + // tie when the caller edited the very document that drifted; then take the one whose text the result is closest to. + private async Task FindOriginAsync(Solution modifiedSolution, CancellationToken cancellationToken) { + if (_history.Count == 0) { + return null; + } + var ranked = _history.Select(candidate => (Solution: candidate, Changed: CountChangedDocuments(modifiedSolution.GetChanges(candidate)))).ToList(); + var fewest = ranked.Min(x => x.Changed); + var tied = ranked.Where(x => x.Changed == fewest).Select(x => x.Solution).ToList(); + if (tied.Count == 1) { + return tied[0]; + } + var closest = tied[0]; + var closestDistance = long.MaxValue; + foreach (var candidate in tied) { + var distance = await TextDistanceAsync(modifiedSolution, candidate, cancellationToken); + if (distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + return closest; + } + + private static async Task TextDistanceAsync(Solution modified, Solution candidate, CancellationToken cancellationToken) { + long distance = 0; + foreach (var projectChange in modified.GetChanges(candidate).GetProjectChanges()) { + foreach (var id in projectChange.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true)) { + var newText = await modified.GetDocument(id)!.GetTextAsync(cancellationToken); + var oldText = await candidate.GetDocument(id)!.GetTextAsync(cancellationToken); + distance += newText.GetTextChanges(oldText).Sum(change => Math.Max(change.Span.Length, change.NewText?.Length ?? 0)); + } + foreach (var id in projectChange.GetAddedDocuments()) { + distance += (await modified.GetDocument(id)!.GetTextAsync(cancellationToken)).Length; + } + foreach (var id in projectChange.GetRemovedDocuments()) { + distance += (await candidate.GetDocument(id)!.GetTextAsync(cancellationToken)).Length; + } + } + return distance; + } + + private void SetCurrentSolution(Solution solution) { + _currentSolution = solution; + _history.Insert(0, solution); + if (_history.Count > HistoryLength) { + _history.RemoveRange(HistoryLength, _history.Count - HistoryLength); + } + _compilationCache.Clear(); + _semanticModelCache.Clear(); + } + + private static int CountChangedDocuments(SolutionChanges changes) => + changes.GetProjectChanges().Sum(pc => pc.GetChangedDocuments().Count() + pc.GetAddedDocuments().Count() + pc.GetRemovedDocuments().Count()) + + changes.GetAddedProjects().Count() + changes.GetRemovedProjects().Count(); + + private async Task SnapshotDiskStateAsync(Solution solution, CancellationToken cancellationToken) { + ResetDiskState(); + _history.Add(solution); + foreach (var document in solution.Projects.SelectMany(p => p.Documents)) { + if (document.FilePath is not { } path || Stat(path) is not { } stamp) { + continue; + } + // Roslyn loads text lazily; materialize it now so the recorded stamp describes the text we actually hold and a + // later outside edit is recognised as such instead of being read silently on first use. + await document.GetTextAsync(cancellationToken); + _diskStamps[document.Id] = stamp; + } + _projectFileStamps = CollectProjectFiles(solution).ToDictionary(p => p, Stat, PathComparer); + _knownSourceFiles = ScanSourceFiles(solution); + _logger.LogDebug("Disk state snapshot: {Documents} documents, {SourceFiles} source files, {ProjectFiles} project files", + _diskStamps.Count, _knownSourceFiles.Count, _projectFileStamps.Count); + } + + private void ResetDiskState() { + OperationScope.Forget(); + _history.Clear(); + _diskStamps.Clear(); + _diskOverrides.Clear(); + _projectFileStamps = new Dictionary(PathComparer); + _knownSourceFiles = new HashSet(PathComparer); + } + + private Solution ApplyOverrides(Solution solution) { + foreach (var (id, text) in _diskOverrides.ToList()) { + var document = solution.GetDocument(id); + if (document == null || (document.TryGetText(out var current) && current.ContentEquals(text))) { + _diskOverrides.Remove(id); + continue; + } + solution = solution.WithDocumentText(id, text, PreservationMode.PreserveValue); + } + return solution; + } + + private string? DetectStructuralChange(Solution solution) { + foreach (var (path, stamp) in _projectFileStamps) { + if (Stat(path) != stamp) { + return $"project file changed: {path}"; + } + } + var current = ScanSourceFiles(solution); + if (current.SetEquals(_knownSourceFiles)) { + return null; + } + var added = current.Except(_knownSourceFiles, PathComparer).ToList(); + var removed = _knownSourceFiles.Except(current, PathComparer).ToList(); + var parts = new List(); + if (added.Count > 0) { + parts.Add($"{added.Count} source file(s) added ({string.Join(", ", added.Take(3))})"); + } + if (removed.Count > 0) { + parts.Add($"{removed.Count} source file(s) removed ({string.Join(", ", removed.Take(3))})"); + } + return string.Join("; ", parts); + } + + private static IEnumerable CollectProjectFiles(Solution solution) { + var files = new HashSet(PathComparer); + if (solution.FilePath is { } solutionPath) { + files.Add(solutionPath); + } + var solutionDir = solution.FilePath == null ? null : Path.GetDirectoryName(solution.FilePath); + foreach (var project in solution.Projects) { + if (project.FilePath is not { } projectPath) { + continue; + } + files.Add(projectPath); + for (var dir = Path.GetDirectoryName(projectPath); dir != null; dir = Path.GetDirectoryName(dir)) { + foreach (var name in ProjectLevelFileNames) { + var candidate = Path.Combine(dir, name); + if (File.Exists(candidate)) { + files.Add(candidate); + } + } + if (solutionDir != null && PathComparer.Equals(dir, solutionDir)) { + break; + } + } + } + return files; + } + + private static HashSet ScanSourceFiles(Solution solution) { + var roots = solution.Projects + .Select(p => Path.GetDirectoryName(p.FilePath)) + .OfType() + .Distinct(PathComparer) + .ToList(); + // A root nested under another root is already covered by the walk of the outer one. + roots = roots.Where(root => !roots.Any(other => !PathComparer.Equals(other, root) && IsUnder(root, other))).ToList(); + + var files = new HashSet(PathComparer); + foreach (var root in roots) { + Walk(new DirectoryInfo(root), files); + } + return files; + } + + private static void Walk(DirectoryInfo directory, HashSet files) { + IEnumerable entries; + try { + entries = directory.EnumerateFileSystemInfos("*", new EnumerationOptions { IgnoreInaccessible = true, AttributesToSkip = FileAttributes.ReparsePoint }); + } catch (IOException) { + return; + } catch (UnauthorizedAccessException) { + return; + } + foreach (var entry in entries) { + if (entry is DirectoryInfo subdirectory) { + if (IgnoredDirectoryNames.Contains(subdirectory.Name) || subdirectory.Name.StartsWith('.')) { + continue; + } + Walk(subdirectory, files); + } else if (IsSourceFile(entry.FullName)) { + files.Add(entry.FullName); + } + } + } + + private static bool IsUnder(string path, string ancestor) { + var prefix = ancestor.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return path.StartsWith(prefix, PathComparer == StringComparer.Ordinal ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSourceFile(string path) => path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + + private static DiskStamp? Stat(string path) { + var info = new FileInfo(path); + return info.Exists ? new DiskStamp(info.LastWriteTimeUtc, info.Length) : null; + } + + private static SourceText ReadSourceText(string path, Encoding? defaultEncoding) { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return SourceText.From(stream, defaultEncoding); + } +} diff --git a/SharpTools.Tools/Services/SolutionManager.cs b/SharpTools.Tools/Services/SolutionManager.cs index 68694f4..5b85377 100644 --- a/SharpTools.Tools/Services/SolutionManager.cs +++ b/SharpTools.Tools/Services/SolutionManager.cs @@ -4,7 +4,7 @@ using SharpTools.Tools.Mcp.Tools; namespace SharpTools.Tools.Services; -public sealed class SolutionManager : ISolutionManager { +public sealed partial class SolutionManager : ISolutionManager { private readonly ILogger _logger; private readonly IFuzzyFqnLookupService _fuzzyFqnLookupService; private MSBuildWorkspace? _workspace; @@ -18,7 +18,13 @@ public sealed class SolutionManager : ISolutionManager { [MemberNotNullWhen(true, nameof(_workspace), nameof(_currentSolution))] public bool IsSolutionLoaded => _workspace != null && _currentSolution != null; public MSBuildWorkspace? CurrentWorkspace => _workspace; - public Solution? CurrentSolution => _currentSolution; + public Solution? CurrentSolution { + get { + var solution = _currentSolution; + OperationScope.Observe(solution); + return solution; + } + } private readonly string? _buildConfiguration; private string? _requestedSolutionPath; @@ -50,6 +56,7 @@ public async Task LoadSolutionAsync(string solutionPath, CancellationToken cance _currentSolution = await _workspace.OpenSolutionAsync(solutionPath, new ProgressReporter(_logger), cancellationToken); _logger.LogInformation("Solution loaded successfully with {ProjectCount} projects.", _currentSolution.Projects.Count()); InitializeMetadataContextAndReflectionCache(_currentSolution, cancellationToken); + await SnapshotDiskStateAsync(_currentSolution, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to load solution: {SolutionPath}", solutionPath); UnloadSolution(); @@ -197,6 +204,7 @@ private void LoadTypesFromAssembly(string assemblyPath, ref int typesCachedCount } public void UnloadSolution() { _logger.LogInformation("Unloading current solution and workspace."); + ResetDiskState(); _compilationCache.Clear(); _semanticModelCache.Clear(); _allLoadedReflectionTypesCache.Clear(); @@ -221,9 +229,7 @@ public void RefreshCurrentSolution() { _logger.LogWarning("Cannot refresh solution: No solution loaded."); return; } - _currentSolution = _workspace.CurrentSolution; - _compilationCache.Clear(); - _semanticModelCache.Clear(); + SetCurrentSolution(ApplyOverrides(_workspace.CurrentSolution)); _logger.LogDebug("Current solution state has been refreshed from workspace."); } public async Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken) { diff --git a/SharpTools.sln b/SharpTools.sln index 3adb5cf..a12fb38 100644 --- a/SharpTools.sln +++ b/SharpTools.sln @@ -9,26 +9,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.SseServer", "Sha EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.StdioServer", "SharpTools.StdioServer\SharpTools.StdioServer.csproj", "{6DF2B244-8781-4F09-87E7-F5130D03821D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.Tools.Tests", "SharpTools.Tools.Tests\SharpTools.Tools.Tests.csproj", "{1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x64.ActiveCfg = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x64.Build.0 = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x86.ActiveCfg = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x86.Build.0 = Debug|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|Any CPU.ActiveCfg = Release|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|Any CPU.Build.0 = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x64.ActiveCfg = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x64.Build.0 = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x86.ActiveCfg = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x86.Build.0 = Release|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x64.Build.0 = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x86.Build.0 = Debug|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|Any CPU.Build.0 = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x64.ActiveCfg = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x64.Build.0 = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x86.ActiveCfg = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x86.Build.0 = Release|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x64.Build.0 = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x86.Build.0 = Debug|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|Any CPU.Build.0 = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x64.ActiveCfg = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x64.Build.0 = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x86.ActiveCfg = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x86.Build.0 = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|x64.Build.0 = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Debug|x86.Build.0 = Debug|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|Any CPU.Build.0 = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|x64.ActiveCfg = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|x64.Build.0 = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|x86.ActiveCfg = Release|Any CPU + {1EB55803-BBD9-4E7E-B8CE-BA23BC93265A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal From 35fe262b24504f67db2ff6b01ebfddcd8f37ac34 Mon Sep 17 00:00:00 2001 From: Jaben Cargman Date: Fri, 21 Aug 2026 21:18:45 -0400 Subject: [PATCH 2/2] Move to .NET 10; update ModelContextProtocol 2.2.0, Roslyn 5.9.0 and other packages All projects now target net10.0. ModelContextProtocol 0.4.0-preview.3 -> 2.2.0 needed no code changes apart from pinning the SharpTask prompt name, since 2.x defaults unnamed prompts to snake_case. Roslyn packages aligned at 5.9.0 (Workspaces.MSBuild had been left at 5.6.0, tripping NU1608). Microsoft.Build.Locator 1.11.2's copy-local check is disabled in Directory.Build.props: the flagged assemblies are Microsoft.Build.Framework (transitive from Roslyn) and NuGet.Frameworks (needed in-process by NuGet.Protocol), and MSBuild itself only runs in Roslyn's out-of-proc BuildHost, so the version-mixing hazard the check guards against does not apply. Also bumps ICSharpCode.Decompiler, LibGit2Sharp, NuGet.Protocol, Serilog, System.CommandLine, test SDK/xunit runner, and drops the in-box System.Reflection.Metadata reference. --- Directory.Build.props | 9 +++++++ README.md | 4 +-- .../SharpTools.SseServer.csproj | 8 +++--- .../SharpTools.StdioServer.csproj | 8 +++--- .../Services/SolutionManagerDiskSyncTests.cs | 4 +-- .../SharpTools.Tools.Tests.csproj | 9 ++++--- SharpTools.Tools/Mcp/Prompts.cs | 3 ++- SharpTools.Tools/SharpTools.Tools.csproj | 25 +++++++++---------- 8 files changed, 41 insertions(+), 29 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2ae5b1c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + true + + diff --git a/README.md b/README.md index 456ef2c..e87fb1f 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ SharpTools exposes a variety of "SharpTool_*" functions via MCP. Here's a brief ## Prerequisites -* .NET 8+ SDK for running the server +* .NET 10 SDK for running the server * The .NET SDK of your target solution ## Building @@ -162,7 +162,7 @@ VSCode Copilot example: "servers": { "SharpTools": { "type": "stdio", - "command": "/path/to/repo/SharpToolsMCP/SharpTools.StdioServer/bin/Debug/net8.0/SharpTools.StdioServer", + "command": "/path/to/repo/SharpToolsMCP/SharpTools.StdioServer/bin/Debug/net10.0/SharpTools.StdioServer", "args": [ "--log-directory", "/var/log/sharptools/", diff --git a/SharpTools.SseServer/SharpTools.SseServer.csproj b/SharpTools.SseServer/SharpTools.SseServer.csproj index 1181932..d410efe 100644 --- a/SharpTools.SseServer/SharpTools.SseServer.csproj +++ b/SharpTools.SseServer/SharpTools.SseServer.csproj @@ -6,15 +6,15 @@ - - + + - + Exe - net8.0 + net10.0 enable enable stserver diff --git a/SharpTools.StdioServer/SharpTools.StdioServer.csproj b/SharpTools.StdioServer/SharpTools.StdioServer.csproj index 44b61b8..8fc9cb9 100644 --- a/SharpTools.StdioServer/SharpTools.StdioServer.csproj +++ b/SharpTools.StdioServer/SharpTools.StdioServer.csproj @@ -5,17 +5,17 @@ - - + + - + Exe - net8.0 + net10.0 enable enable true diff --git a/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs b/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs index 5ce5226..ca815ec 100644 --- a/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs +++ b/SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs @@ -24,8 +24,8 @@ public async Task InitializeAsync() { Directory.CreateDirectory(lib); File.WriteAllText(Path.Combine(_root, "Probe.slnx"), "\r\n \r\n\r\n"); // A global.json further up the temp path can pin an older SDK; insist on one that can target net8.0. - File.WriteAllText(Path.Combine(_root, "global.json"), "{ \"sdk\": { \"version\": \"8.0.100\", \"rollForward\": \"latestMajor\" } }"); - File.WriteAllText(Path.Combine(lib, "Lib.csproj"), "\r\n \r\n net8.0\r\n \r\n\r\n"); + File.WriteAllText(Path.Combine(_root, "global.json"), "{ \"sdk\": { \"version\": \"10.0.100\", \"rollForward\": \"latestMajor\" } }"); + File.WriteAllText(Path.Combine(lib, "Lib.csproj"), "\r\n \r\n net10.0\r\n \r\n\r\n"); _a = Path.Combine(lib, "A.cs"); _b = Path.Combine(lib, "B.cs"); File.WriteAllText(_a, Source("public class A { public int One() => 1; }")); diff --git a/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj index 52fc2cc..b31374b 100644 --- a/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj +++ b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj @@ -1,14 +1,17 @@ - net8.0 + net10.0 enable enable false - + - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/SharpTools.Tools/Mcp/Prompts.cs b/SharpTools.Tools/Mcp/Prompts.cs index 533e1da..96b1201 100644 --- a/SharpTools.Tools/Mcp/Prompts.cs +++ b/SharpTools.Tools/Mcp/Prompts.cs @@ -35,7 +35,8 @@ Always perform multiple targeted edits (such as adding usings first, then modify "; - [McpServerPrompt, Description("Github Copilot Agent: Execute task with SharpTools")] + // Explicit name: since ModelContextProtocol 2.x unnamed prompts default to snake_case ("sharp_task"). + [McpServerPrompt(Name = nameof(SharpTask)), Description("Github Copilot Agent: Execute task with SharpTools")] public static ChatMessage SharpTask([Description("Your task for the agent")] string content) { return new(ChatRole.User, string.Format(CopilotTemplate, content)); } diff --git a/SharpTools.Tools/SharpTools.Tools.csproj b/SharpTools.Tools/SharpTools.Tools.csproj index 2392500..44351b4 100644 --- a/SharpTools.Tools/SharpTools.Tools.csproj +++ b/SharpTools.Tools/SharpTools.Tools.csproj @@ -1,22 +1,21 @@ -net8.0 +net10.0 enable enable - - - - - - - - - - - - + + + + + + + + + + +