diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs index 6c998803..3e05003e 100644 --- a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs @@ -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; diff --git a/CSharpCodeAnalyst.Analyzers/MethodComplexity/Presentation/MethodComplexityViewModel.cs b/CSharpCodeAnalyst.Analyzers/MethodComplexity/Presentation/MethodComplexityViewModel.cs index ef76f16b..37b428b0 100644 --- a/CSharpCodeAnalyst.Analyzers/MethodComplexity/Presentation/MethodComplexityViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/MethodComplexity/Presentation/MethodComplexityViewModel.cs @@ -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; diff --git a/CSharpCodeAnalyst.Analyzers/TypeCohesion/Presentation/TypeCohesionViewModel.cs b/CSharpCodeAnalyst.Analyzers/TypeCohesion/Presentation/TypeCohesionViewModel.cs index 15637991..2566f883 100644 --- a/CSharpCodeAnalyst.Analyzers/TypeCohesion/Presentation/TypeCohesionViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/TypeCohesion/Presentation/TypeCohesionViewModel.cs @@ -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; diff --git a/CSharpCodeAnalyst.Analyzers/TypeDependencies/Presentation/TypeDependenciesViewModel.cs b/CSharpCodeAnalyst.Analyzers/TypeDependencies/Presentation/TypeDependenciesViewModel.cs index d76526c9..5ce542fa 100644 --- a/CSharpCodeAnalyst.Analyzers/TypeDependencies/Presentation/TypeDependenciesViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/TypeDependencies/Presentation/TypeDependenciesViewModel.cs @@ -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; diff --git a/CSharpCodeAnalyst.CodeGraph/Search/PascalCaseSearch.cs b/CSharpCodeAnalyst.CodeGraph/Search/PascalCaseSearch.cs index 633303bb..be65f056 100644 --- a/CSharpCodeAnalyst.CodeGraph/Search/PascalCaseSearch.cs +++ b/CSharpCodeAnalyst.CodeGraph/Search/PascalCaseSearch.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace CSharpCodeAnalyst.AnalyzerSdk.Search; +namespace CSharpCodeAnalyst.CodeGraph.Search; public static class PascalCaseSearch { diff --git a/CSharpCodeAnalyst.CodeGraph/Search/SearchExpression.cs b/CSharpCodeAnalyst.CodeGraph/Search/SearchExpression.cs index e3aac223..7092a17a 100644 --- a/CSharpCodeAnalyst.CodeGraph/Search/SearchExpression.cs +++ b/CSharpCodeAnalyst.CodeGraph/Search/SearchExpression.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using CSharpCodeAnalyst.CodeGraph.Graph; -namespace CSharpCodeAnalyst.AnalyzerSdk.Search; +namespace CSharpCodeAnalyst.CodeGraph.Search; /// /// Helper to build (very) simple search expressions with AND/OR/TERM diff --git a/CSharpCodeAnalyst.CodeGraph/Search/SearchExpressionFactory.cs b/CSharpCodeAnalyst.CodeGraph/Search/SearchExpressionFactory.cs index b01a620e..93abcaa9 100644 --- a/CSharpCodeAnalyst.CodeGraph/Search/SearchExpressionFactory.cs +++ b/CSharpCodeAnalyst.CodeGraph/Search/SearchExpressionFactory.cs @@ -1,4 +1,4 @@ -namespace CSharpCodeAnalyst.AnalyzerSdk.Search; +namespace CSharpCodeAnalyst.CodeGraph.Search; public static class SearchExpressionFactory { diff --git a/CSharpCodeAnalyst.Mcp/CSharpCodeAnalyst.Mcp.csproj b/CSharpCodeAnalyst.Mcp/CSharpCodeAnalyst.Mcp.csproj new file mode 100644 index 00000000..d82450cd --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/CSharpCodeAnalyst.Mcp.csproj @@ -0,0 +1,28 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/CSharpCodeAnalyst.Mcp/Contracts/GraphSnapshot.cs b/CSharpCodeAnalyst.Mcp/Contracts/GraphSnapshot.cs new file mode 100644 index 00000000..4a0ad1a0 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Contracts/GraphSnapshot.cs @@ -0,0 +1,30 @@ +namespace CSharpCodeAnalyst.Mcp.Contracts; + +/// +/// A consistent, read only view of the code graph the application had loaded at +/// . 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. +/// +/// Everything except 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. +/// +/// +/// The copied graph. Treat as immutable. +/// +/// 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. +/// +/// When the copy was taken. Source files may have changed since. +/// +/// 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. +/// +public sealed record GraphSnapshot( + CodeGraph.Graph.CodeGraph Graph, + string SourceName, + DateTimeOffset CapturedAtUtc, + bool ContainsRefactorings); diff --git a/CSharpCodeAnalyst.Mcp/Contracts/ICodeGraphSnapshotSource.cs b/CSharpCodeAnalyst.Mcp/Contracts/ICodeGraphSnapshotSource.cs new file mode 100644 index 00000000..ac674c1d --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Contracts/ICodeGraphSnapshotSource.cs @@ -0,0 +1,22 @@ +namespace CSharpCodeAnalyst.Mcp.Contracts; + +/// +/// 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. +/// +/// 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 +/// . +/// +/// +public interface ICodeGraphSnapshotSource +{ + /// + /// The current snapshot, taken fresh if the graph changed since the last call. + /// Returns null 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. + /// + Task GetSnapshotAsync(CancellationToken cancellationToken = default); +} diff --git a/CSharpCodeAnalyst.Mcp/McpServerHost.cs b/CSharpCodeAnalyst.Mcp/McpServerHost.cs new file mode 100644 index 00000000..821c5267 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/McpServerHost.cs @@ -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; + +/// +/// 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. +/// +/// 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. +/// +/// +public sealed class McpServerHost : IAsyncDisposable +{ + /// + /// 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. + /// + public const string EndpointPath = "/mcp"; + + private WebApplication? _app; + + public bool IsRunning => _app is not null; + + /// + /// The URL to configure a client with, once started. Null while stopped. + /// + public Uri? Endpoint { get; private set; } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + } + + /// + /// 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. + /// + 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() + .WithTools() + .WithTools(); + + 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()?.InformationalVersion; + + // A deterministic build appends "+" 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"; + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/ElementFormatter.cs b/CSharpCodeAnalyst.Mcp/Tools/ElementFormatter.cs new file mode 100644 index 00000000..9fe76f96 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/ElementFormatter.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Text; +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// 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. +/// +/// 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. +/// +/// +internal static class ElementFormatter +{ + /// + /// A single element, as used in lists: kind, full path, id, and where it is defined. + /// Example: [Class] Sample.Core.OrderService id=8f3c... Orders.cs:42 + /// + 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(); + } + + /// + /// A source location as file:line, 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. + /// + 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)}"; + } + + /// + /// Counts per kind, ordered by count, as 8 Calls, 3 Uses, 1 Inherits. Gives a caller the + /// shape of a result before it reads the entries - and often that is already the answer. + /// + public static string Summarize(IEnumerable items, Func 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); + } + + /// + /// Appends at most 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". + /// + public static void AppendLimited(StringBuilder text, IReadOnlyList 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."); + } + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/ElementTools.cs b/CSharpCodeAnalyst.Mcp/Tools/ElementTools.cs new file mode 100644 index 00000000..6b4bff98 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/ElementTools.cs @@ -0,0 +1,265 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeGraph.Search; +using CSharpCodeAnalyst.Mcp.Contracts; +using ModelContextProtocol.Server; + +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// Tools that work on a single code element. +/// +[McpServerToolType] +public sealed class ElementTools(ICodeGraphSnapshotSource snapshotSource) +{ + /// + /// A type with many members still fits; a namespace with hundreds of types does not, and listing + /// them all would bury the rest of the answer. + /// + private const int ChildLimit = 40; + + private const int LocationLimit = 5; + + private const int DefaultSearchLimit = 50; + + /// + /// A caller that asks for a thousand hits does not want to read them - it wants to be sure it + /// saw everything, and a truncation notice answers that better than a flooded context window. + /// + private const int MaxSearchLimit = 200; + + [McpServerTool(Name = "search_elements", ReadOnly = true, Destructive = false, Idempotent = true, + OpenWorld = false)] + [Description( + "Finds code elements by name. This is the entry point for every other tool, because element " + + "ids cannot be guessed.\n" + + "Query syntax. An all-lowercase term matches anywhere in the full name, case-insensitively. " + + "A term containing an uppercase letter switches to camel-hump matching: it is split at every " + + "uppercase letter and the parts must occur in that order, each starting a word, matched " + + "case-sensitively. So 'OS', 'OrdServ' and 'OrderService' all find 'OrderService', but 'OSvc' " + + "finds nothing, because 'Svc' does not occur in the name.\n" + + "Space means AND, '|' means OR, a leading '-' excludes. 'type:class' (also interface, struct, " + + "record, method, property, field, event, enum, delegate, namespace, assembly) restricts the " + + "kind; 'source:extern', 'source:intern' and 'source:generated' restrict the origin.\n" + + "Example: 'order type:class -source:extern' finds classes in the analyzed code whose full " + + "name contains 'order'.")] + public async Task SearchElementsAsync( + [Description("Search expression, see the syntax above.")] + string query, + [Description("Maximum number of results (default 50, capped at 200).")] + int limit = DefaultSearchLimit, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + return "The query is empty. Pass a name or a search expression."; + } + + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var effectiveLimit = Math.Clamp(limit, 1, MaxSearchLimit); + var expression = SearchExpressionFactory.CreateSearchExpression(query); + + var matches = snapshot.Graph.Nodes.Values + .Where(element => expression.Evaluate(element)) + .OrderBy(element => Rank(element, query)) + .ThenBy(element => element.IsExternal) + .ThenBy(element => element.ElementType) + .ThenBy(element => element.FullName, StringComparer.Ordinal) + .ToList(); + + if (matches.Count == 0) + { + return $"Nothing matches '{query}'. Note that the search runs over the graph, not over " + + "your files: anything the parser did not see is not in it, and external code is " + + "only present as far as it is referenced."; + } + + var text = new StringBuilder(); + text.Append(matches.Count.ToString(CultureInfo.InvariantCulture)) + .Append(" match(es) for '").Append(query).Append("': ") + .AppendLine(ElementFormatter.Summarize(matches, m => m.ElementType.ToString())); + text.AppendLine(); + + ElementFormatter.AppendLimited(text, matches, effectiveLimit); + + return text.ToString(); + } + + /// + /// Puts the element the caller most likely meant first. The expression itself does not rank - + /// it only says yes or no - so a search for "OrderService" would otherwise bury the type among + /// its own members and everything else whose full name contains the word. + /// + /// Only meaningful when the query is a plain name. For an expression with operators nothing + /// matches exactly, everything lands in the last bucket, and the remaining sort keys decide. + /// + /// + private static int Rank(CodeElement element, string query) + { + if (string.Equals(element.Name, query, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + return element.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 1 : 2; + } + + [McpServerTool(Name = "describe_element", ReadOnly = true, Destructive = false, Idempotent = true, + OpenWorld = false)] + [Description( + "Everything known about one code element: kind, full path, accessibility, where it is defined, " + + "what it contains, and how many relationships run in and out. Takes an id from search_elements.")] + public async Task DescribeElementAsync( + [Description("Element id, as returned by search_elements.")] + string id, + CancellationToken cancellationToken = default) + { + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var element = snapshot.Graph.TryGetCodeElement(id); + if (element is null) + { + return ToolText.UnknownId(id); + } + + var text = new StringBuilder(); + + text.Append('[').Append(element.ElementType).Append("] ").AppendLine(element.FullName); + text.Append("id: ").AppendLine(element.Id); + + AppendFlags(text, element); + AppendLocations(text, element); + AppendParents(text, element); + AppendChildren(text, element); + AppendRelationships(text, snapshot.Graph, element); + + return text.ToString(); + } + + private static void AppendFlags(StringBuilder text, CodeElement element) + { + // Unknown is not a value, it means the producer told us nothing - reporting it as an access + // level would invite exactly the conclusion the model must not draw. + if (element.AccessLevel != AccessLevel.Unknown) + { + text.Append("Access: ").AppendLine(element.AccessLevel.ToString()); + } + + if (element.IsExternal) + { + text.AppendLine( + "External: defined outside the analyzed code. Its own dependencies were not analyzed, " + + "so an empty outgoing result says nothing about it."); + } + + if (element.IsGenerated) + { + text.AppendLine("Generated: written by a tool. Editing it by hand has no lasting effect."); + } + + if (element.Attributes.Count > 0) + { + text.Append("Attributes: ").AppendLine(string.Join(", ", element.Attributes.Order())); + } + } + + private static void AppendLocations(StringBuilder text, CodeElement element) + { + if (element.SourceLocations.Count == 0) + { + return; + } + + text.AppendLine(); + text.AppendLine(element.SourceLocations.Count == 1 ? "Defined in:" : "Declarations:"); + + foreach (var location in element.SourceLocations.Take(LocationLimit)) + { + text.Append(" ").Append(location.File).Append(':') + .AppendLine(location.Line.ToString(CultureInfo.InvariantCulture)); + } + + if (element.SourceLocations.Count > LocationLimit) + { + var omitted = element.SourceLocations.Count - LocationLimit; + text.Append(" ... ").Append(omitted.ToString(CultureInfo.InvariantCulture)) + .AppendLine(" more"); + } + } + + private static void AppendParents(StringBuilder text, CodeElement element) + { + var path = element.GetPathToRoot(false); + if (path.Count == 0) + { + return; + } + + text.AppendLine(); + text.AppendLine("Contained in:"); + foreach (var ancestor in path) + { + text.Append(" ").AppendLine(ElementFormatter.Line(ancestor)); + } + } + + private static void AppendChildren(StringBuilder text, CodeElement element) + { + if (element.Children.Count == 0) + { + return; + } + + var children = element.Children + .OrderBy(child => child.ElementType) + .ThenBy(child => child.Name, StringComparer.Ordinal) + .ToList(); + + text.AppendLine(); + text.Append("Contains (").Append(children.Count.ToString(CultureInfo.InvariantCulture)) + .Append("): ") + .AppendLine(ElementFormatter.Summarize(children, child => child.ElementType.ToString())); + + ElementFormatter.AppendLimited(text, children, ChildLimit); + } + + private static void AppendRelationships(StringBuilder text, CodeGraph.Graph.CodeGraph graph, + CodeElement element) + { + text.AppendLine(); + + // Outgoing relationships live on the element itself. Incoming ones do not - the graph stores + // every relationship on its source - so finding them means one pass over all of them. At the + // scale of a solution that is cheap, and the number is worth having: it is the difference + // between a leaf and something the whole code base leans on. + var outgoing = element.Relationships; + if (outgoing.Count > 0) + { + text.Append("Outgoing relationships (") + .Append(outgoing.Count.ToString(CultureInfo.InvariantCulture)).Append("): ") + .AppendLine(ElementFormatter.Summarize(outgoing, r => r.Type.ToString())); + } + else + { + text.AppendLine("Outgoing relationships: none"); + } + + var incoming = graph.GetAllRelationships().Count(r => r.TargetId == element.Id); + text.Append("Incoming relationships: ") + .AppendLine(incoming.ToString(CultureInfo.InvariantCulture)); + + text.AppendLine( + "Use find_outgoing_relationships or find_incoming_relationships for the actual entries."); + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/GraphInfoTools.cs b/CSharpCodeAnalyst.Mcp/Tools/GraphInfoTools.cs new file mode 100644 index 00000000..1cd9c5d5 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/GraphInfoTools.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Contracts; +using ModelContextProtocol.Server; + +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// Tells a caller what it is actually looking at before it starts asking questions about it. +/// +[McpServerToolType] +public sealed class GraphInfoTools(ICodeGraphSnapshotSource snapshotSource) +{ + [McpServerTool(Name = "graph_info", ReadOnly = true, Destructive = false, Idempotent = true, + OpenWorld = false)] + [Description( + "Describes the code graph currently loaded in CSharp Code Analyst: what it was built from, " + + "when it was captured, how large it is, and which assemblies it contains. Call this first. " + + "The graph is a snapshot, so it can be older than the files on disk, and it can contain " + + "simulated refactorings that were never applied to the source - both are reported here and " + + "both change how much the other answers can be trusted.")] + public async Task GraphInfoAsync(CancellationToken cancellationToken = default) + { + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var graph = snapshot.Graph; + var text = new StringBuilder(); + + text.Append("Source: ").AppendLine(string.IsNullOrEmpty(snapshot.SourceName) + ? "unknown" + : snapshot.SourceName); + text.Append("Captured: ") + .Append(snapshot.CapturedAtUtc.ToString("u", CultureInfo.InvariantCulture)) + .AppendLine(" (source files may have changed since)"); + + if (snapshot.ContainsRefactorings) + { + text.AppendLine( + "WARNING: this graph contains simulated refactorings. It describes a hypothetical " + + "code base, not the code on disk. Say so when reporting anything derived from it."); + } + + text.Append("Code elements: ") + .AppendLine(graph.Nodes.Count.ToString(CultureInfo.InvariantCulture)); + text.Append("Relationships: ") + .AppendLine(graph.GetAllRelationships().Count().ToString(CultureInfo.InvariantCulture)); + + AppendAssemblies(text, graph); + + text.AppendLine(); + text.AppendLine( + "Element ids are opaque and valid only while this server runs. Use search_elements to " + + "find an element and obtain its id."); + + return text.ToString(); + } + + private static void AppendAssemblies(StringBuilder text, CodeGraph.Graph.CodeGraph graph) + { + // Roots are the assemblies: the parser puts everything below one, inserting a synthetic + // namespace where code sits at the root, so nothing else ends up parentless. + var assemblies = graph.GetRoots() + .Where(root => root.ElementType == CodeElementType.Assembly) + .OrderBy(root => root.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (assemblies.Count == 0) + { + return; + } + + text.AppendLine(); + text.Append("Assemblies (").Append(assemblies.Count.ToString(CultureInfo.InvariantCulture)) + .AppendLine("):"); + foreach (var assembly in assemblies) + { + text.Append(" ").Append(assembly.Name); + if (assembly.IsExternal) + { + text.Append(" [external]"); + } + + text.AppendLine(); + } + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/RelationshipFormatter.cs b/CSharpCodeAnalyst.Mcp/Tools/RelationshipFormatter.cs new file mode 100644 index 00000000..12e6d2bb --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/RelationshipFormatter.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using System.Text; +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// Writes down relationships around a known element. +/// +/// One end of every relationship is the element the caller asked about, so spelling both ends +/// out in full would repeat the same name on every line. Only the far end gets its full name and +/// id - that is the one worth a follow up question. The near end appears only when it differs +/// from the anchor, which happens in the deep searches, where the relationship may start at a +/// member rather than at the element itself. +/// +/// +internal static class RelationshipFormatter +{ + public static void Append(StringBuilder text, CodeGraph.Graph.CodeGraph graph, + IReadOnlyList relationships, CodeElement anchor, bool anchorIsSource, int limit) + { + var lines = relationships + .Select(relationship => Describe(graph, relationship, anchor, anchorIsSource)) + .Where(line => line is not null) + .Select(line => line!) + .OrderBy(line => line, StringComparer.Ordinal) + .ToList(); + + foreach (var line in lines.Take(limit)) + { + text.Append(" ").AppendLine(line); + } + + if (lines.Count > limit) + { + var omitted = lines.Count - limit; + text.Append(" ... ").Append(omitted.ToString(CultureInfo.InvariantCulture)) + .Append(" more not shown (") + .Append(lines.Count.ToString(CultureInfo.InvariantCulture)) + .AppendLine(" in total). Raise the limit or ask a narrower question."); + } + } + + private static string? Describe(CodeGraph.Graph.CodeGraph graph, Relationship relationship, + CodeElement anchor, bool anchorIsSource) + { + var nearId = anchorIsSource ? relationship.SourceId : relationship.TargetId; + var farId = anchorIsSource ? relationship.TargetId : relationship.SourceId; + + var far = graph.TryGetCodeElement(farId); + if (far is null) + { + // A relationship pointing at something the graph does not contain would be a defect + // elsewhere. Skipping it keeps this tool from turning that into an exception. + return null; + } + + var text = new StringBuilder(); + text.Append(relationship.Type.ToString().PadRight(14)); + + if (nearId != anchor.Id) + { + var near = graph.TryGetCodeElement(nearId); + if (near is not null) + { + text.Append(near.Name).Append(' '); + } + } + + text.Append(anchorIsSource ? "-> " : "<- "); + text.Append(ElementFormatter.Line(far)); + + // The relationship's own location is the call site, which is more specific than the + // declaration of the far element that ElementFormatter already printed. + var site = relationship.SourceLocations.FirstOrDefault(); + if (site?.File is not null) + { + text.Append(" at ").Append(Path.GetFileName(site.File)).Append(':') + .Append(site.Line.ToString(CultureInfo.InvariantCulture)); + } + + return text.ToString(); + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/RelationshipTools.cs b/CSharpCodeAnalyst.Mcp/Tools/RelationshipTools.cs new file mode 100644 index 00000000..a2237217 --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/RelationshipTools.cs @@ -0,0 +1,376 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using CSharpCodeAnalyst.CodeGraph.Exploration; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Contracts; +using ModelContextProtocol.Server; + +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// Tools that follow relationships: what an element depends on, what depends on it, and how two +/// elements are connected. +/// +[McpServerToolType] +public sealed class RelationshipTools(ICodeGraphSnapshotSource snapshotSource) +{ + private const int DefaultLimit = 50; + private const int MaxLimit = 300; + + /// + /// Enough to see the shape of a bundle without turning one question into a wall of text. + /// + private const int MaxRenderedPaths = 15; + + [McpServerTool(Name = "find_outgoing_relationships", ReadOnly = true, Destructive = false, + Idempotent = true, OpenWorld = false)] + [Description( + "What an element depends on: the relationships that start at it - calls, uses, inherits, " + + "implements and so on.\n" + + "deep=true additionally follows relationships that start at contained elements, so asking a " + + "class covers what its methods depend on. It reports only what LEAVES the element: a call " + + "from one method of the class to another stays inside and is not listed. To see those, ask " + + "about the member itself with deep=false.")] + public async Task FindOutgoingRelationshipsAsync( + [Description("Element id from search_elements.")] + string id, + [Description("Include relationships that start at contained elements and point outside the " + + "element. Default false.")] + bool deep = false, + [Description("Maximum number of relationships listed (default 50, capped at 300).")] + int limit = DefaultLimit, + CancellationToken cancellationToken = default) + { + return await ExploreAsync(id, limit, true, deep, cancellationToken); + } + + [McpServerTool(Name = "find_incoming_relationships", ReadOnly = true, Destructive = false, + Idempotent = true, OpenWorld = false)] + [Description( + "What depends on an element: the relationships that end at it. This is the blast radius of a " + + "change.\n" + + "deep=true additionally follows relationships that end at contained elements, so asking a " + + "class covers everything reaching into its members. It reports only what comes from OUTSIDE " + + "the element: one member using another stays inside and is not listed.")] + public async Task FindIncomingRelationshipsAsync( + [Description("Element id from search_elements.")] + string id, + [Description("Include relationships that end at contained elements and come from outside the " + + "element. Default false.")] + bool deep = false, + [Description("Maximum number of relationships listed (default 50, capped at 300).")] + int limit = DefaultLimit, + CancellationToken cancellationToken = default) + { + return await ExploreAsync(id, limit, false, deep, cancellationToken); + } + + [McpServerTool(Name = "find_incoming_calls", ReadOnly = true, Destructive = false, + Idempotent = true, OpenWorld = false)] + [Description( + "Who calls a method, transitively - the full chain of callers, not just the direct ones.\n" + + "followAbstractions=true (the default) also treats a call to an interface or base declaration " + + "as reaching the implementation, which is what you want for 'who can end up here'. It is a " + + "heuristic: the graph is static and cannot know which implementation runs, so a reported " + + "caller may never actually reach this method at runtime.\n" + + "followAbstractions=false follows only direct call edges. Every result is then certain, but " + + "callers that arrive through virtual dispatch or events are missing - an empty result does " + + "NOT mean nothing calls it.")] + public async Task FindIncomingCallsAsync( + [Description("Id of a method, from search_elements.")] + string id, + [Description("Follow interface and base declarations. Default true.")] + bool followAbstractions = true, + [Description("Maximum number of callers listed (default 50, capped at 300).")] + int limit = DefaultLimit, + CancellationToken cancellationToken = default) + { + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var element = snapshot.Graph.TryGetCodeElement(id); + if (element is null) + { + return ToolText.UnknownId(id); + } + + var explorer = CreateExplorer(snapshot); + var result = followAbstractions + ? explorer.FollowIncomingCallsHeuristically(id) + : explorer.FindIncomingCallsRecursive(id); + + // The start method is part of the traversal and comes back with the result; as a "caller of + // itself" it is noise. + var callers = result.Elements.Where(caller => caller.Id != id).ToList(); + + var text = new StringBuilder(); + text.Append("Callers of ").AppendLine(ElementFormatter.Line(element)); + + if (followAbstractions) + { + text.AppendLine( + "Heuristic. The list contains callers that only reach this method through an interface " + + "or base declaration - some of them may never reach it at runtime - and the " + + "declarations themselves, which are steps on the route rather than callers."); + } + else + { + text.AppendLine( + "Direct call edges only. Callers going through virtual dispatch or events are not " + + "listed - an empty result does not prove the method is unused."); + } + + text.AppendLine(); + + if (callers.Count == 0) + { + text.AppendLine("No callers found."); + return text.ToString(); + } + + text.Append(callers.Count.ToString(CultureInfo.InvariantCulture)).Append(" caller(s): ") + .AppendLine(ElementFormatter.Summarize(callers, c => c.ElementType.ToString())); + + ElementFormatter.AppendLimited(text, callers, Math.Clamp(limit, 1, MaxLimit)); + return text.ToString(); + } + + [McpServerTool(Name = "find_paths_between", ReadOnly = true, Destructive = false, + Idempotent = true, OpenWorld = false)] + [Description( + "How two elements are connected: the shortest dependency chains from one to the other, " + + "through any number of elements in between. Answers 'these two are related somehow, but how?'\n" + + "Both elements are expanded to their contents first, so asking about two classes finds the " + + "concrete chain between their methods. Only real dependencies are followed - containment is " + + "not a path, or everything would be connected through a common ancestor. All chains of the " + + "shortest length are reported, because one alone would hide whether the connection is a " + + "single thin wire or a bundle.")] + public async Task FindPathsBetweenAsync( + [Description("Id of the element the chain starts at.")] + string sourceId, + [Description("Id of the element the chain ends at.")] + string targetId, + [Description("Maximum number of relationships in a chain. Default 5. Pairs whose shortest " + + "chain is longer are reported as unconnected, which keeps an unrelated pair " + + "from pulling in half the graph.")] + int maxLength = 5, + CancellationToken cancellationToken = default) + { + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var source = snapshot.Graph.TryGetCodeElement(sourceId); + if (source is null) + { + return ToolText.UnknownId(sourceId); + } + + var target = snapshot.Graph.TryGetCodeElement(targetId); + if (target is null) + { + return ToolText.UnknownId(targetId); + } + + var explorer = CreateExplorer(snapshot); + var result = explorer.FindPathsBetween([sourceId, targetId], Math.Max(1, maxLength)); + + var text = new StringBuilder(); + text.Append("From ").AppendLine(ElementFormatter.Line(source)); + text.Append("To ").AppendLine(ElementFormatter.Line(target)); + text.AppendLine(); + + if (result.Relationships.Count == 0) + { + text.Append("No dependency chain of ") + .Append(maxLength.ToString(CultureInfo.InvariantCulture)) + .AppendLine(" relationships or fewer connects them in either direction."); + text.AppendLine( + "They may still be connected over a longer chain - raise maxLength - or only through " + + "a shared parent, which is not a dependency and deliberately not reported as a path."); + return text.ToString(); + } + + AppendPaths(text, snapshot.Graph, result, source, target, maxLength); + AppendPaths(text, snapshot.Graph, result, target, source, maxLength); + + return text.ToString(); + } + + /// + /// A fresh explorer per call, bound to the snapshot this call answers from. It holds the graph + /// in a field, so a shared instance would be a race the moment two calls straddle a snapshot + /// change - and creating one costs a single field assignment. + /// + private static CodeGraphExplorer CreateExplorer(GraphSnapshot snapshot) + { + var explorer = new CodeGraphExplorer(); + explorer.LoadCodeGraph(snapshot.Graph); + return explorer; + } + + private async Task ExploreAsync(string id, int limit, bool outgoing, bool deep, + CancellationToken cancellationToken) + { + var snapshot = await snapshotSource.GetSnapshotAsync(cancellationToken); + if (snapshot is null) + { + return ToolText.NoProjectLoaded; + } + + var element = snapshot.Graph.TryGetCodeElement(id); + if (element is null) + { + return ToolText.UnknownId(id); + } + + var explorer = CreateExplorer(snapshot); + var result = outgoing + ? deep ? explorer.FindOutgoingRelationshipsDeep(id) : explorer.FindOutgoingRelationships(id) + : deep ? explorer.FindIncomingRelationshipsDeep(id) : explorer.FindIncomingRelationships(id); + + var text = new StringBuilder(); + text.Append(outgoing ? "Outgoing from " : "Incoming to ") + .AppendLine(ElementFormatter.Line(element)); + + if (deep) + { + // Without this the reader draws the wrong conclusion from a short result: the internal + // relationships are missing by design, not because they do not exist. + text.AppendLine(outgoing + ? "Including contained elements. Only relationships leaving this element are listed - " + + "one member calling another is internal and not shown." + : "Including contained elements. Only relationships arriving from outside this element " + + "are listed - one member using another is internal and not shown."); + } + + if (element.IsExternal && outgoing) + { + text.AppendLine( + "This element is external. Its own dependencies were never analyzed, so an empty " + + "result says nothing about it."); + } + + text.AppendLine(); + + var relationships = result.Relationships; + if (relationships.Count == 0) + { + text.AppendLine("No relationships found."); + return text.ToString(); + } + + text.Append(relationships.Count.ToString(CultureInfo.InvariantCulture)) + .Append(" relationship(s): ") + .AppendLine(ElementFormatter.Summarize(relationships, r => r.Type.ToString())); + text.AppendLine(); + + RelationshipFormatter.Append(text, snapshot.Graph, relationships, element, outgoing, + Math.Clamp(limit, 1, MaxLimit)); + + return text.ToString(); + } + + /// + /// Turns the returned sub graph back into readable chains. The explorer answers with a set of + /// elements and relationships, not with ordered paths, so they are walked out again here - a + /// chain the reader can follow is worth far more than an edge list they have to reassemble. + /// + /// Both ends are expanded to their contents by the search, so a chain usually starts at a + /// member rather than at the element that was asked about. The walk therefore begins at every + /// descendant of that the result contains. + /// + /// + private static void AppendPaths(StringBuilder text, CodeGraph.Graph.CodeGraph graph, + SearchResult result, CodeElement from, CodeElement to, int maxLength) + { + var edgesBySource = result.Relationships.ToLookup(relationship => relationship.SourceId); + var starts = from.GetChildrenIncludingSelf(); + var ends = to.GetChildrenIncludingSelf(); + + var paths = new List>(); + foreach (var startId in starts) + { + Walk(startId, [], new HashSet { startId }); + if (paths.Count >= MaxRenderedPaths) + { + break; + } + } + + if (paths.Count == 0) + { + return; + } + + text.Append(from.Name).Append(" -> ").Append(to.Name).Append(" (") + .Append(paths.Count.ToString(CultureInfo.InvariantCulture)) + .AppendLine(paths.Count >= MaxRenderedPaths ? "+ chains)" : " chain(s))"); + + foreach (var path in paths) + { + text.Append(" ").AppendLine(RenderPath(graph, path)); + } + + text.AppendLine(); + return; + + void Walk(string currentId, List soFar, HashSet visited) + { + if (paths.Count >= MaxRenderedPaths || soFar.Count >= maxLength) + { + return; + } + + foreach (var edge in edgesBySource[currentId]) + { + if (!visited.Add(edge.TargetId)) + { + continue; + } + + soFar.Add(edge); + + if (ends.Contains(edge.TargetId)) + { + paths.Add([.. soFar]); + } + else + { + Walk(edge.TargetId, soFar, visited); + } + + soFar.RemoveAt(soFar.Count - 1); + visited.Remove(edge.TargetId); + + if (paths.Count >= MaxRenderedPaths) + { + return; + } + } + } + } + + private static string RenderPath(CodeGraph.Graph.CodeGraph graph, List path) + { + var text = new StringBuilder(); + + var first = graph.TryGetCodeElement(path[0].SourceId); + text.Append(first?.FullName ?? path[0].SourceId); + + foreach (var edge in path) + { + var next = graph.TryGetCodeElement(edge.TargetId); + text.Append(" --").Append(edge.Type).Append("--> "); + text.Append(next?.FullName ?? edge.TargetId); + } + + return text.ToString(); + } +} diff --git a/CSharpCodeAnalyst.Mcp/Tools/ToolText.cs b/CSharpCodeAnalyst.Mcp/Tools/ToolText.cs new file mode 100644 index 00000000..f4c915df --- /dev/null +++ b/CSharpCodeAnalyst.Mcp/Tools/ToolText.cs @@ -0,0 +1,33 @@ +namespace CSharpCodeAnalyst.Mcp.Tools; + +/// +/// Shared wording for answers every tool can end up giving. +/// +/// Tool results are read by a language model, so they are plain text rather than serialized +/// objects: a JSON graph of code elements spends most of its tokens on field names the reader +/// does not need. The same reasoning drives the phrasing - an answer says what to do next +/// instead of only stating that something is missing. +/// +/// +internal static class ToolText +{ + /// + /// The application is running but has no project open. Not an error, so it is answered rather + /// than thrown: an exception would surface as a protocol failure and tell the caller nothing. + /// + public const string NoProjectLoaded = + "No project is loaded in CSharp Code Analyst. Ask the user to open a solution or a saved " + + "project in the application, then try again."; + + /// + /// Ids are regenerated on every parse, so a stale one is the single most likely mistake a caller + /// can make - and it looks exactly like "the element does not exist". The answer names both + /// possibilities, because the recovery differs: search again, or accept that it is gone. + /// + public static string UnknownId(string id) + { + return $"No element with id '{id}' exists in the loaded graph. Ids are only valid while this " + + "server runs and change whenever the project is re-parsed, so an id from an earlier " + + "session will not resolve. Use search_elements to look the element up again."; + } +} diff --git a/CSharpCodeAnalyst.sln b/CSharpCodeAnalyst.sln index b0720f1e..ccfbeae8 100644 --- a/CSharpCodeAnalyst.sln +++ b/CSharpCodeAnalyst.sln @@ -56,6 +56,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DsmSuite.DsmViewer.ViewMode EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpCodeAnalyst.Importers", "CSharpCodeAnalyst.Importers\CSharpCodeAnalyst.Importers.csproj", "{965A3D97-B328-4A0A-88D9-9943ECDEA184}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpCodeAnalyst.Mcp", "CSharpCodeAnalyst.Mcp\CSharpCodeAnalyst.Mcp.csproj", "{3F2E5505-609D-4ADA-9713-340146252F66}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -282,6 +284,18 @@ Global {965A3D97-B328-4A0A-88D9-9943ECDEA184}.Release|x64.Build.0 = Release|Any CPU {965A3D97-B328-4A0A-88D9-9943ECDEA184}.Release|x86.ActiveCfg = Release|Any CPU {965A3D97-B328-4A0A-88D9-9943ECDEA184}.Release|x86.Build.0 = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|x64.ActiveCfg = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|x64.Build.0 = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|x86.ActiveCfg = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Debug|x86.Build.0 = Debug|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|Any CPU.Build.0 = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|x64.ActiveCfg = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|x64.Build.0 = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|x86.ActiveCfg = Release|Any CPU + {3F2E5505-609D-4ADA-9713-340146252F66}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CSharpCodeAnalyst/App.xaml.cs b/CSharpCodeAnalyst/App.xaml.cs index c772ad10..b075296f 100644 --- a/CSharpCodeAnalyst/App.xaml.cs +++ b/CSharpCodeAnalyst/App.xaml.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Diagnostics; +using System.IO; using System.Windows; using CSharpCodeAnalyst.AnalyzerSdk.Messages; using CSharpCodeAnalyst.CodeGraph.Exploration; @@ -7,9 +8,12 @@ using CSharpCodeAnalyst.Configuration; using CSharpCodeAnalyst.Features.AdvancedSearch; using CSharpCodeAnalyst.Persistence.Json; +using CSharpCodeAnalyst.Resources; using CSharpCodeAnalyst.Features.Analyzers; using CSharpCodeAnalyst.Features.Graph; using CSharpCodeAnalyst.Features.Info; +using CSharpCodeAnalyst.Features.Mcp; +using CSharpCodeAnalyst.Mcp; using CSharpCodeAnalyst.Features.Refactoring; using CSharpCodeAnalyst.Features.Tree; using CSharpCodeAnalyst.Shared.Messages; @@ -21,6 +25,8 @@ namespace CSharpCodeAnalyst; public partial class App { + private McpServerService? _mcpServerService; + protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); @@ -42,6 +48,12 @@ protected override async void OnStartup(StartupEventArgs e) await LoadProjectFileFromCommandLineAsync(e); } + protected override void OnExit(ExitEventArgs e) + { + _mcpServerService?.StopWithoutWaiting(); + base.OnExit(e); + } + private async Task LoadProjectFileFromCommandLineAsync(StartupEventArgs e) { const string prefix = "-load:"; @@ -127,7 +139,13 @@ private void StartUi() var projectStorage = new JsonProjectStorage(); var projectService = new ProjectService(projectStorage, uiNotification, userSettings); - var viewModel = new MainViewModel(messaging, applicationSettings, userSettings, analyzerManager, refactoringService, projectService, metricStore, externalContractStore); + // Hands the MCP server a copy of the loaded graph. Created unconditionally: it costs nothing + // until something asks it for a snapshot, and MainViewModel notifies it either way. + var mcpSnapshotProvider = new CodeGraphSnapshotProvider(Dispatcher, projectService); + var mcpServerService = new McpServerService(applicationSettings, mcpSnapshotProvider, uiNotification); + _mcpServerService = mcpServerService; + + var viewModel = new MainViewModel(messaging, applicationSettings, userSettings, analyzerManager, refactoringService, projectService, metricStore, externalContractStore, mcpSnapshotProvider, mcpServerService); var graphViewModel = new GraphViewModel(graphViewState, explorer, messaging, applicationSettings, refactoringService); var treeViewModel = new TreeViewModel(messaging, refactoringService); var searchViewModel = new AdvancedSearchViewModel(messaging, refactoringService); diff --git a/CSharpCodeAnalyst/CSharpCodeAnalyst.csproj b/CSharpCodeAnalyst/CSharpCodeAnalyst.csproj index 2a09feae..f6927b2e 100644 --- a/CSharpCodeAnalyst/CSharpCodeAnalyst.csproj +++ b/CSharpCodeAnalyst/CSharpCodeAnalyst.csproj @@ -130,6 +130,8 @@ + + @@ -160,6 +162,7 @@ + diff --git a/CSharpCodeAnalyst/Configuration/AppSettings.cs b/CSharpCodeAnalyst/Configuration/AppSettings.cs index 452f53c5..3e517a04 100644 --- a/CSharpCodeAnalyst/Configuration/AppSettings.cs +++ b/CSharpCodeAnalyst/Configuration/AppSettings.cs @@ -27,6 +27,12 @@ public string DefaultProjectExcludeFilter /// public bool ShowOverviewOnImport { get; set; } = true; + /// + /// TCP port for the MCP endpoint, bound to loopback only. Configurable because the default may + /// already be taken - the client configuration has to name the same port. + /// + public int McpServerPort { get; set; } = 5178; + public static string CleanupProjectFilters(string filterText) { char[] separators = [';', '\n', '\r']; @@ -58,7 +64,8 @@ public AppSettings Clone() IncludeExternalCode = this.IncludeExternalCode, SplitPropertyAccessors = this.SplitPropertyAccessors, WarnIfFiltersActive = this.WarnIfFiltersActive, - ShowOverviewOnImport = this.ShowOverviewOnImport + ShowOverviewOnImport = this.ShowOverviewOnImport, + McpServerPort = this.McpServerPort }; } } diff --git a/CSharpCodeAnalyst/Features/AdvancedSearch/AdvancedSearchViewModel.cs b/CSharpCodeAnalyst/Features/AdvancedSearch/AdvancedSearchViewModel.cs index c249c16b..74488265 100644 --- a/CSharpCodeAnalyst/Features/AdvancedSearch/AdvancedSearchViewModel.cs +++ b/CSharpCodeAnalyst/Features/AdvancedSearch/AdvancedSearchViewModel.cs @@ -4,9 +4,9 @@ using System.Windows.Input; using System.Windows.Threading; using CSharpCodeAnalyst.AnalyzerSdk.Messages; -using CSharpCodeAnalyst.AnalyzerSdk.Search; using CSharpCodeAnalyst.AnalyzerSdk.Wpf; using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeGraph.Search; using CSharpCodeAnalyst.Features.Refactoring; using CSharpCodeAnalyst.Shared.Messages; using CSharpCodeAnalyst.Shared.Services; diff --git a/CSharpCodeAnalyst/Features/Graph/GraphSearchViewModel.cs b/CSharpCodeAnalyst/Features/Graph/GraphSearchViewModel.cs index 6feca74e..b23ab8ba 100644 --- a/CSharpCodeAnalyst/Features/Graph/GraphSearchViewModel.cs +++ b/CSharpCodeAnalyst/Features/Graph/GraphSearchViewModel.cs @@ -1,8 +1,8 @@ using System.ComponentModel; using System.Windows.Input; using System.Windows.Threading; -using CSharpCodeAnalyst.AnalyzerSdk.Search; using CSharpCodeAnalyst.AnalyzerSdk.Wpf; +using CSharpCodeAnalyst.CodeGraph.Search; using CSharpCodeAnalyst.Shared.Wpf; namespace CSharpCodeAnalyst.Features.Graph; diff --git a/CSharpCodeAnalyst/Features/Mcp/CodeGraphSnapshotProvider.cs b/CSharpCodeAnalyst/Features/Mcp/CodeGraphSnapshotProvider.cs new file mode 100644 index 00000000..b631787f --- /dev/null +++ b/CSharpCodeAnalyst/Features/Mcp/CodeGraphSnapshotProvider.cs @@ -0,0 +1,146 @@ +using System.Windows.Threading; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Contracts; +using CSharpCodeAnalyst.Persistence.Contracts; + +namespace CSharpCodeAnalyst.Features.Mcp; + +/// +/// Hands the MCP server a copy of the loaded code graph instead of the graph itself. +/// +/// The live graph is not safe to read from a request thread: the refactoring simulation mutates +/// it in place (move, delete, cut relationships) on the UI thread, and a query walking it at the +/// same time would see a half changed structure. Copying is what makes the two independent - the +/// copy belongs to the MCP layer alone, so no lock is needed on either side. +/// +/// +/// The copy is taken lazily. Loading a project or applying a refactoring only marks the current +/// one stale; the next tool call pays for the copy, and only if there was a change. That keeps +/// the cost off the interactive path, where nobody is waiting for a copy that may never be read. +/// The copy itself still runs on the UI thread - it is the one moment nothing may mutate - so a +/// large graph shows up as a brief pause. Everything after it runs on the request thread. +/// +/// +public sealed class CodeGraphSnapshotProvider(Dispatcher dispatcher, IProjectService projectService) + : ICodeGraphSnapshotSource +{ + /// + /// Serializes the copying, so several tool calls arriving at once do not each start one and + /// then throw all but the last away. + /// + private readonly SemaphoreSlim _captureGate = new(1, 1); + + private readonly object _sync = new(); + + /// Set on the UI thread, read on the UI thread while copying. + private CodeGraph.Graph.CodeGraph? _liveGraph; + + private bool _containsRefactorings; + + private GraphSnapshot? _snapshot; + private bool _isStale = true; + + public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) + { + if (TryGetCurrent(out var current)) + { + return current; + } + + await _captureGate.WaitAsync(cancellationToken); + try + { + // Another call may have captured while this one waited for the gate. + if (TryGetCurrent(out current)) + { + return current; + } + + var captured = await dispatcher.InvokeAsync(Capture, DispatcherPriority.Background, + cancellationToken); + + lock (_sync) + { + _snapshot = captured; + _isStale = false; + } + + return captured; + } + finally + { + _captureGate.Release(); + } + } + + /// + /// A different graph is now the loaded one. Call on the UI thread whenever the application + /// swaps graphs - importing a solution, loading a project, restoring a snapshot. + /// + public void SetGraph(CodeGraph.Graph.CodeGraph graph) + { + _liveGraph = graph; + _containsRefactorings = false; + MarkStale(); + } + + /// + /// The loaded graph was changed in place by a refactoring simulation. Call on the UI thread. + /// The flag is sticky until the next , because from here on the graph no + /// longer describes the code on disk and every answer derived from it has to say so. + /// + public void MarkRefactored() + { + _containsRefactorings = true; + MarkStale(); + } + + /// + /// Throws the copy away. Called when the server stops: switching the feature off should also + /// give back the memory it costs, and nothing is left to answer a question with anyway. + /// + public void Release() + { + lock (_sync) + { + _snapshot = null; + _isStale = true; + } + } + + private void MarkStale() + { + lock (_sync) + { + _isStale = true; + } + } + + /// + /// Note the distinction between "no snapshot" and "a snapshot whose value is null": with no + /// project loaded the captured value is legitimately null, and returning true for it keeps the + /// idle application from copying nothing on every single call. + /// + private bool TryGetCurrent(out GraphSnapshot? snapshot) + { + lock (_sync) + { + snapshot = _snapshot; + return !_isStale; + } + } + + private GraphSnapshot? Capture() + { + if (_liveGraph is null) + { + return null; + } + + return new GraphSnapshot( + _liveGraph.Clone(), + projectService.CurrentFilePath ?? string.Empty, + DateTimeOffset.UtcNow, + _containsRefactorings); + } +} diff --git a/CSharpCodeAnalyst/Features/Mcp/McpServerService.cs b/CSharpCodeAnalyst/Features/Mcp/McpServerService.cs new file mode 100644 index 00000000..455761b2 --- /dev/null +++ b/CSharpCodeAnalyst/Features/Mcp/McpServerService.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using System.Windows; +using CSharpCodeAnalyst.AnalyzerSdk.Notifications; +using CSharpCodeAnalyst.Configuration; +using CSharpCodeAnalyst.Mcp; +using CSharpCodeAnalyst.Resources; + +namespace CSharpCodeAnalyst.Features.Mcp; + +/// +/// Owns the MCP server for the application: starting and stopping it on demand, reporting the +/// result, and handing out the line a user needs to register it with a client. +/// +/// Nothing listens until someone asks for it. That is the point of driving this from a button +/// rather than a setting - a shipped application that opens a socket before anyone wanted one +/// is a decision nobody made. +/// +/// +public sealed class McpServerService( + AppSettings settings, + CodeGraphSnapshotProvider snapshotProvider, + IUserNotification notification) +{ + /// + /// The name the server is registered under in the client. Only a default - the user can pick + /// another - but it decides how the tools are addressed there (mcp__csca__graph_info), so it is + /// worth being short and recognizable. + /// + private const string DefaultClientName = "csca"; + + private readonly McpServerHost _host = new(); + + public bool IsRunning => _host.IsRunning; + + public Uri? Endpoint => _host.Endpoint; + + /// Raised after the server started or stopped, so the UI can follow. + public event EventHandler? StateChanged; + + public async Task ToggleAsync() + { + if (IsRunning) + { + await StopAsync(); + } + else + { + await StartAsync(); + } + } + + /// + /// Starts the server. A failure here - most likely the port being taken - costs the MCP feature + /// and nothing else, so it is reported to the user rather than thrown at the application. + /// + public async Task StartAsync() + { + if (IsRunning) + { + return; + } + + try + { + await _host.StartAsync(snapshotProvider, settings.McpServerPort); + notification.ShowSuccess(string.Format(Strings.Mcp_Started, _host.Endpoint)); + } + catch (Exception ex) + { + Trace.TraceError($"Starting the MCP server failed: {ex}"); + notification.ShowError(string.Format(Strings.Mcp_StartFailed, settings.McpServerPort, + ex.Message)); + } + finally + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + } + + public async Task StopAsync() + { + if (!IsRunning) + { + return; + } + + try + { + await _host.StopAsync(); + } + catch (Exception ex) + { + // The socket is gone either way; a failure while shutting down must not leave the button + // stuck in "running". + Trace.TraceError($"Stopping the MCP server failed: {ex}"); + } + finally + { + // Switching the server off gives back the second copy of the graph. Without this the + // memory stays claimed for a feature nobody is using any more. + snapshotProvider.Release(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Stops without waiting, for application shutdown. Deliberately not awaited by the caller: + /// a tool call still in flight captures the graph on the UI thread, so blocking that thread to + /// wait for the drain would block the drain itself. The process is going away with the socket. + /// + public void StopWithoutWaiting() + { + if (IsRunning) + { + _ = StopAsync(); + } + } + + /// + /// The command that registers this server with Claude Code. Handed over ready to paste, because + /// every part of it is easy to get wrong: the default scope binds the entry to whatever + /// directory the user happened to be in, and the port has to match the running server. + /// + public string GetClientSetupCommand() + { + var endpoint = Endpoint?.ToString() ?? + $"http://127.0.0.1:{settings.McpServerPort}{McpServerHost.EndpointPath}"; + return $"claude mcp add --scope user --transport http {DefaultClientName} {endpoint}"; + } + + public void CopyClientSetupCommand() + { + try + { + Clipboard.SetText(GetClientSetupCommand()); + notification.ShowSuccess(Strings.Mcp_SetupCopied); + } + catch (Exception ex) + { + // Another process can hold the clipboard open. Not worth an error dialog - show the + // command instead, so the user can still copy it by hand. + Trace.TraceError($"Copying the MCP setup command failed: {ex}"); + notification.ShowInfo(GetClientSetupCommand()); + } + } +} diff --git a/CSharpCodeAnalyst/Features/Tree/TreeViewModel.cs b/CSharpCodeAnalyst/Features/Tree/TreeViewModel.cs index adca4fc0..d4e9761d 100644 --- a/CSharpCodeAnalyst/Features/Tree/TreeViewModel.cs +++ b/CSharpCodeAnalyst/Features/Tree/TreeViewModel.cs @@ -4,9 +4,9 @@ using System.Windows; using System.Windows.Input; using CSharpCodeAnalyst.AnalyzerSdk.Messages; -using CSharpCodeAnalyst.AnalyzerSdk.Search; using CSharpCodeAnalyst.AnalyzerSdk.Wpf; using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeGraph.Search; using CSharpCodeAnalyst.Features.Refactoring; using CSharpCodeAnalyst.Shared.Messages; using CSharpCodeAnalyst.Shared.Services; diff --git a/CSharpCodeAnalyst/MainViewModel.cs b/CSharpCodeAnalyst/MainViewModel.cs index cb2f13c5..764b9934 100644 --- a/CSharpCodeAnalyst/MainViewModel.cs +++ b/CSharpCodeAnalyst/MainViewModel.cs @@ -32,6 +32,7 @@ using CSharpCodeAnalyst.Features.History; using CSharpCodeAnalyst.Features.Import; using CSharpCodeAnalyst.Features.Info; +using CSharpCodeAnalyst.Features.Mcp; using CSharpCodeAnalyst.Features.Partitions; using CSharpCodeAnalyst.Features.Refactoring; using CSharpCodeAnalyst.Features.Statistics; @@ -84,6 +85,16 @@ internal sealed class MainViewModel : INotifyPropertyChanged private InfoPanelViewModel? _infoPanelViewModel; private string _loadMessage; + + /// + /// Told when the graph is replaced or refactored, so the MCP server can hand out a fresh copy. + /// Always present, even with the server switched off - it does nothing until someone asks it + /// for a snapshot, which keeps the notifications below free of null checks. + /// + private readonly CodeGraphSnapshotProvider _mcpSnapshotProvider; + + private readonly McpServerService _mcpServerService; + private LegendDialog? _openedLegendDialog; private AdvancedSearchViewModel? _searchViewModel; @@ -92,7 +103,8 @@ internal sealed class MainViewModel : INotifyPropertyChanged internal MainViewModel(MessageBus messaging, AppSettings settings, UserPreferences userSettings, AnalyzerManager analyzerManager, RefactoringService refactoringService, IProjectService projectService, - MetricStore metricStore, ExternalContractStore externalContractStore) + MetricStore metricStore, ExternalContractStore externalContractStore, + CodeGraphSnapshotProvider mcpSnapshotProvider, McpServerService mcpServerService) { // Initialize settings _applicationSettings = settings; @@ -101,6 +113,9 @@ internal MainViewModel(MessageBus messaging, AppSettings settings, UserPreferenc _refactoringService = refactoringService; _metricStore = metricStore; _externalContractStore = externalContractStore; + _mcpSnapshotProvider = mcpSnapshotProvider; + _mcpServerService = mcpServerService; + _mcpServerService.StateChanged += OnMcpServerStateChanged; analyzerManager.AnalyzerDataChanged += OnAnalyzerDataChanged; @@ -165,6 +180,9 @@ internal MainViewModel(MessageBus messaging, AppSettings settings, UserPreferenc OpenRecentFileCommand = new WpfCommand(OnOpenRecentFile); SnapshotCommand = new WpfCommand(OnSnapshot); RestoreCommand = new WpfCommand(OnRestore); + ToggleMcpServerCommand = new WpfCommand(OnToggleMcpServer); + CopyMcpSetupCommand = new WpfCommand(_mcpServerService.CopyClientSetupCommand, + () => _mcpServerService.IsRunning); _loadMessage = string.Empty; @@ -305,6 +323,28 @@ public bool IsLegendOpen public ICommand ExecuteAnalyzerCommand { get; set; } public ICommand SnapshotCommand { get; } + + public ICommand ToggleMcpServerCommand { get; } + public ICommand CopyMcpSetupCommand { get; } + + public bool IsMcpServerRunning => _mcpServerService.IsRunning; + + /// + /// The button says what pressing it does, not what the state is - a label that reads "Running" + /// leaves the user guessing whether clicking starts or stops it. + /// + public string McpServerLabel => + IsMcpServerRunning ? Strings.Mcp_Stop_Label : Strings.Mcp_Start_Label; + + /// + /// Carries the endpoint while running. A user who wants to register the server needs the exact + /// URL, and reconstructing it from a settings file is where the first attempt usually goes + /// wrong. + /// + public string McpServerTooltip => + IsMcpServerRunning + ? string.Format(Strings.Mcp_Tooltip_Running, _mcpServerService.Endpoint) + : Strings.Mcp_Tooltip_Stopped; public ICommand RestoreCommand { get; } @@ -971,6 +1011,7 @@ private void LoadCodeGraph(CodeGraph.Graph.CodeGraph codeGraph) { _codeGraph = codeGraph; _refactoringService.LoadCodeGraph(codeGraph); + _mcpSnapshotProvider.SetGraph(codeGraph); // Rebuild tree view and graph TreeViewModel?.LoadCodeGraph(_codeGraph); @@ -1229,6 +1270,7 @@ public void HandleCodeGraphRefactored(CodeGraphRefactored message) _graphViewModel?.HandleCodeGraphRefactored(message); _treeViewModel?.HandleCodeGraphRefactored(message); _gallery?.HandleCodeGraphRefactored(message); + _mcpSnapshotProvider.MarkRefactored(); // Brute force // LoadCodeGraph(_codeGraph); @@ -1276,6 +1318,30 @@ private void OnSnapshot() } } + private async void OnToggleMcpServer() + { + try + { + await _mcpServerService.ToggleAsync(); + } + catch (Exception ex) + { + // ToggleAsync reports its own failures; anything arriving here is unexpected and must not + // escape an async void handler, which would take the application down. + Trace.WriteLine($"Failed {nameof(OnToggleMcpServer)} {ex}"); + } + } + + private void OnMcpServerStateChanged(object? sender, EventArgs e) + { + OnPropertyChanged(nameof(IsMcpServerRunning)); + OnPropertyChanged(nameof(McpServerLabel)); + OnPropertyChanged(nameof(McpServerTooltip)); + + // The copy command is only available while the server runs. + WpfCommand.RaiseCanExecuteChanged(); + } + private void OnRestore() { // Note we do not touch the dirty flags when restoring. diff --git a/CSharpCodeAnalyst/MainWindow.xaml b/CSharpCodeAnalyst/MainWindow.xaml index 4539dbec..078a83dd 100644 --- a/CSharpCodeAnalyst/MainWindow.xaml +++ b/CSharpCodeAnalyst/MainWindow.xaml @@ -187,7 +187,6 @@ - + + + + + + + + + + + + + + diff --git a/CSharpCodeAnalyst/Resources/Strings.Designer.cs b/CSharpCodeAnalyst/Resources/Strings.Designer.cs index e8054d04..636bad58 100644 --- a/CSharpCodeAnalyst/Resources/Strings.Designer.cs +++ b/CSharpCodeAnalyst/Resources/Strings.Designer.cs @@ -528,7 +528,97 @@ public static string Cmd_UnknownCommandLineArgs { return ResourceManager.GetString("Cmd_UnknownCommandLineArgs", resourceCulture); } } - + + /// + /// Looks up a localized string similar to The MCP server could not be started on port {0}. ... + /// + public static string Mcp_StartFailed { + get { + return ResourceManager.GetString("Mcp_StartFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MCP. + /// + public static string Mcp_Header { + get { + return ResourceManager.GetString("Mcp_Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start MCP server. + /// + public static string Mcp_Start_Label { + get { + return ResourceManager.GetString("Mcp_Start_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stop MCP server. + /// + public static string Mcp_Stop_Label { + get { + return ResourceManager.GetString("Mcp_Stop_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open a local endpoint so an AI assistant can ask questions ... + /// + public static string Mcp_Tooltip_Stopped { + get { + return ResourceManager.GetString("Mcp_Tooltip_Stopped", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Running at {0} ... + /// + public static string Mcp_Tooltip_Running { + get { + return ResourceManager.GetString("Mcp_Tooltip_Running", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy setup command. + /// + public static string Mcp_CopySetup_Label { + get { + return ResourceManager.GetString("Mcp_CopySetup_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copies the command that registers this server with Claude Code ... + /// + public static string Mcp_CopySetup_Tooltip { + get { + return ResourceManager.GetString("Mcp_CopySetup_Tooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MCP server running at {0}. + /// + public static string Mcp_Started { + get { + return ResourceManager.GetString("Mcp_Started", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Setup command copied. ... + /// + public static string Mcp_SetupCopied { + get { + return ResourceManager.GetString("Mcp_SetupCopied", resourceCulture); + } + } + /// /// Looks up a localized string similar to Verify architectural rules. /// diff --git a/CSharpCodeAnalyst/Resources/Strings.resx b/CSharpCodeAnalyst/Resources/Strings.resx index f8529210..cff93baf 100644 --- a/CSharpCodeAnalyst/Resources/Strings.resx +++ b/CSharpCodeAnalyst/Resources/Strings.resx @@ -803,6 +803,42 @@ Search with resharper style = Use at least one uppercase character in a search t The command to execute is unknown or the required parameters are missing. + + The MCP server could not be started on port {0}. The rest of the application is unaffected; only AI assistants cannot query the code graph. If the port is already in use, change McpServerPort in appsettings.json. + +{1} + + + MCP + + + Start MCP server + + + Stop MCP server + + + Open a local endpoint so an AI assistant can ask questions about the loaded code graph: who calls what, what depends on what, how two elements are connected. + +Nothing is reachable until you start it, and only from this machine. All queries are read-only. + + + Running at {0} + +Use "Copy setup command" to get the line that registers it with Claude Code. Stopping also frees the graph copy the server works on. + + + Copy setup command + + + Copies the command that registers this server with Claude Code, with the right scope and the port actually in use. + + + MCP server running at {0} + + + Setup command copied. Run it in a terminal to register the server with Claude Code. + Solution file not found. diff --git a/CSharpCodeAnalyst/Resources/server_32.png b/CSharpCodeAnalyst/Resources/server_32.png new file mode 100644 index 00000000..58734b51 Binary files /dev/null and b/CSharpCodeAnalyst/Resources/server_32.png differ diff --git a/CSharpCodeAnalyst/appsettings.json b/CSharpCodeAnalyst/appsettings.json index 54666168..23dedd09 100644 --- a/CSharpCodeAnalyst/appsettings.json +++ b/CSharpCodeAnalyst/appsettings.json @@ -5,6 +5,8 @@ "AutomaticallyAddContainingType": false, "IncludeExternalCode": false, "SplitPropertyAccessors": true, - "ShowOverviewOnImport": true + "ShowOverviewOnImport": true, + "McpServerAutoStart": false, + "McpServerPort": 5178 } -} \ No newline at end of file +} diff --git a/Documentation/mcp.md b/Documentation/mcp.md new file mode 100644 index 00000000..c2250c98 --- /dev/null +++ b/Documentation/mcp.md @@ -0,0 +1,291 @@ +# MCP Server + +CSharp Code Analyst can open a local endpoint that speaks the [Model Context +Protocol](https://modelcontextprotocol.io), so an AI assistant such as Claude Code can ask questions +about the dependency graph you currently have loaded. + +The point is not another search. It is that the graph knows things a coding assistant cannot easily +find out on its own: who calls a method transitively, how two classes are connected, what a change +would break. Grep does not answer those, and a language server only answers the first one, one hop at +a time. + +The server answers from the graph **loaded in the application** — not from your source files. That has +consequences worth understanding before you trust an answer; see [What the assistant actually +sees](#what-the-assistant-actually-sees). + +## Requirements + +- The application must be **running** and have a project loaded. The endpoint lives inside it. +- The **ASP.NET Core 10 runtime** must be installed, in addition to the desktop runtime. It comes with + the .NET SDK, so a developer machine almost always has it. Without it the application does not + start at all. +- An MCP-capable client. The instructions below use Claude Code; the endpoint is a standard MCP + server and any client that supports the HTTP transport works. + +## Turning it on + +**Home → AI access → Start MCP server.** Nothing listens until you press it, and pressing it again +stops the server and frees the graph copy it works on. + +The endpoint is + +``` +http://127.0.0.1:5178/mcp +``` + +The button next to it, **Copy setup command**, puts the whole registration line on the clipboard with +the port actually in use — that is the shortest path to a working client, and it avoids the two +mistakes that are easy to make by hand (see [Choosing a scope](#choosing-a-scope)). + +Two settings in `appsettings.json` **next to the executable**: + +| Setting | Default | Meaning | +| --- | --- | --- | +| `McpServerAutoStart` | `false` | Start the server at application startup, for daily use. | +| `McpServerPort` | `5178` | Change it if something else holds the port. The client URL has to match. | + +Both are read once at startup, so a change needs a restart. The button does not. + +## Connecting Claude Code + +```bash +claude mcp add --scope user --transport http csca http://127.0.0.1:5178/mcp +``` + +| Part | Meaning | +| --- | --- | +| `--scope user` | Register for every project on this machine. See the table below. | +| `--transport http` | The server already runs; the client only connects. The default, `stdio`, would try to *launch* a process. | +| `csca` | A name you choose. It prefixes the tool names the assistant sees: `mcp__csca__graph_info`. | +| the URL | Loopback address, the port from the setting, and the fixed path `/mcp`. | + +### Choosing a scope + +`claude mcp add` writes the entry into a configuration file. Which one depends on the scope, and the +default is rarely the one you want here: + +| Scope | Stored in | Visible | +| --- | --- | --- | +| `local` (**default**) | `~/.claude.json`, keyed by the current working directory | Only in that one directory | +| `user` | `~/.claude.json`, global | Every project on this machine | +| `project` | `.mcp.json` in the project root | Everyone who checks out the repository | + +Use **`user`** for your own machine: the server belongs to the running application, not to one +repository, so tying it to a directory only means it disappears when you work somewhere else. + +Use **`project`** to give a whole team access to their own local instance. The checked-in `.mcp.json` +points at `127.0.0.1`, so every developer connects to the copy running on their own machine — the +file is shared, the server is not. + +### Verifying + +```bash +claude mcp list +``` + +A ✗ means "registered, but not answering". With this server that is the normal state whenever the +application is closed — it is not a defect. If you start the application in the middle of a session, +pick the server up with `/mcp` → **reconnect** instead of restarting the session. + +To test the endpoint without involving any client: + +```bash +curl -sS -X POST http://127.0.0.1:5178/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +In PowerShell use `Invoke-RestMethod` instead — PowerShell mangles the quotes inside a JSON body when +passing it to a native executable, and the server rejects the result as malformed: + +```powershell +Invoke-RestMethod -Uri http://127.0.0.1:5178/mcp -Method Post -ContentType 'application/json' -Headers @{ Accept = 'application/json, text/event-stream' } -Body '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +## Using it + +### There are no magic words + +You do not invoke a tool. You ask a question, and the assistant decides whether one of the tools can +answer it. + +That decision is made from the tool **descriptions** and nothing else. When a session starts, the +client asks the server which tools it has and puts their names, descriptions and parameters into the +model's context. From then on the model matches your question against those descriptions — there is no +keyword list, no trigger phrase, and no configuration that maps a question to a tool. + +The practical consequence: phrase the question the way the descriptions are written. They talk about +*who calls what*, *what depends on what*, *what breaks if this changes*, and *how two elements are +connected*. A question in those terms lands; "tell me about the architecture" is too vague to point +at any particular tool. + +### How you see that it happened + +The tool call appears in the transcript, named `mcp____` — with the default registration +that is `mcp__csca__search_elements`, `mcp__csca__find_incoming_calls`, and so on. You see the +arguments it passed and the answer it got back. + +If no such line appears, the assistant answered from your source files instead. That is not +necessarily wrong, but it is a different answer: reading files finds text, the graph knows +relationships. + +**One question usually produces several calls.** Element ids cannot be guessed, so almost every +answer starts with `search_elements` to turn a name into an id, and only then asks the real question. +A chain of three calls for one question is normal. + +### Example questions + +| Ask this | The assistant reaches for | +| --- | --- | +| "What is loaded in Code Analyst right now?" | `graph_info` | +| "Which classes depend on `CodeGraphExplorer`?" | `search_elements` → `find_incoming_relationships` | +| "Who can end up calling `MainViewModel.LoadCodeGraph`?" | `search_elements` → `find_incoming_calls` | +| "If I delete the `Importers` feature, what breaks?" | `search_elements` → `find_incoming_relationships` with `deep` | +| "How does the web graph view end up using the Roslyn parser?" | `search_elements` twice → `find_paths_between` | +| "Does anything outside the UI use `RefactoringService`?" | `search_elements` → `find_incoming_relationships` | +| "What does `GraphViewModel` depend on outside its own assembly?" | `search_elements` → `find_outgoing_relationships` with `deep` | +| "Is `FindPathsBetween` still used anywhere?" | `search_elements` → `find_incoming_calls` | +| "Find every class whose name contains 'Importer' that is not external" | `search_elements` with `importer type:class -source:extern` | + +Questions that mix both worlds work well, because the assistant can use the graph *and* read your +files: *"Who calls `Parser.ParseAsync`, and does any of those callers handle the cancellation +correctly?"* — the first half is the graph, the second half is reading the code it found. + +### When it does not trigger + +Name the server, and it will: *"Use the csca MCP server to find out who calls this."* Or name the tool +outright: *"Call `graph_info`."* + +If it still does not work, walk down this list: + +1. Is the server running? The ribbon button says **Stop MCP server** when it is. +2. Is a project loaded in the application? Every tool answers "No project is loaded" otherwise — + and that answer *does* appear in the transcript, so it is easy to spot. +3. Was the server started after the session began? Pick it up with `/mcp` → **reconnect**. + +### What it cannot tell you + +The graph is what the parser found. Reflection, dependency injection, and anything else resolved at +runtime leave no edge, so "nothing calls this" is a statement about the static graph, not about your +program. `find_incoming_calls` says so in its own answer for exactly that reason. + +## Tools + +| Tool | Answers | +| --- | --- | +| `graph_info` | What is loaded, when it was captured, how large it is, which assemblies it contains. | +| `search_elements` | Find code elements by name. The entry point — see the syntax below. | +| `describe_element` | Kind, full path, accessibility, source locations, contents, relationship counts. | +| `find_outgoing_relationships` | What this element depends on. `deep` includes its members. | +| `find_incoming_relationships` | What depends on this element — the blast radius of a change. | +| `find_incoming_calls` | Who calls a method, transitively. | +| `find_paths_between` | How two elements are connected. | + +Three semantics in that list are easy to misread, so each tool states them in its own description as +well: + +**`deep` means "crossing the boundary", not "everything inside".** Asking a class with `deep=true` +reports what its members depend on *outside* the class. One method calling another stays inside and is +not listed — ask about the member itself for that. + +**`find_incoming_calls` follows abstractions by default.** A call to `IOrderService.Place` counts as +reaching `OrderService.Place`, which is what you want for "who can end up here". It is a heuristic: a +static graph cannot know which implementation runs. Turn it off for certainty, and accept that callers +arriving through virtual dispatch or events then go missing — an empty result is *not* proof that a +method is unused. + +**`find_paths_between` reports only the shortest chains.** If a direct dependency exists, you will not +see the longer route that also connects the two. Containment is never a path, or every pair of +elements would be connected through a shared parent. + +**Element ids are opaque and only valid while the server runs.** They are regenerated on every parse, +so an assistant cannot remember one across sessions. Every workflow therefore starts with +`search_elements` to obtain an id, and `graph_info` says so explicitly. + +### Search syntax + +The same expression language as the **Search** tab in the application, so what you type there and what +an assistant sends produce the same result. + +| Pattern | Matches | +| --- | --- | +| `order` | Anywhere in the full name, case-insensitively. | +| `OS`, `OrdServ`, `OrderService` | Camel-hump matching. Any term containing an uppercase letter is split at each uppercase letter; the parts must occur in that order, each starting a word, matched **case-sensitively**. | +| `OSvc` | *Nothing* — `Svc` does not occur in `OrderService`. The parts are literal, not abbreviations. | +| `order service` | AND — both terms must match. | +| `order \| invoice` | OR. | +| `-source:extern` | Excludes. | +| `type:class` | Restricts the kind: `interface`, `struct`, `record`, `method`, `property`, `field`, `event`, `enum`, `delegate`, `namespace`, `assembly`. | +| `source:intern`, `source:extern`, `source:generated` | Restricts the origin. | + +The most common surprise is the case rule: `order` is case-insensitive, `Order` is not, because the +uppercase letter switches modes. + +Results are ordered by how likely they are the element you meant — an exact name match first, then a +prefix match, then the rest — with internal code before external. Long results are truncated with an +explicit count of what was left out; they are never silently cut. + +## What the assistant actually sees + +Three properties of the answer that are invisible in the data itself, and that `graph_info` reports +for exactly that reason: + +**A snapshot, not your files.** The graph is a copy taken when the application last loaded or changed +it. Edit code in your editor and the graph does not follow. `graph_info` reports the capture time so +the assistant can weigh how much to trust it. + +**Possibly a hypothetical code base.** The refactoring simulation changes the loaded graph — moving, +deleting, cutting relationships — without touching a single source file. Once you have done that, the +graph describes code that never existed. `graph_info` says so, and any answer derived from it should +repeat the warning. + +This is also the most interesting thing you can do with the feature: delete a module in the +simulation, then ask the assistant what breaks. + +**Only what the parser found.** External assemblies are leaf nodes; their internals are not analyzed. +Reflection, dependency injection and anything else resolved at runtime is invisible to a static +parse. + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `claude mcp list` shows ✗ | Application not running, server not started, or a different port. | +| `claude mcp list` shows nothing at all | The entry was added with the default `local` scope from a different directory. Re-add with `--scope user`. | +| `Bad Request: The POST body did not contain a valid JSON-RPC message` | PowerShell ate the quotes in the JSON body. Use `Invoke-RestMethod`, or `curl.exe --%`. | +| Application does not start at all | Missing ASP.NET Core runtime — it is required from the version that introduced this feature onwards, whether or not you use the server. Or `appsettings.json` is not in the working directory: it is read from there, not from next to the executable. | +| `McpServerPort` change has no effect | The settings are read once at startup. Restart the application; the button alone does not re-read them. | +| Every tool answers "No project is loaded" | The application is running but empty. Open a solution or a saved project. | +| Server does not start, port message | Another process holds the port. Change `McpServerPort` and the client URL together. | + +## Security + +The endpoint binds to **loopback only** (`127.0.0.1`). It is reachable from this machine and nowhere +else. That is deliberate: the graph contains the full structure of your source — assembly, namespace +and member names, file paths, call relationships. On a network interface that would be published. + +There is no authentication. Anything running on the machine can query the endpoint while the server +is up. All tools are read-only: nothing an assistant does through MCP can change the graph, your +project file, or your source. + +## How it works + +The server runs inside the WPF application as a Kestrel endpoint (`CSharpCodeAnalyst.Mcp`, a +UI-free assembly, so the tools can be unit tested against a hand-built graph). + +Tools never touch the live graph. The application mutates it in place during a refactoring +simulation, on the UI thread, while tool calls arrive on request threads — a query walking it at the +same time would see a half-changed structure. Instead, `CodeGraphSnapshotProvider` hands out a +**copy**. Loading a project or applying a refactoring only marks the current copy stale; the next tool +call pays for a fresh one, and only if something changed. The copy itself is taken on the UI thread, +the one moment nothing can mutate. Everything after that runs on the request thread, without a lock +anywhere. + +## Status + +Feature complete: the ribbon toggle, the host, the snapshot mechanism and all seven tools, with unit +tests in `Tests/UnitTests/Mcp/`. + +Known limits, none of them bugs: + +- The server exists only while the application runs, so there is no headless or CI use. +- The graph is a snapshot of what the application has loaded, not of your source files. +- Nothing authenticates. Loopback binding and read-only tools are what keeps that acceptable. diff --git a/README.md b/README.md index c127dfe5..1b912a98 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,10 @@ https://github.com/punker76/gong-wpf-dragdrop - Markdown rendering in the AI Advisor window is powered by **Markdig.Wpf** and **Markdig**. Copyright (c) Nicolas Musset and Alexandre Mutel. Licensed under BSD-2-Clause. https://github.com/Kryptos-FR/markdig.wpf / https://github.com/xoofx/markdig +- The optional MCP server, which lets an AI assistant query the loaded code graph, is built on the +**Model Context Protocol C# SDK** (`ModelContextProtocol`, `ModelContextProtocol.Core`, +`ModelContextProtocol.AspNetCore`), licensed under Apache-2.0. +https://github.com/modelcontextprotocol/csharp-sdk - The dependency structure matrix on the DSM tab is the viewer from **DsmSuite**, licensed under GPL-3.0-or-later (same as this project) and originally MIT-licensed by jmuijsenberg. A modified subset of it is vendored under [ThirdParty/DsmSuite](ThirdParty/DsmSuite/). diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index f955f68d..78029bca 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/Tests/UnitTests/Mcp/ElementToolsTests.cs b/Tests/UnitTests/Mcp/ElementToolsTests.cs new file mode 100644 index 00000000..8f327d96 --- /dev/null +++ b/Tests/UnitTests/Mcp/ElementToolsTests.cs @@ -0,0 +1,234 @@ +using CodeParserTests.Helper; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Tools; + +namespace CodeParserTests.UnitTests.Mcp; + +/// +/// Tests for : finding an element and describing it. +/// +/// Several of these pin behaviour that the tool's own description promises. That description is +/// the only thing a language model ever learns about the tool - it cannot read the code and it +/// cannot ask - so a claim in it that the code does not honour is a defect, and these tests are +/// what catches it. +/// +/// +[TestFixture] +public class ElementToolsTests +{ + [SetUp] + public void SetUp() + { + _graph = new TestCodeGraph(); + _assembly = _graph.CreateAssembly("Sample.Core"); + _namespace = _graph.CreateNamespace("Services", _assembly); + _orderService = _graph.CreateClass("OrderService", _namespace, accessLevel: AccessLevel.Public); + _place = _graph.CreateMethod("Place", _orderService); + _validate = _graph.CreateMethod("Validate", _orderService); + _tools = new ElementTools(FakeSnapshotSource.With(_graph)); + } + + private TestCodeGraph _graph = null!; + private CodeElement _assembly = null!; + private CodeElement _namespace = null!; + private CodeElement _orderService = null!; + private CodeElement _place = null!; + private CodeElement _validate = null!; + private ElementTools _tools = null!; + + [Test] + public async Task Search_LowerCaseTerm_MatchesCaseInsensitively() + { + var answer = await _tools.SearchElementsAsync("orderservice"); + + Assert.That(answer, Does.Contain("OrderService")); + } + + /// + /// Camel-hump matching, as promised by the tool description: the term is split at every + /// uppercase letter and the parts must occur in order. + /// + [Test] + public async Task Search_CamelHumpTerm_FindsTheType() + { + var answer = await _tools.SearchElementsAsync("OS"); + + Assert.That(answer, Does.Contain("OrderService")); + } + + /// + /// The counterpart to the test above, and the reason it exists. The parts of a camel-hump term + /// are literal, not abbreviations - "Svc" simply does not occur in "OrderService". An earlier + /// version of the tool description claimed otherwise, which would have sent a caller looking + /// for elements it could never find. + /// + [Test] + public async Task Search_CamelHumpWithAnAbbreviationThatDoesNotOccur_FindsNothing() + { + var answer = await _tools.SearchElementsAsync("OSvc"); + + Assert.That(answer, Does.Contain("Nothing matches")); + } + + [Test] + public async Task Search_TypeFilter_RestrictsTheKind() + { + var answer = await _tools.SearchElementsAsync("type:method"); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("Place")); + Assert.That(answer, Does.Contain("Validate")); + Assert.That(answer, Does.Not.Contain("[Class]")); + }); + } + + [Test] + public async Task Search_NegatedTerm_Excludes() + { + var answer = await _tools.SearchElementsAsync("type:method -Place"); + + // The header echoes the query, so "Place" appears in the answer either way - only the result + // list can tell whether the exclusion worked. + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("1 match(es)")); + Assert.That(answer, Does.Contain($"id={_validate.Id}")); + Assert.That(answer, Does.Not.Contain($"id={_place.Id}")); + }); + } + + /// + /// The expression only says yes or no. Without a ranking the type a caller asked for would sit + /// below its own members and anything else whose full name happens to contain the word. + /// + [Test] + public async Task Search_ExactNameMatch_IsListedFirst() + { + var extra = _graph.CreateClass("OrderServiceFactory", _namespace); + + var answer = await _tools.SearchElementsAsync("OrderService"); + + var exact = answer.IndexOf(_orderService.Id, StringComparison.Ordinal); + var other = answer.IndexOf(extra.Id, StringComparison.Ordinal); + Assert.That(exact, Is.GreaterThanOrEqualTo(0)); + Assert.That(other, Is.GreaterThan(exact)); + } + + /// + /// A truncated list that does not say it is truncated is worse than a short one: the caller + /// concludes there are only as many results as it can see. + /// + [Test] + public async Task Search_MoreResultsThanTheLimit_SaysHowManyAreMissing() + { + var answer = await _tools.SearchElementsAsync("type:method", 1); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("2 match(es)")); + Assert.That(answer, Does.Contain("1 more not shown")); + }); + } + + [Test] + public async Task Search_EmptyQuery_AsksForOne() + { + var answer = await _tools.SearchElementsAsync(" "); + + Assert.That(answer, Does.Contain("query is empty")); + } + + [Test] + public async Task Search_WithoutAProject_SaysSo() + { + var tools = new ElementTools(FakeSnapshotSource.Empty()); + + var answer = await tools.SearchElementsAsync("anything"); + + Assert.That(answer, Does.Contain("No project is loaded")); + } + + /// + /// A stale id and a deleted element look identical to the caller, and the recovery differs, so + /// the answer has to name both possibilities. + /// + [Test] + public async Task Describe_UnknownId_ExplainsThatIdsDoNotSurviveAReparse() + { + var answer = await _tools.DescribeElementAsync("no-such-id"); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("no-such-id")); + Assert.That(answer, Does.Contain("re-parsed")); + Assert.That(answer, Does.Contain("search_elements")); + }); + } + + [Test] + public async Task Describe_ListsContainersAndContents() + { + var answer = await _tools.DescribeElementAsync(_orderService.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("[Class] OrderService")); + Assert.That(answer, Does.Contain("Contained in:")); + Assert.That(answer, Does.Contain("Sample.Core")); + Assert.That(answer, Does.Contain("Contains (2)")); + Assert.That(answer, Does.Contain(_place.Id)); + Assert.That(answer, Does.Contain(_validate.Id)); + }); + } + + [Test] + public async Task Describe_ReportsAKnownAccessLevel() + { + var answer = await _tools.DescribeElementAsync(_orderService.Id); + + Assert.That(answer, Does.Contain("Access: Public")); + } + + /// + /// Unknown means "nobody told us", not a value. Printing it would invite exactly the conclusion + /// the domain model warns against - reading it as public, or as private. + /// + [Test] + public async Task Describe_OmitsAnUnknownAccessLevel() + { + var answer = await _tools.DescribeElementAsync(_place.Id); + + Assert.That(answer, Does.Not.Contain("Access:")); + } + + /// + /// An external element has no analyzed dependencies, so "no outgoing relationships" would read + /// as a finding when it is really an absence of data. + /// + [Test] + public async Task Describe_ExternalElement_SaysItsDependenciesWereNeverAnalyzed() + { + var external = _graph.CreateExternalClass("JsonSerializer", _namespace); + + var answer = await _tools.DescribeElementAsync(external.Id); + + Assert.That(answer, Does.Contain("External")); + Assert.That(answer, Does.Contain("not analyzed")); + } + + [Test] + public async Task Describe_CountsRelationshipsInBothDirections() + { + _place.Relationships.Add(new Relationship(_place.Id, _validate.Id, RelationshipType.Calls)); + + var outgoing = await _tools.DescribeElementAsync(_place.Id); + var incoming = await _tools.DescribeElementAsync(_validate.Id); + + Assert.Multiple(() => + { + Assert.That(outgoing, Does.Contain("Outgoing relationships (1): 1 Calls")); + Assert.That(incoming, Does.Contain("Incoming relationships: 1")); + }); + } +} diff --git a/Tests/UnitTests/Mcp/FakeSnapshotSource.cs b/Tests/UnitTests/Mcp/FakeSnapshotSource.cs new file mode 100644 index 00000000..11ea3838 --- /dev/null +++ b/Tests/UnitTests/Mcp/FakeSnapshotSource.cs @@ -0,0 +1,28 @@ +using CSharpCodeAnalyst.Mcp.Contracts; + +namespace CodeParserTests.UnitTests.Mcp; + +/// +/// Stands in for the application. The tools only ever see a snapshot, so a fake that hands one out +/// is enough to test them - no WPF, no server, no dispatcher. +/// +internal sealed class FakeSnapshotSource(GraphSnapshot? snapshot) : ICodeGraphSnapshotSource +{ + /// The state of a freshly started application: running, but nothing opened yet. + public static FakeSnapshotSource Empty() + { + return new FakeSnapshotSource(null); + } + + public static FakeSnapshotSource With(CSharpCodeAnalyst.CodeGraph.Graph.CodeGraph graph, + string sourceName = "test.json", bool containsRefactorings = false) + { + return new FakeSnapshotSource( + new GraphSnapshot(graph, sourceName, DateTimeOffset.UtcNow, containsRefactorings)); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(snapshot); + } +} diff --git a/Tests/UnitTests/Mcp/GraphInfoToolsTests.cs b/Tests/UnitTests/Mcp/GraphInfoToolsTests.cs new file mode 100644 index 00000000..88d41334 --- /dev/null +++ b/Tests/UnitTests/Mcp/GraphInfoToolsTests.cs @@ -0,0 +1,111 @@ +using CodeParserTests.Helper; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Tools; + +namespace CodeParserTests.UnitTests.Mcp; + +/// +/// Tests for , the tool that tells a caller what it is looking at. +/// +/// The assertions are on substrings, not on the exact layout: the wording is meant to stay +/// editable, while the facts it has to carry are not. +/// +/// +[TestFixture] +public class GraphInfoToolsTests +{ + [Test] + public async Task GraphInfo_WithoutAProject_SaysSoInsteadOfFailing() + { + var tools = new GraphInfoTools(FakeSnapshotSource.Empty()); + + var answer = await tools.GraphInfoAsync(); + + // Not an exception: a protocol error would tell the caller nothing about what to do next. + Assert.That(answer, Does.Contain("No project is loaded")); + Assert.That(answer, Does.Contain("open a solution")); + } + + [Test] + public async Task GraphInfo_ReportsCountsAndAssemblies() + { + var graph = new TestCodeGraph(); + var assembly = graph.CreateAssembly("Sample.Core"); + var ns = graph.CreateNamespace("Services", assembly); + var type = graph.CreateClass("OrderService", ns); + var method = graph.CreateMethod("Place", type); + method.Relationships.Add(new Relationship(method.Id, type.Id, RelationshipType.Uses)); + + var tools = new GraphInfoTools(FakeSnapshotSource.With(graph, "sample.json")); + var answer = await tools.GraphInfoAsync(); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("sample.json")); + Assert.That(answer, Does.Contain("Code elements: 4")); + Assert.That(answer, Does.Contain("Relationships: 1")); + Assert.That(answer, Does.Contain("Sample.Core")); + }); + } + + [Test] + public async Task GraphInfo_MarksExternalAssemblies() + { + var graph = new TestCodeGraph(); + graph.CreateAssembly("Sample.Core"); + var external = graph.CreateAssembly("System.Text.Json"); + graph.Nodes[external.Id] = new CodeElement(external.Id, CodeElementType.Assembly, + external.Name, external.FullName, null) { IsExternal = true }; + + var tools = new GraphInfoTools(FakeSnapshotSource.With(graph)); + var answer = await tools.GraphInfoAsync(); + + Assert.That(answer, Does.Contain("System.Text.Json").And.Contain("[external]")); + } + + /// + /// The most important thing this tool does. A graph changed by the refactoring simulation + /// describes code that does not exist, and nothing in the data itself gives that away - so + /// every answer derived from it would silently be about a fiction. + /// + [Test] + public async Task GraphInfo_WarnsWhenTheGraphContainsSimulatedRefactorings() + { + var graph = new TestCodeGraph(); + graph.CreateAssembly("Sample.Core"); + + var tools = new GraphInfoTools(FakeSnapshotSource.With(graph, containsRefactorings: true)); + var answer = await tools.GraphInfoAsync(); + + Assert.That(answer, Does.Contain("WARNING")); + Assert.That(answer, Does.Contain("not the code on disk")); + } + + [Test] + public async Task GraphInfo_WithoutRefactorings_DoesNotWarn() + { + var graph = new TestCodeGraph(); + graph.CreateAssembly("Sample.Core"); + + var tools = new GraphInfoTools(FakeSnapshotSource.With(graph)); + var answer = await tools.GraphInfoAsync(); + + Assert.That(answer, Does.Not.Contain("WARNING")); + } + + /// + /// Ids are regenerated on every parse. A caller that does not know this will try to reuse one + /// from an earlier session, so the entry point is named where it is first needed. + /// + [Test] + public async Task GraphInfo_PointsAtSearchAsTheEntryPoint() + { + var graph = new TestCodeGraph(); + graph.CreateAssembly("Sample.Core"); + + var tools = new GraphInfoTools(FakeSnapshotSource.With(graph)); + var answer = await tools.GraphInfoAsync(); + + Assert.That(answer, Does.Contain("search_elements")); + } +} diff --git a/Tests/UnitTests/Mcp/RelationshipToolsTests.cs b/Tests/UnitTests/Mcp/RelationshipToolsTests.cs new file mode 100644 index 00000000..c72ee22a --- /dev/null +++ b/Tests/UnitTests/Mcp/RelationshipToolsTests.cs @@ -0,0 +1,238 @@ +using CodeParserTests.Helper; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.Mcp.Tools; + +namespace CodeParserTests.UnitTests.Mcp; + +/// +/// Tests for . +/// +/// The fixture is the smallest arrangement that still has every trap in it: a class whose +/// members call each other (internal), a member calling out of the class (crossing), and a +/// caller that only reaches an implementation through an interface. +/// +/// +/// OrderController.Post --Calls--> IOrderService.Place +/// OrderService.Place --Implements--> IOrderService.Place +/// OrderService.Place --Calls--> OrderService.Validate (internal to OrderService) +/// OrderService.Place --Calls--> OrderRepository.Save (leaves OrderService) +/// +/// +[TestFixture] +public class RelationshipToolsTests +{ + [SetUp] + public void SetUp() + { + _graph = new TestCodeGraph(); + var assembly = _graph.CreateAssembly("Sample.Core"); + var ns = _graph.CreateNamespace("Services", assembly); + + _contract = _graph.CreateInterface("IOrderService", ns); + _contractPlace = _graph.CreateMethod("IOrderService.Place", _contract); + + _orderService = _graph.CreateClass("OrderService", ns); + _place = _graph.CreateMethod("OrderService.Place", _orderService); + _validate = _graph.CreateMethod("OrderService.Validate", _orderService); + + _repository = _graph.CreateClass("OrderRepository", ns); + _save = _graph.CreateMethod("OrderRepository.Save", _repository); + + _controller = _graph.CreateClass("OrderController", ns); + _post = _graph.CreateMethod("OrderController.Post", _controller); + + Link(_place, _contractPlace, RelationshipType.Implements); + Link(_place, _validate, RelationshipType.Calls); + Link(_place, _save, RelationshipType.Calls); + Link(_post, _contractPlace, RelationshipType.Calls); + + _tools = new RelationshipTools(FakeSnapshotSource.With(_graph)); + } + + private TestCodeGraph _graph = null!; + private CodeElement _contract = null!; + private CodeElement _contractPlace = null!; + private CodeElement _orderService = null!; + private CodeElement _place = null!; + private CodeElement _validate = null!; + private CodeElement _repository = null!; + private CodeElement _save = null!; + private CodeElement _controller = null!; + private CodeElement _post = null!; + private RelationshipTools _tools = null!; + + private static void Link(CodeElement source, CodeElement target, RelationshipType type) + { + source.Relationships.Add(new Relationship(source.Id, target.Id, type)); + } + + [Test] + public async Task Outgoing_ListsWhatTheElementItselfDependsOn() + { + var answer = await _tools.FindOutgoingRelationshipsAsync(_place.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("3 relationship(s)")); + Assert.That(answer, Does.Contain(_validate.Id)); + Assert.That(answer, Does.Contain(_save.Id)); + Assert.That(answer, Does.Contain(_contractPlace.Id)); + }); + } + + /// + /// The semantics that is easiest to get wrong. "deep" means relationships crossing the + /// element's boundary, not everything inside it: Place calling Validate stays within + /// OrderService and is deliberately absent. Without the note in the answer a caller would + /// conclude that call does not exist. + /// + [Test] + public async Task OutgoingDeep_ReportsWhatLeavesTheClassButNotWhatStaysInside() + { + var answer = await _tools.FindOutgoingRelationshipsAsync(_orderService.Id, true); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain(_save.Id), "the call leaving the class"); + Assert.That(answer, Does.Contain(_contractPlace.Id), "the interface it implements"); + Assert.That(answer, Does.Not.Contain(_validate.Id), "internal call must not be listed"); + Assert.That(answer, Does.Contain("internal and not shown"), + "the absence has to be explained, or it reads as a finding"); + }); + } + + [Test] + public async Task Outgoing_WithoutDeep_DoesNotDescendIntoMembers() + { + var answer = await _tools.FindOutgoingRelationshipsAsync(_orderService.Id); + + Assert.That(answer, Does.Contain("No relationships found")); + } + + [Test] + public async Task Incoming_ListsWhatDependsOnTheElement() + { + var answer = await _tools.FindIncomingRelationshipsAsync(_contractPlace.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain(_post.Id), "the caller"); + Assert.That(answer, Does.Contain(_place.Id), "the implementation"); + }); + } + + /// + /// Nothing calls the concrete method directly - every caller arrives through the interface. The + /// answer is formally correct and practically misleading, so it has to say what it does not + /// prove. + /// + [Test] + public async Task IncomingCalls_WithoutAbstractions_MissesCallersGoingThroughAnInterface() + { + var answer = await _tools.FindIncomingCallsAsync(_place.Id, false); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("No callers found")); + Assert.That(answer, Does.Contain("does not prove the method is unused")); + }); + } + + [Test] + public async Task IncomingCalls_WithAbstractions_FindsTheCallerThroughTheInterface() + { + var answer = await _tools.FindIncomingCallsAsync(_place.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain(_post.Id)); + Assert.That(answer, Does.Contain("Heuristic")); + }); + } + + [Test] + public async Task IncomingCalls_DoesNotReportTheMethodAsItsOwnCaller() + { + Link(_validate, _place, RelationshipType.Calls); + + var answer = await _tools.FindIncomingCallsAsync(_place.Id, false); + + Assert.That(answer, Does.Contain(_validate.Id)); + Assert.That(answer, Does.Contain("1 caller(s)")); + } + + /// + /// Both ends are expanded to their contents first, so asking about two classes has to surface + /// the chain between their methods - that is the whole point of the tool. + /// + [Test] + public async Task PathsBetween_TwoClasses_FindsTheChainBetweenTheirMembers() + { + var answer = await _tools.FindPathsBetweenAsync(_orderService.Id, _repository.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("OrderService.Place")); + Assert.That(answer, Does.Contain("--Calls-->")); + Assert.That(answer, Does.Contain("OrderRepository.Save")); + }); + } + + [Test] + public async Task PathsBetween_UnconnectedElements_SaysWhatThatDoesAndDoesNotMean() + { + var lonely = _graph.CreateClass("Unrelated"); + + var answer = await _tools.FindPathsBetweenAsync(_orderService.Id, lonely.Id); + + Assert.Multiple(() => + { + Assert.That(answer, Does.Contain("No dependency chain")); + Assert.That(answer, Does.Contain("raise maxLength")); + Assert.That(answer, Does.Contain("shared parent")); + }); + } + + /// + /// Containment is not a dependency. If it were, every two elements under the same assembly + /// would be "connected" and the tool would answer nothing useful ever again. + /// + [Test] + public async Task PathsBetween_SiblingsWithNoDependency_AreNotConnectedThroughTheirParent() + { + var answer = await _tools.FindPathsBetweenAsync(_controller.Id, _repository.Id); + + Assert.That(answer, Does.Contain("No dependency chain")); + } + + [Test] + public async Task AllTools_WithAnUnknownId_ExplainInsteadOfFailing() + { + var outgoing = await _tools.FindOutgoingRelationshipsAsync("nope"); + var incoming = await _tools.FindIncomingRelationshipsAsync("nope"); + var calls = await _tools.FindIncomingCallsAsync("nope"); + var paths = await _tools.FindPathsBetweenAsync("nope", _place.Id); + + Assert.Multiple(() => + { + Assert.That(outgoing, Does.Contain("search_elements")); + Assert.That(incoming, Does.Contain("search_elements")); + Assert.That(calls, Does.Contain("search_elements")); + Assert.That(paths, Does.Contain("search_elements")); + }); + } + + [Test] + public async Task AllTools_WithoutAProject_SaySo() + { + var tools = new RelationshipTools(FakeSnapshotSource.Empty()); + + Assert.Multiple(async () => + { + Assert.That(await tools.FindOutgoingRelationshipsAsync("x"), Does.Contain("No project")); + Assert.That(await tools.FindIncomingRelationshipsAsync("x"), Does.Contain("No project")); + Assert.That(await tools.FindIncomingCallsAsync("x"), Does.Contain("No project")); + Assert.That(await tools.FindPathsBetweenAsync("x", "y"), Does.Contain("No project")); + }); + } +} diff --git a/Tests/UnitTests/Search/PascalCaseSearchTests.cs b/Tests/UnitTests/Search/PascalCaseSearchTests.cs index 05859fbf..406dd0fa 100644 --- a/Tests/UnitTests/Search/PascalCaseSearchTests.cs +++ b/Tests/UnitTests/Search/PascalCaseSearchTests.cs @@ -1,4 +1,4 @@ -using CSharpCodeAnalyst.AnalyzerSdk.Search; +using CSharpCodeAnalyst.CodeGraph.Search; namespace CodeParserTests.UnitTests.Search; diff --git a/Tests/UnitTests/Search/SearchExpressionTests.cs b/Tests/UnitTests/Search/SearchExpressionTests.cs index 7104888c..aba62571 100644 --- a/Tests/UnitTests/Search/SearchExpressionTests.cs +++ b/Tests/UnitTests/Search/SearchExpressionTests.cs @@ -1,5 +1,5 @@ -using CSharpCodeAnalyst.AnalyzerSdk.Search; -using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeGraph.Search; namespace CodeParserTests.UnitTests.Search; diff --git a/ThirdPartyNotices/APACHE-2.0-LICENSED-LIBRARIES.txt b/ThirdPartyNotices/APACHE-2.0-LICENSED-LIBRARIES.txt new file mode 100644 index 00000000..29c01055 --- /dev/null +++ b/ThirdPartyNotices/APACHE-2.0-LICENSED-LIBRARIES.txt @@ -0,0 +1,233 @@ +Apache-2.0-Licensed Libraries +================================================================================ + +This file lists the Apache-2.0-licensed third-party libraries used in this +project, followed by the full license text. + + +1. MODEL CONTEXT PROTOCOL C# SDK +================================================================================ + +https://csharp.sdk.modelcontextprotocol.io/ +https://github.com/modelcontextprotocol/csharp-sdk + +The official C# SDK for the Model Context Protocol. It powers the optional MCP +server the application can open so an AI assistant can query the loaded code +graph (see Documentation/mcp.md). + +The following packages are used: +- ModelContextProtocol +- ModelContextProtocol.Core +- ModelContextProtocol.AspNetCore + +Copyright (c) Model Context Protocol contributors + +The packages are used unmodified. + + +================================================================================ +APACHE LICENSE 2.0 TEXT +================================================================================ + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ThirdPartyNotices/MIT-LICENSED-LIBRARIES.txt b/ThirdPartyNotices/MIT-LICENSED-LIBRARIES.txt index bf179c53..56f70de1 100644 --- a/ThirdPartyNotices/MIT-LICENSED-LIBRARIES.txt +++ b/ThirdPartyNotices/MIT-LICENSED-LIBRARIES.txt @@ -17,6 +17,7 @@ https://github.com/microsoft/XamlBehaviorsWpf The following Microsoft packages are used in this project: Production Dependencies: +- Microsoft.Extensions.AI.Abstractions - Microsoft.Extensions.Configuration - Microsoft.Extensions.Configuration.Binder - Microsoft.Extensions.Configuration.Json @@ -70,6 +71,7 @@ locally under CSharpCodeAnalyst/Features/WebGraph/Web/lib: (The layered layout engine elkjs is EPL-2.0; see EPL-2.0-LICENSED-LIBRARIES.txt.) (The SVG export extension cytoscape-svg is GPL-3.0; see GPL-3.0-LICENSED-LIBRARIES.txt.) +(The Model Context Protocol C# SDK is Apache-2.0; see APACHE-2.0-LICENSED-LIBRARIES.txt.) ================================================================================