Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<!-- Microsoft.Build.Locator 1.11+ fails the build when Microsoft.Build.Framework / NuGet.Frameworks are copied to the
output, to prevent mixing MSBuild versions in-process. MSBuild is never loaded in this process (Roslyn's MSBuildWorkspace
builds in its out-of-proc BuildHost); the copies come transitively from Roslyn and NuGet.Protocol, which needs
NuGet.Frameworks at runtime, so the check doesn't apply here. -->
<DisableMSBuildAssemblyCopyCheck>true</DisableMSBuildAssemblyCopyCheck>
</PropertyGroup>
</Project>
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<externalChanges>` 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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/",
Expand Down
8 changes: 4 additions & 4 deletions SharpTools.SseServer/SharpTools.SseServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.4.0-preview.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.11" />
</ItemGroup>

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>stserver</AssemblyName>
Expand Down
8 changes: 4 additions & 4 deletions SharpTools.StdioServer/SharpTools.StdioServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.11" />
</ItemGroup>

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackAsTool>true</PackAsTool>
Expand Down
207 changes: 207 additions & 0 deletions SharpTools.Tools.Tests/Services/SolutionManagerDiskSyncTests.cs
Original file line number Diff line number Diff line change
@@ -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<SolutionManager>.Instance, new FuzzyFqnLookupService(NullLogger<FuzzyFqnLookupService>.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"), "<Solution>\r\n <Project Path=\"Lib/Lib.csproj\" />\r\n</Solution>\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"), "<Project Sdk=\"Microsoft.NET.Sdk\">\r\n <PropertyGroup>\r\n <TargetFramework>net10.0</TargetFramework>\r\n </PropertyGroup>\r\n</Project>\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("</PropertyGroup>", " <LangVersion>latest</LangVersion>\r\n </PropertyGroup>"));

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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _manager.ApplyChangesAsync(fromStale, CancellationToken.None, baseSolution: stale));
Assert.Contains("=> 11;", File.ReadAllText(_a));
}

private async Task Edit(string path, Func<string, string> 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<string> 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()}");
}
}
}
19 changes: 19 additions & 0 deletions SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SharpTools.Tools\SharpTools.Tools.csproj" />
</ItemGroup>
</Project>
4 changes: 4 additions & 0 deletions SharpTools.Tools/Interfaces/ISolutionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ public interface ISolutionManager : IDisposable {
Task<Compilation?> GetCompilationAsync(ProjectId projectId, CancellationToken cancellationToken);
Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken);
void RefreshCurrentSolution();
/// <summary>Re-reads documents modified on disk outside SharpTools; reloads the solution when files were added/removed or a project file changed.</summary>
Task<DiskSyncResult> SyncWithDiskAsync(CancellationToken cancellationToken);
/// <summary>Writes the documents <paramref name="modifiedSolution"/> changed relative to the solution it was derived from (<paramref name="baseSolution"/>, else the first <see cref="CurrentSolution"/> 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.</summary>
Task<IReadOnlyList<string>> ApplyChangesAsync(Solution modifiedSolution, CancellationToken cancellationToken, Solution? baseSolution = null);
}
9 changes: 8 additions & 1 deletion SharpTools.Tools/Mcp/ErrorHandlingHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ public static async Task<T> ExecuteWithErrorHandlingAsync<T, TLogCategory>(
string operationName,
CancellationToken cancellationToken,
[CallerMemberName] string callerName = "") {
var syncNotice = new StrongBox<DiskSyncResult?>();
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.");
Expand Down
3 changes: 2 additions & 1 deletion SharpTools.Tools/Mcp/Prompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ Always perform multiple targeted edits (such as adding usings first, then modify
</critical>
";

[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));
}
Expand Down
Loading