From 539e70c79db631fb73ea0216500d8925c62b848c Mon Sep 17 00:00:00 2001
From: ATrefzer <36333177+ATrefzer@users.noreply.github.com>
Date: Mon, 10 Aug 2026 19:05:27 +0200
Subject: [PATCH 1/2] MCP server
---
.../Presentation/DeadCodeViewModel.cs | 2 +-
.../Presentation/MethodComplexityViewModel.cs | 2 +-
.../Presentation/TypeCohesionViewModel.cs | 2 +-
.../Presentation/TypeDependenciesViewModel.cs | 2 +-
.../Search/PascalCaseSearch.cs | 2 +-
.../Search/SearchExpression.cs | 2 +-
.../Search/SearchExpressionFactory.cs | 2 +-
.../CSharpCodeAnalyst.Mcp.csproj | 28 ++
.../Contracts/GraphSnapshot.cs | 30 ++
.../Contracts/ICodeGraphSnapshotSource.cs | 22 +
CSharpCodeAnalyst.Mcp/McpServerHost.cs | 138 +++++++
.../Tools/ElementFormatter.cs | 104 +++++
CSharpCodeAnalyst.Mcp/Tools/ElementTools.cs | 265 ++++++++++++
CSharpCodeAnalyst.Mcp/Tools/GraphInfoTools.cs | 92 +++++
.../Tools/RelationshipFormatter.cs | 84 ++++
.../Tools/RelationshipTools.cs | 376 ++++++++++++++++++
CSharpCodeAnalyst.Mcp/Tools/ToolText.cs | 33 ++
CSharpCodeAnalyst.sln | 14 +
CSharpCodeAnalyst/App.xaml.cs | 29 +-
CSharpCodeAnalyst/CSharpCodeAnalyst.csproj | 3 +
.../Configuration/AppSettings.cs | 18 +-
.../AdvancedSearch/AdvancedSearchViewModel.cs | 2 +-
.../Features/Graph/GraphSearchViewModel.cs | 2 +-
.../Features/Mcp/CodeGraphSnapshotProvider.cs | 146 +++++++
.../Features/Mcp/McpServerService.cs | 146 +++++++
.../Features/Tree/TreeViewModel.cs | 2 +-
CSharpCodeAnalyst/MainViewModel.cs | 68 +++-
CSharpCodeAnalyst/MainWindow.xaml | 20 +-
.../Resources/Strings.Designer.cs | 92 ++++-
CSharpCodeAnalyst/Resources/Strings.resx | 36 ++
CSharpCodeAnalyst/Resources/server_32.png | Bin 0 -> 3545 bytes
CSharpCodeAnalyst/appsettings.json | 6 +-
Documentation/mcp.md | 291 ++++++++++++++
README.md | 4 +
Tests/Tests.csproj | 1 +
Tests/UnitTests/Mcp/ElementToolsTests.cs | 234 +++++++++++
Tests/UnitTests/Mcp/FakeSnapshotSource.cs | 28 ++
Tests/UnitTests/Mcp/GraphInfoToolsTests.cs | 111 ++++++
Tests/UnitTests/Mcp/RelationshipToolsTests.cs | 238 +++++++++++
.../UnitTests/Search/PascalCaseSearchTests.cs | 2 +-
.../UnitTests/Search/SearchExpressionTests.cs | 4 +-
.../APACHE-2.0-LICENSED-LIBRARIES.txt | 233 +++++++++++
ThirdPartyNotices/MIT-LICENSED-LIBRARIES.txt | 2 +
43 files changed, 2897 insertions(+), 21 deletions(-)
create mode 100644 CSharpCodeAnalyst.Mcp/CSharpCodeAnalyst.Mcp.csproj
create mode 100644 CSharpCodeAnalyst.Mcp/Contracts/GraphSnapshot.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Contracts/ICodeGraphSnapshotSource.cs
create mode 100644 CSharpCodeAnalyst.Mcp/McpServerHost.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/ElementFormatter.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/ElementTools.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/GraphInfoTools.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/RelationshipFormatter.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/RelationshipTools.cs
create mode 100644 CSharpCodeAnalyst.Mcp/Tools/ToolText.cs
create mode 100644 CSharpCodeAnalyst/Features/Mcp/CodeGraphSnapshotProvider.cs
create mode 100644 CSharpCodeAnalyst/Features/Mcp/McpServerService.cs
create mode 100644 CSharpCodeAnalyst/Resources/server_32.png
create mode 100644 Documentation/mcp.md
create mode 100644 Tests/UnitTests/Mcp/ElementToolsTests.cs
create mode 100644 Tests/UnitTests/Mcp/FakeSnapshotSource.cs
create mode 100644 Tests/UnitTests/Mcp/GraphInfoToolsTests.cs
create mode 100644 Tests/UnitTests/Mcp/RelationshipToolsTests.cs
create mode 100644 ThirdPartyNotices/APACHE-2.0-LICENSED-LIBRARIES.txt
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..be15213c 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);
@@ -163,5 +181,12 @@ private void StartUi()
mainWindow.DataContext = viewModel;
MainWindow = mainWindow;
mainWindow.Show();
+
+ // Not awaited: the window is up and usable whether or not the endpoint comes up, and every
+ // failure mode is reported by the service itself.
+ if (applicationSettings.McpServerAutoStart)
+ {
+ _ = mcpServerService.StartAsync();
+ }
}
}
\ No newline at end of file
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..c0514503 100644
--- a/CSharpCodeAnalyst/Configuration/AppSettings.cs
+++ b/CSharpCodeAnalyst/Configuration/AppSettings.cs
@@ -27,6 +27,20 @@ public string DefaultProjectExcludeFilter
///
public bool ShowOverviewOnImport { get; set; } = true;
+ ///
+ /// Whether the MCP endpoint opens automatically at startup, for someone who uses it every day
+ /// and does not want to press the ribbon button every time. Off by default: a listening socket
+ /// nobody asked for is not something a shipped application should decide on its own.
+ /// See Documentation/mcp.md.
+ ///
+ public bool McpServerAutoStart { get; set; }
+
+ ///
+ /// 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 +72,9 @@ public AppSettings Clone()
IncludeExternalCode = this.IncludeExternalCode,
SplitPropertyAccessors = this.SplitPropertyAccessors,
WarnIfFiltersActive = this.WarnIfFiltersActive,
- ShowOverviewOnImport = this.ShowOverviewOnImport
+ ShowOverviewOnImport = this.ShowOverviewOnImport,
+ McpServerAutoStart = this.McpServerAutoStart,
+ 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 0000000000000000000000000000000000000000..58734b514b41de1a75a95ba65e0056fd2369dd26
GIT binary patch
literal 3545
zcmZ8kc{J4D`+v`(VH%8m&6YmKPEiV(G-Ae*K|T^i_GJ`?gqSQLj3rCSzAHj(3lKHu*-zjJ=iInRAw&vIYS{o~$y?u{`u(PxJX!vO$b#~NVFm{#JC
zvL0iOzJY;srUChy>FWZOG|>g719iD%dN0f3|HkAhO=IYgL2
zA)=lY(fp1x@t(bp6JToZ=0#L8(X)_HS5j3{J(Um&fq;3)|JncF6#$sYL>K^N
z)~cL7qXNqPFYlk8EV=*XN@LG<=Q92+A4O-Wd;C{pSa@?tyklkeHZ^xAS7$WF@y~nP+j-6A3HT)a|W*Mt4?x`~S&o-BY0n
zbR}=6Z`~Rl9kAYCVMtBx{kXBnd35yrR#sN1d)sx**BUMlUW;PImOaJj?C}WyjIGP~
z6>juhTdItNS;@7A->pS?trc70wnv
zrcVA+I*a*Idh#7^xkppSs}xVzI+Mfbta?&%apR>jVi4%|+EoIHCzty5^)0%<|hkc5se3$vf%#ztQ16?Eu29iOJq=TkD_
z?)1C+XcX^vF6-T_=-6vI@$6OE3KiK5rNSpeWVadV2p_kAT7T!@eeq6-rw4<|!OaRb
z_h*}wui6Ixat(6O-pd*eK{Yku?~gq(D0M0<&NVdZdf4r=kv(8qv-qj2&CuL0UBUKnM?4O(
zz18K@{hfX|qj_I=H=7MT`n@q~{+Up1E0FfNV|L&6%MC@Mr$9(=*R-Yn?&ms>#`e0H
zh!^WIw=>QnJTkq-Zd84(%dk(5%#t$^cZ1igz3Q{!9`xfJf-4hjAVikxTjYk37NEy1dr@yvG?to{$%u
zc^(BVA;+iCQZJ?`K9X?WF-aZ(%`JP7*nxyL%gb$!T>dRcEAh4|!2^bs4qGw#C|Tev
zWbA`;a+%RxXM|x@d;9?Py=&V-D+{(_%CQ|(cZXN?dW0Z0_h6(ZTK4o;
z4}v2l4=0r_duk&(?vTuuquL9uW5Hc}h%7(x_1S8WC>o#Q_;A#67&?U)!|ZJPRhFODPd`wr-&99B*N_)#N7KO1GncTR3Z_p+wxrn%xZYE!
zFzcJ|au_eTu!GK2I{2t(MALe6Y)V)IwnukqU!PWPryE1i`PVg-R=g@>B&6ACCncm8
zgS^aB3co;lkw`!SnyG4t>_!#wMhw88<#A?`>xTv3A8u|L
zwOK(a?OU6}Mw=cvhd+mOLKdp70$rf4FiJub*)~4{moGhZ>iQ}ynux1UFc9+T@rgV0
zRtN@zX}Dziru$ZAb61(5hFDqi!V-C*KU&SLIlxwcUF&ALvUFF1R~_MbFhl%i%-law
zw8_!ROZ+GrC*QP(X4JZnJ}&h_+H_!~=Y<<^NB~-WBGk#
zNzOCm~Xw|Rk#Frad
zth7o?S`*(Xj_Gw0k*CQPZqOSZ@)t>$ai~0*l`A3?f2kYMG6ixRa!ZZ7MIl-nKSi(T
z(7GvSd$g#Jsm}gpzm;t$ALgPyPc$FrAhCePuke*Wh!b7?!i5G?k$Hd9{(=k_M$eNneoe*k0AWMF>?Cvl
zVgtxe74l_@b$M9@=SJMKNsx8HpmkYBPA9rUgSv?C+kg}UNY9$3$C#*eTbVyehDk
zOM{<*@aKv!LXHITq$^B{Z2Q)~Me-ciXKg&67S`~+wb}QDM)fn&?EyiDuNYk$GL>!H
z6ZDZT$1~XmUj-^}nU@l-bC5LU8*~J@sk2k^!qF08P4$$b-T>`fU2O@+!>(%oyu)!zw1@jIVCtA|ex
z6i-Z9y<`c>-wlFI#=^h0W1Suy|j%4kM7~-6|iw
zklTpAJ2569aIK9Px2>uDG()RTTnt5FdyBiOdqdr(9nFS+!X?B!P~Tjk2^@S}=8lC0
zaBz=Y_5oY3p(|yBoMXa(=}NzY
zSE;~E9C;>EBzSgUS{sTqx+%_`G`#K>`+W@)zV_-{?_Nq594r1*u5c?B{)Ts>#X05M
z0O>uXqkPi*>65-_kXZWxN;b7W0^pe3?+4M($3cu9Id
zJDY?zu6n7&-*f+qbrC2Nm@1(Mi&Kn&tDsLNTao7bA0fS2O0`vx#_{qKsck#=CU@n1
zwhvfT(+t|x)Poj2{O~HeaM~|qmj+v#c%Buy2_}776g~#1xeqy4j-+#jOVDko*ut7`
zfqo2?RK;H!E=@#ig3nckh$KufRC
fbrUD2jXOsI+ZQzK_HpX}eonA@CYVZHyRiQNOMyb>
literal 0
HcmV?d00001
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.)
================================================================================
From a0a8288ca896b81ed1172e2650301152eb81089b Mon Sep 17 00:00:00 2001
From: ATrefzer <36333177+ATrefzer@users.noreply.github.com>
Date: Mon, 10 Aug 2026 19:15:00 +0200
Subject: [PATCH 2/2] Remove auto start
---
CSharpCodeAnalyst/App.xaml.cs | 7 -------
CSharpCodeAnalyst/Configuration/AppSettings.cs | 9 ---------
2 files changed, 16 deletions(-)
diff --git a/CSharpCodeAnalyst/App.xaml.cs b/CSharpCodeAnalyst/App.xaml.cs
index be15213c..b075296f 100644
--- a/CSharpCodeAnalyst/App.xaml.cs
+++ b/CSharpCodeAnalyst/App.xaml.cs
@@ -181,12 +181,5 @@ private void StartUi()
mainWindow.DataContext = viewModel;
MainWindow = mainWindow;
mainWindow.Show();
-
- // Not awaited: the window is up and usable whether or not the endpoint comes up, and every
- // failure mode is reported by the service itself.
- if (applicationSettings.McpServerAutoStart)
- {
- _ = mcpServerService.StartAsync();
- }
}
}
\ No newline at end of file
diff --git a/CSharpCodeAnalyst/Configuration/AppSettings.cs b/CSharpCodeAnalyst/Configuration/AppSettings.cs
index c0514503..3e517a04 100644
--- a/CSharpCodeAnalyst/Configuration/AppSettings.cs
+++ b/CSharpCodeAnalyst/Configuration/AppSettings.cs
@@ -27,14 +27,6 @@ public string DefaultProjectExcludeFilter
///
public bool ShowOverviewOnImport { get; set; } = true;
- ///
- /// Whether the MCP endpoint opens automatically at startup, for someone who uses it every day
- /// and does not want to press the ribbon button every time. Off by default: a listening socket
- /// nobody asked for is not something a shipped application should decide on its own.
- /// See Documentation/mcp.md.
- ///
- public bool McpServerAutoStart { get; set; }
-
///
/// 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.
@@ -73,7 +65,6 @@ public AppSettings Clone()
SplitPropertyAccessors = this.SplitPropertyAccessors,
WarnIfFiltersActive = this.WarnIfFiltersActive,
ShowOverviewOnImport = this.ShowOverviewOnImport,
- McpServerAutoStart = this.McpServerAutoStart,
McpServerPort = this.McpServerPort
};
}