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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1"/>
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="18.7.23"/>
<PackageVersion Include="NSubstitute" Version="6.0.0"/>
<PackageVersion Include="ModelContextProtocol" Version="1.4.1"/>
<PackageVersion Include="ModelContextProtocol" Version="2.0.0"/>
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0"/>
<PackageVersion Include="Roslynator.Formatting.Analyzers" Version="4.15.0"/>
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.31.0.145097"/>
Expand Down
37 changes: 31 additions & 6 deletions src/ProjGraph.Mcp/ProjGraphTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ internal sealed class ProjGraphTools(
DiagramRenderers renderers,
IFileSystem fileSystem,
DiagramResourceCache cache,
McpServer server,
WorkspaceRootService rootService,
CollectingOutputConsole outputConsole)
{
Expand All @@ -48,6 +47,9 @@ public async Task<string> GetClassDiagramAsync(
[Description("Whether to include the title in the diagram (default: true).")]
bool showTitle = true,
IProgress<ProgressNotificationValue>? progress = null,
// Request-scoped, never the singleton's root instance — see PreparePathAsync. Defaulted so
// tests can call the method directly with an absolute path, which never consults the server.
McpServer server = null!,
CancellationToken cancellationToken = default)
{
if (options is { MaxDepth: < 0 })
Expand All @@ -56,7 +58,7 @@ public async Task<string> GetClassDiagramAsync(
throw new McpException($"maxDepth must not be negative; got {options.MaxDepth}.");
}

path = await PreparePathAsync(path, cancellationToken);
path = await PreparePathAsync(path, server, cancellationToken);

if (!fileSystem.FileExists(path) && !fileSystem.DirectoryExists(path))
throw new McpException($"Path not found: {path}");
Expand Down Expand Up @@ -133,9 +135,12 @@ public async Task<string> GetProjectGraphAsync(
[Description("Whether to include NuGet package dependencies in the graph (default: false).")]
bool includePackages = false,
IProgress<ProgressNotificationValue>? progress = null,
// Request-scoped, never the singleton's root instance — see PreparePathAsync. Defaulted so
// tests can call the method directly with an absolute path, which never consults the server.
McpServer server = null!,
CancellationToken cancellationToken = default)
{
path = await PreparePathAsync(path, cancellationToken);
path = await PreparePathAsync(path, server, cancellationToken);

progress?.Report(new ProgressNotificationValue
{
Expand Down Expand Up @@ -187,14 +192,17 @@ public async Task<string> GetProjectStatsAsync(
[Description("Number of top most-referenced projects to include. Defaults to 5.")]
int topN = 5,
IProgress<ProgressNotificationValue>? progress = null,
// Request-scoped, never the singleton's root instance — see PreparePathAsync. Defaulted so
// tests can call the method directly with an absolute path, which never consults the server.
McpServer server = null!,
CancellationToken cancellationToken = default)
{
if (topN < 1)
{
throw new McpException($"topN must be at least 1; got {topN}.");
}

path = await PreparePathAsync(path, cancellationToken);
path = await PreparePathAsync(path, server, cancellationToken);

progress?.Report(new ProgressNotificationValue
{
Expand Down Expand Up @@ -248,6 +256,9 @@ public async Task<string> GetErdAsync(
[Description("How EF Core owned types are shown: 'mirror' (default) inlines table-split owned types onto the owner as EF names them; 'classic' gives every owned type its own entity")]
string ownedMode = "mirror",
IProgress<ProgressNotificationValue>? progress = null,
// Request-scoped, never the singleton's root instance — see PreparePathAsync. Defaulted so
// tests can call the method directly with an absolute path, which never consults the server.
McpServer server = null!,
CancellationToken cancellationToken = default)
{
if (!ErdOwnedModeParser.TryParse(ownedMode, out var mode))
Expand All @@ -260,7 +271,7 @@ public async Task<string> GetErdAsync(
throw new McpException($"Invalid ownedMode '{ownedMode}'. Expected 'mirror' or 'classic'.");
}

path = await PreparePathAsync(path, cancellationToken);
path = await PreparePathAsync(path, server, cancellationToken);

RequireFileExists(path);
RequireCsFile(path);
Expand Down Expand Up @@ -437,7 +448,21 @@ private static async Task<T> RunAnalysisAsync<T>(Func<Task<T>> analysis)
}
}

private async Task<string> PreparePathAsync(string path, CancellationToken cancellationToken)
/// <summary>
/// Validates the requested path and resolves it to an absolute one, using the client's
/// workspace roots when it is relative.
/// </summary>
/// <param name="path">The path supplied by the client.</param>
/// <param name="server">
/// The request-scoped <see cref="McpServer"/> bound to the tool-method parameter, never one
/// captured at construction time: from protocol revision 2026-07-28 the client declares its
/// capabilities per request in <c>_meta</c> rather than in an <c>initialize</c> handshake, so
/// <see cref="McpServer.ClientCapabilities"/> is populated only on the request-scoped instance.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The resolved absolute path.</returns>
/// <exception cref="McpException">Thrown when the path is empty or cannot be resolved.</exception>
private async Task<string> PreparePathAsync(string path, McpServer server, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(path))
Expand Down
8 changes: 7 additions & 1 deletion src/ProjGraph.Mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ Diagram resources are automatically created when tools generate output. Clients
### Roots

The server resolves relative file paths against workspace roots declared by the client. When a client declares roots via
`roots/list`, passing `"MyApp.slnx"` instead of `"D:/Projects/MyApp/MyApp.slnx"` just works.
`roots/list`, passing `"MyApp.slnx"` instead of `"D:/Projects/MyApp/MyApp.slnx"` just works. A client that declares no
roots is asked to pass an absolute path instead.

Roots is deprecated by specification version 2026-07-28 ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2577-deprecate-roots-sampling-and-logging.md))
and stays supported for at least twelve months. Until it is retired the server keeps serving it on both that revision and
down-level ones; on 2026-07-28 the roots are re-read per request, since that revision drops the session that
`roots/list_changed` would otherwise invalidate.

### Progress Notifications

Expand Down
130 changes: 111 additions & 19 deletions src/ProjGraph.Mcp/WorkspaceRootService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@

namespace ProjGraph.Mcp;

/// <summary>
/// Resolves relative paths supplied to the MCP tools against the client's workspace roots.
/// </summary>
/// <remarks>
/// The Roots feature is deprecated by specification version 2026-07-28 (SEP-2577), which is why the
/// SDK calls below are wrapped in <c>MCP9005</c> suppressions. It stays wire-supported for at least
/// twelve months — removing it needs a separate SEP — so it is still served here, on both the new
/// revision and down-level ones. A client that does not advertise the capability degrades to
/// <see cref="RootsStatusKind.Unsupported"/> and is told to pass an absolute path. Retiring it means
/// taking the workspace root as a tool parameter or as server configuration, which is a behavioural
/// change tracked separately from this SDK upgrade.
/// </remarks>
/// <remarks>
/// Every method takes the <b>request-scoped</b> <see cref="McpServer"/>. From 2026-07-28 there is no
/// <c>initialize</c> handshake: the client restates its capabilities per request in <c>_meta</c>, so
/// <see cref="McpServer.ClientCapabilities"/> is null on the root server and populated only on the
/// instance the SDK binds to a tool-method parameter.
/// </remarks>
/// <param name="fileSystem">The file system used to probe candidate paths under each root.</param>
internal sealed class WorkspaceRootService(IFileSystem fileSystem) : IAsyncDisposable
{
private readonly SemaphoreSlim _initLock = new(1, 1);
Expand All @@ -28,18 +47,20 @@ public async Task<string> TryResolveAsync(string path, McpServer server, Cancell
return path;
}

await EnsureInitializedAsync(server, ct);
// Resolved into a local: on the per-request revision the roots belong to this request only,
// so an overlapping request must not be able to swap them out from under this one.
var roots = await ResolveRootsAsync(server, ct);

// Every failure below throws McpException: the SDK replaces the message of any other
// exception type with a generic "An error occurred invoking '…'", so the guidance
// (most importantly "provide an absolute path") would never reach the client.
if (_status == RootsStatusKind.Unsupported)
if (roots is null)
{
throw new McpException(
"Client does not support workspace roots. Please provide an absolute path.");
}

var matches = ResolveMatches(_rootPaths, path);
var matches = ResolveMatches(roots, path);

return matches.Count switch
{
Expand Down Expand Up @@ -143,55 +164,126 @@ private void EnsureRootsChangedHandler(McpServer server)

// Only mark as registered after a successful call, so a failed registration can be retried
// on the next initialization instead of permanently disabling roots invalidation.
#pragma warning disable MCP9005 // Roots is deprecated (SEP-2577); still served for down-level clients. See the file header.
_rootsChangedRegistration = server.RegisterNotificationHandler(
NotificationMethods.RootsListChangedNotification,
(_, _) =>
{
InvalidateRoots();
return default;
});
#pragma warning restore MCP9005
_notificationHandlerRegistered = true;
}

internal async Task RefreshRootsAsync(McpServer server, CancellationToken ct)
/// <summary>
/// Fetches the client's workspace roots over <c>roots/list</c>.
/// </summary>
/// <param name="server">The request-scoped server handling the current request.</param>
/// <param name="ct">A token to cancel the request.</param>
/// <returns>
/// The root directories, or <see langword="null"/> when the client refuses the request.
/// </returns>
private static async Task<List<string>?> TryFetchRootsAsync(McpServer server, CancellationToken ct)
{
var result = await server.RequestRootsAsync(new ListRootsRequestParams(), ct);
var paths = new List<string>();
foreach (var root in result.Roots)
try
{
paths.Add(new Uri(root.Uri).LocalPath);
#pragma warning disable MCP9005 // Roots is deprecated (SEP-2577); still served for down-level clients. See the file header.
var result = await server.RequestRootsAsync(new ListRootsRequestParams(), ct);
#pragma warning restore MCP9005
var paths = new List<string>();
foreach (var root in result.Roots)
{
paths.Add(new Uri(root.Uri).LocalPath);
}

return paths;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A client can advertise the capability and still refuse the request — most likely once
// it drops the deprecated feature. Reporting it as unsupported gets the caller the
// actionable "provide an absolute path" guidance instead of a generic SDK error.
return null;
}
}

_rootPaths = paths;
_status = RootsStatusKind.Ready;
private static bool HasRootsCapability(McpServer server)
{
#pragma warning disable MCP9005 // Roots is deprecated (SEP-2577); still served for down-level clients. See the file header.
return server.ClientCapabilities?.Roots is not null;
#pragma warning restore MCP9005
}

private async Task EnsureInitializedAsync(McpServer server, CancellationToken ct)
/// <summary>
/// Indicates whether the connection established client state once, via the <c>initialize</c>
/// handshake (protocol revision <c>2025-11-25</c> and earlier). Only such a connection has a
/// session for roots to be cached against and a durable channel for the client's
/// <c>roots/list_changed</c> notification to invalidate that cache; from <c>2026-07-28</c> the
/// client restates its capabilities on every request instead, so the roots are re-fetched each
/// time rather than served stale for the rest of the process's life.
/// </summary>
/// <param name="server">The request-scoped server handling the current request.</param>
/// <returns><see langword="true"/> when client state is session-scoped.</returns>
private static bool UsesSessionScopedCapabilities(McpServer server)
{
if (_status != RootsStatusKind.Unknown)
// Date-based revisions order correctly under an ordinal comparison.
var version = server.NegotiatedProtocolVersion;
return version is not null && string.CompareOrdinal(version, "2026-07-28") < 0;
}

/// <summary>
/// Produces the workspace roots the current request must resolve against.
/// </summary>
/// <param name="server">The request-scoped server handling the current request.</param>
/// <param name="ct">A token to cancel the request.</param>
/// <returns>
/// The root directories, or <see langword="null"/> when the client offers none.
/// </returns>
private async Task<IReadOnlyList<string>?> ResolveRootsAsync(McpServer server, CancellationToken ct)
{
if (!UsesSessionScopedCapabilities(server))
{
return;
// The per-request revision keeps nothing: the roots are scoped to this request, so
// publishing them to the shared cache would let an overlapping request resolve against
// the wrong workspace, and there is no session for roots/list_changed to invalidate.
return HasRootsCapability(server) ? await TryFetchRootsAsync(server, ct) : null;
}

if (_status == RootsStatusKind.Ready)
{
return _rootPaths;
}

if (!HasRootsCapability(server))
{
_status = RootsStatusKind.Unsupported;
return null;
}

await _initLock.WaitAsync(ct);
try
{
// Double-checked locking: re-check after acquiring lock
#pragma warning disable CA1508 // Avoid dead conditional code — volatile field may change between outer check and lock acquisition
if (_status != RootsStatusKind.Unknown)
if (_status == RootsStatusKind.Ready)
{
return;
return _rootPaths;
}
#pragma warning restore CA1508

if (server.ClientCapabilities?.Roots is null)
EnsureRootsChangedHandler(server);

var paths = await TryFetchRootsAsync(server, ct);
if (paths is null)
{
_status = RootsStatusKind.Unsupported;
return;
return null;
}

EnsureRootsChangedHandler(server);
await RefreshRootsAsync(server, ct);
_rootPaths = paths;
_status = RootsStatusKind.Ready;
return paths;
}
finally
{
Expand Down
7 changes: 4 additions & 3 deletions tests/ProjGraph.Tests.Contract/McpErdContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ public void GetErd_ShouldHave_Parameters()
var method = type.GetMethod("GetErdAsync");
var parameters = method!.GetParameters();

// Assert parameters exist (path, contextName, showTitle, ownedMode, progress, cancellationToken)
parameters.Should().HaveCount(6,
"GetErd should have 6 parameters: path, contextName, showTitle, ownedMode, progress, and cancellationToken");
// Assert parameters exist. 'server' is bound by the SDK to the request-scoped McpServer and
// is excluded from the tool's JSON schema, so it does not widen the client-facing contract.
parameters.Should().HaveCount(7,
"GetErd should have 7 parameters: path, contextName, showTitle, ownedMode, progress, server, and cancellationToken");

var pathParam = parameters.Should().ContainSingle(p => p.Name == "path").Which;
pathParam.ParameterType.Should().Be<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ public void GetProjectGraph_ShouldHave_Parameters()
var method = type.GetMethod("GetProjectGraphAsync");
var parameters = method!.GetParameters();

// Assert parameters exist (path, showTitle, includePackages, progress, cancellationToken)
parameters.Should().HaveCount(5,
"GetProjectGraph should have 'path', 'showTitle', 'includePackages', 'progress', and 'cancellationToken' parameters");
// Assert parameters exist. 'server' is bound by the SDK to the request-scoped McpServer and
// is excluded from the tool's JSON schema, so it does not widen the client-facing contract.
parameters.Should().HaveCount(6,
"GetProjectGraph should have 'path', 'showTitle', 'includePackages', 'progress', 'server', and 'cancellationToken' parameters");

var pathParam = parameters.Should().ContainSingle(p => p.Name == "path").Which;
pathParam.ParameterType.Should().Be<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ public void GetProjectStats_ShouldHave_CorrectParameterCount()
var method = typeof(ProjGraphTools).GetMethod("GetProjectStatsAsync");
var parameters = method!.GetParameters();

// path, topN, progress, cancellationToken = 4 parameters
parameters.Should().HaveCount(4,
"GetProjectStats should have 'path', 'topN', 'progress', and 'cancellationToken' parameters");
// path, topN, progress, server, cancellationToken = 5 parameters. 'server' is bound by the
// SDK to the request-scoped McpServer and is excluded from the tool's JSON schema, so it
// does not widen the client-facing contract.
parameters.Should().HaveCount(5,
"GetProjectStats should have 'path', 'topN', 'progress', 'server', and 'cancellationToken' parameters");
}
}
Loading
Loading