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 ef246c3..e87fb1f 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
@@ -110,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
@@ -157,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
new file mode 100644
index 0000000..ca815ec
--- /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\": \"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; }"));
+ 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..b31374b
--- /dev/null
+++ b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
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/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/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