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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
using CSharpCodeAnalyst.AnalyzerSdk.Contracts;
using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData;
using CSharpCodeAnalyst.AnalyzerSdk.Messages;
using CSharpCodeAnalyst.AnalyzerSdk.Search;
using CSharpCodeAnalyst.AnalyzerSdk.Wpf;
using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode;
using CSharpCodeAnalyst.CodeGraph.Search;

namespace CSharpCodeAnalyst.Analyzers.DeadCode.Presentation;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
using CSharpCodeAnalyst.AnalyzerSdk.Contracts;
using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData;
using CSharpCodeAnalyst.AnalyzerSdk.Messages;
using CSharpCodeAnalyst.AnalyzerSdk.Search;
using CSharpCodeAnalyst.AnalyzerSdk.Wpf;
using CSharpCodeAnalyst.CodeGraph.Search;

namespace CSharpCodeAnalyst.Analyzers.MethodComplexity.Presentation;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
using CSharpCodeAnalyst.AnalyzerSdk.Contracts;
using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData;
using CSharpCodeAnalyst.AnalyzerSdk.Messages;
using CSharpCodeAnalyst.AnalyzerSdk.Search;
using CSharpCodeAnalyst.AnalyzerSdk.Wpf;
using CSharpCodeAnalyst.CodeGraph.Algorithms.Partitioning;
using CSharpCodeAnalyst.CodeGraph.Search;

namespace CSharpCodeAnalyst.Analyzers.TypeCohesion.Presentation;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
using CSharpCodeAnalyst.AnalyzerSdk.Contracts;
using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData;
using CSharpCodeAnalyst.AnalyzerSdk.Messages;
using CSharpCodeAnalyst.AnalyzerSdk.Search;
using CSharpCodeAnalyst.AnalyzerSdk.Wpf;
using CSharpCodeAnalyst.CodeGraph.Algorithms.Metrics;
using CSharpCodeAnalyst.CodeGraph.Search;

namespace CSharpCodeAnalyst.Analyzers.TypeDependencies.Presentation;

Expand Down
2 changes: 1 addition & 1 deletion CSharpCodeAnalyst.CodeGraph/Search/PascalCaseSearch.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Text;
using System.Text.RegularExpressions;

namespace CSharpCodeAnalyst.AnalyzerSdk.Search;
namespace CSharpCodeAnalyst.CodeGraph.Search;

public static class PascalCaseSearch
{
Expand Down
2 changes: 1 addition & 1 deletion CSharpCodeAnalyst.CodeGraph/Search/SearchExpression.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Text.RegularExpressions;
using CSharpCodeAnalyst.CodeGraph.Graph;

namespace CSharpCodeAnalyst.AnalyzerSdk.Search;
namespace CSharpCodeAnalyst.CodeGraph.Search;

/// <summary>
/// Helper to build (very) simple search expressions with AND/OR/TERM
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace CSharpCodeAnalyst.AnalyzerSdk.Search;
namespace CSharpCodeAnalyst.CodeGraph.Search;

public static class SearchExpressionFactory
{
Expand Down
28 changes: 28 additions & 0 deletions CSharpCodeAnalyst.Mcp/CSharpCodeAnalyst.Mcp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--
Deliberately not net10.0-windows: this assembly must stay free of WPF so the tools can be
unit tested against a hand built CodeGraph, the same way the analyzers are.
-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<!--
Brings a FrameworkReference to Microsoft.AspNetCore.App with it. Through the ProjectReference
in CSharpCodeAnalyst.csproj that reference propagates into the application's
runtimeconfig.json, so the executable requires the ASP.NET Core runtime next to the desktop
runtime. See Documentation/mcp.md for the measurement and for the alternative
(ReferenceOutputAssembly="false" plus dynamic loading) should that ever become a problem.
-->
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CSharpCodeAnalyst.CodeGraph\CSharpCodeAnalyst.CodeGraph.csproj" />
</ItemGroup>

</Project>
30 changes: 30 additions & 0 deletions CSharpCodeAnalyst.Mcp/Contracts/GraphSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace CSharpCodeAnalyst.Mcp.Contracts;

/// <summary>
/// A consistent, read only view of the code graph the application had loaded at
/// <see cref="CapturedAtUtc" />. Never the live graph: the application mutates that one in place
/// during a refactoring simulation, and a query walking it at the same time would see a half
/// changed structure. The snapshot belongs to the MCP layer alone, so nothing can change under a
/// running query and no locking is needed anywhere.
/// <para>
/// Everything except <see cref="Graph" /> exists so a caller can judge how much to trust the
/// answer: whether the graph is still current, and whether it describes code that actually
/// exists.
/// </para>
/// </summary>
/// <param name="Graph">The copied graph. Treat as immutable.</param>
/// <param name="SourceName">
/// What the graph was built from - a solution or project file name. Empty when unknown, which is
/// the case for a graph produced by an importer that does not report one.
/// </param>
/// <param name="CapturedAtUtc">When the copy was taken. Source files may have changed since.</param>
/// <param name="ContainsRefactorings">
/// Whether the user simulated refactorings after loading. If true the graph describes a hypothetical
/// code base, not the one on disk - a fact any consumer has to be told, because the difference is
/// invisible in the data itself.
/// </param>
public sealed record GraphSnapshot(
CodeGraph.Graph.CodeGraph Graph,
string SourceName,
DateTimeOffset CapturedAtUtc,
bool ContainsRefactorings);
22 changes: 22 additions & 0 deletions CSharpCodeAnalyst.Mcp/Contracts/ICodeGraphSnapshotSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace CSharpCodeAnalyst.Mcp.Contracts;

/// <summary>
/// Supplies the MCP tools with the code graph to answer questions about. Implemented by the host
/// application, which owns the live graph; this assembly never touches that graph directly.
/// <para>
/// The seam exists for one reason: the live graph is mutated on the UI thread (project load,
/// refactoring simulation) while tool calls run on request threads. Whoever implements this
/// is responsible for handing out something that cannot change while a query walks it - see
/// <see cref="GraphSnapshot" />.
/// </para>
/// </summary>
public interface ICodeGraphSnapshotSource
{
/// <summary>
/// The current snapshot, taken fresh if the graph changed since the last call.
/// Returns <c>null</c> when no project is loaded - the normal state of a freshly started
/// application, not an error. Tools must report that as an answer rather than throwing, so a
/// caller learns what to do instead of seeing a protocol failure.
/// </summary>
Task<GraphSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken = default);
}
138 changes: 138 additions & 0 deletions CSharpCodeAnalyst.Mcp/McpServerHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Reflection;
using CSharpCodeAnalyst.Mcp.Contracts;
using CSharpCodeAnalyst.Mcp.Tools;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;

namespace CSharpCodeAnalyst.Mcp;

/// <summary>
/// Runs the MCP server inside the host application: a Kestrel endpoint that speaks the Model
/// Context Protocol over HTTP, so an assistant can query the currently loaded code graph.
/// <para>
/// Bound to loopback only, and deliberately so. The graph describes someone's source code in
/// full - assembly, namespace and member names, file paths, call structure. Binding to
/// anything else would publish that to the network.
/// </para>
/// </summary>
public sealed class McpServerHost : IAsyncDisposable
{
/// <summary>
/// The path the endpoint is mapped to. Part of the URL a client is configured with, so it is
/// named here rather than spelled out in the documentation twice.
/// </summary>
public const string EndpointPath = "/mcp";

private WebApplication? _app;

public bool IsRunning => _app is not null;

/// <summary>
/// The URL to configure a client with, once started. Null while stopped.
/// </summary>
public Uri? Endpoint { get; private set; }

public async ValueTask DisposeAsync()
{
await StopAsync();
}

/// <summary>
/// Starts listening. Throws if the port is taken - the caller decides whether that is fatal or
/// merely means "no MCP this session", because only it knows whether the user asked for the
/// server explicitly.
/// </summary>
public async Task StartAsync(ICodeGraphSnapshotSource snapshotSource, int port,
CancellationToken cancellationToken = default)
{
if (_app is not null)
{
throw new InvalidOperationException("The MCP server is already running.");
}

var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
// The working directory of a desktop application is whatever the user last browsed to.
// Pinning the content root keeps the web host from resolving configuration relative to it.
ContentRootPath = AppContext.BaseDirectory,
ApplicationName = typeof(McpServerHost).Assembly.GetName().Name
});

// Kestrel would otherwise log to a console this process does not have. Debug output keeps
// startup failures visible while developing without adding a dependency on the host's logging.
builder.Logging.ClearProviders();
builder.Logging.AddDebug();

builder.WebHost.ConfigureKestrel(kestrel => kestrel.ListenLocalhost(port));

builder.Services.AddSingleton(snapshotSource);
builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = "csharp-code-analyst",
Version = GetVersion()
};
options.ServerInstructions =
"Answers questions about the C# dependency graph currently loaded in CSharp Code Analyst: " +
"who calls what, what depends on what, and how two elements are connected. " +
"Element ids are opaque and only valid for the running server - always start with " +
"search_elements to obtain one. Call graph_info first to learn what is loaded and how " +
"current it is.";
})
// Stateless: every request stands on its own. The tools are read only and answer from a
// snapshot, so there is no per-session state worth keeping - and none to lose when a client
// reconnects.
.WithHttpTransport(transport => transport.Stateless = true)
.WithTools<GraphInfoTools>()
.WithTools<ElementTools>()
.WithTools<RelationshipTools>();

var app = builder.Build();
app.MapMcp(EndpointPath);

await app.StartAsync(cancellationToken);

_app = app;
Endpoint = new Uri($"http://127.0.0.1:{port}{EndpointPath}");
}

public async Task StopAsync()
{
var app = _app;
if (app is null)
{
return;
}

_app = null;
Endpoint = null;

await app.StopAsync();
await app.DisposeAsync();
}

private static string GetVersion()
{
var assembly = typeof(McpServerHost).Assembly;
var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;

// A deterministic build appends "+<commit sha>" to the informational version. Useful in a
// crash report, noise in a protocol field a client displays.
var plus = informational?.IndexOf('+') ?? -1;
if (plus > 0)
{
return informational![..plus];
}

return informational
?? assembly.GetName().Version?.ToString()
?? "0.0.0";
}
}
104 changes: 104 additions & 0 deletions CSharpCodeAnalyst.Mcp/Tools/ElementFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Globalization;
using System.Text;
using CSharpCodeAnalyst.CodeGraph.Graph;

namespace CSharpCodeAnalyst.Mcp.Tools;

/// <summary>
/// One place that decides how a code element is written down, so every tool answers in the same
/// shape and a caller only has to learn it once.
/// <para>
/// The format is a compromise between two readers. A human skimming a transcript wants the name
/// first; the model needs the id, because nothing else lets it ask a follow up question. Both are
/// on one line, because a block per element would spend most of the answer on structure.
/// </para>
/// </summary>
internal static class ElementFormatter
{
/// <summary>
/// A single element, as used in lists: kind, full path, id, and where it is defined.
/// Example: <c>[Class] Sample.Core.OrderService id=8f3c... Orders.cs:42</c>
/// </summary>
public static string Line(CodeElement element)
{
var text = new StringBuilder();
text.Append('[').Append(element.ElementType).Append("] ");
text.Append(element.FullName);
text.Append(" id=").Append(element.Id);

var location = FirstLocation(element);
if (location is not null)
{
text.Append(" ").Append(location);
}

if (element.IsExternal)
{
text.Append(" [external]");
}

if (element.IsGenerated)
{
text.Append(" [generated]");
}

return text.ToString();
}

/// <summary>
/// A source location as <c>file:line</c>, with the directory dropped - the file name plus the line
/// is what a reader needs to find the code, while full paths are long and identical across most of
/// a result. Null when the producer supplied no location, which is the normal case for external
/// elements and for several importers.
/// </summary>
public static string? FirstLocation(CodeElement element)
{
var location = element.SourceLocations.FirstOrDefault();
if (location?.File is null)
{
return null;
}

var fileName = Path.GetFileName(location.File);
return $"{fileName}:{location.Line.ToString(CultureInfo.InvariantCulture)}";
}

/// <summary>
/// Counts per kind, ordered by count, as <c>8 Calls, 3 Uses, 1 Inherits</c>. Gives a caller the
/// shape of a result before it reads the entries - and often that is already the answer.
/// </summary>
public static string Summarize<T>(IEnumerable<T> items, Func<T, string> kind)
{
var counts = items
.GroupBy(kind)
.OrderByDescending(group => group.Count())
.ThenBy(group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Count().ToString(CultureInfo.InvariantCulture)} {group.Key}");

return string.Join(", ", counts);
}

/// <summary>
/// Appends at most <paramref name="limit" /> lines and says plainly how many were left out.
/// A silently truncated list is worse than a short one: a caller that cannot tell it is looking at
/// a fragment will happily conclude "there are only three callers".
/// </summary>
public static void AppendLimited(StringBuilder text, IReadOnlyList<CodeElement> elements, int limit)
{
foreach (var element in elements.Take(limit))
{
text.Append(" ").AppendLine(Line(element));
}

if (elements.Count > limit)
{
var omitted = elements.Count - limit;
text.Append(" ... ")
.Append(omitted.ToString(CultureInfo.InvariantCulture))
.Append(" more not shown (")
.Append(elements.Count.ToString(CultureInfo.InvariantCulture))
.AppendLine(" in total). Narrow the question to see them.");
}
}
}
Loading