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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,45 @@
</plugin>
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
<version>4.1.124.Final</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.2</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-core</artifactId>
<version>1.0.39</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<version>1.0.39</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>4.1.118.Final</version>
</dependency>
</dependencies>
</dependencyManagement>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
Expand Down
16 changes: 12 additions & 4 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,12 @@ private async ValueTask<ListToolsResult> HandleListToolsAsync(RequestContext<Lis
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
{
resourceToolMap = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
// Don't send tools/list_changed here — the client already called tools/list
// and will receive the up-to-date result. Sending a notification during the
// list handler would cause the client to call tools/list again, creating an
// infinite loop when tool availability is unstable (e.g., container MCP tools
// oscillating between available/unavailable).
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
}

tools.AddRange(resourceToolMap.Select(x => new Tool
Expand Down Expand Up @@ -193,8 +197,12 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
{
resourceToolMap = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
bool changed;
(resourceToolMap, changed) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
if (changed)
{
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
}
toolsRefreshed = true;
}

Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal interface IMcpResourceToolRefreshService
/// Refreshes the resource tool map by discovering MCP tools from connected resources.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The refreshed resource tool map.</returns>
Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
/// <returns>A tuple containing the refreshed resource tool map and a flag indicating whether the tool set changed.</returns>
Task<(IReadOnlyDictionary<string, ResourceToolEntry> ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken);

/// <summary>
/// Sends a tools list changed notification to connected MCP clients.
Expand Down
41 changes: 37 additions & 4 deletions src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel
}

/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
public async Task<(IReadOnlyDictionary<string, ResourceToolEntry> ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing resource tool map.");

Expand All @@ -95,10 +95,15 @@ public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourc
{
Debug.Assert(resource.McpServer is not null);

// Use DisplayName (the app-model name, e.g. "db1-mcp") rather than Name
// (the DCP runtime ID, e.g. "db1-mcp-ypnvhwvw") because the AppHost resolves
// resources by their app-model name in CallResourceMcpToolAsync.
var routedResourceName = resource.DisplayName ?? resource.Name;

foreach (var tool in resource.McpServer.Tools)
{
var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}";
refreshedMap[exposedName] = new ResourceToolEntry(resource.Name, tool);
var exposedName = $"{routedResourceName.Replace("-", "_")}_{tool.Name}";
refreshedMap[exposedName] = new ResourceToolEntry(routedResourceName, tool);

_logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description);
}
Expand All @@ -117,10 +122,38 @@ public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourc

lock (_lock)
{
var changed = _resourceToolMap.Count != refreshedMap.Count;
if (!changed)
{
// Check for deleted tools (in old but not in new).
foreach (var key in _resourceToolMap.Keys)
{
if (!refreshedMap.ContainsKey(key))
{
changed = true;
break;
}
}

// Check for new tools (in new but not in old).
if (!changed)
{
foreach (var key in refreshedMap.Keys)
{
if (!_resourceToolMap.ContainsKey(key))
{
changed = true;
break;
}
}
}
}

_resourceToolMap = refreshedMap;
_selectedAppHostPath = selectedAppHostPath;
_invalidated = false;
return _resourceToolMap;
return (_resourceToolMap, changed);
}
}

}
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public override JsonElement GetInputSchema()

public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
var (resourceToolMap, _) = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);

var totalToolCount = KnownMcpTools.All.Count + resourceToolMap.Count;
Expand Down
161 changes: 155 additions & 6 deletions tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools()
[
new ResourceSnapshot
{
Name = "test-resource",
DisplayName = "Test Resource",
Name = "test-resource-abcd1234",
DisplayName = "test-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
Expand Down Expand Up @@ -202,8 +202,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools()
// Assert - Verify resource tools are included
Assert.NotNull(tools);

// The resource tools should be exposed with a prefixed name: {resource_name}_{tool_name}
// Resource name "test-resource" becomes "test_resource" (dashes replaced with underscores)
// The resource tools should be exposed with a prefixed name using the DisplayName (app-model name):
// DisplayName "test-resource" becomes "test_resource" (dashes replaced with underscores)
var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one");
var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two");

Expand Down Expand Up @@ -235,8 +235,8 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
[
new ResourceSnapshot
{
Name = "my-resource",
DisplayName = "My Resource",
Name = "my-resource-abcd1234",
DisplayName = "my-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
Expand Down Expand Up @@ -291,6 +291,69 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
Assert.Equal("do_something", callToolName);
}

[Fact]
public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting()
{
// Arrange - Simulate resource snapshots that use a unique resource id and a logical display name.
var expectedToolResult = "List schemas completed";
string? callResourceName = null;
string? callToolName = null;

var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
Hash = "test-apphost-hash",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "db1-mcp-ypnvhwvw",
DisplayName = "db1-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "list_schemas",
Description = "Lists database schemas"
}
]
}
}
],
CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) =>
{
callResourceName = resourceName;
callToolName = toolName;
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = expectedToolResult }]
});
}
};

_backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout();

// Act
var result = await _mcpClient.CallToolAsync("db1_mcp_list_schemas", cancellationToken: _cts.Token).DefaultTimeout();

// Assert
Assert.NotNull(result);
Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
Assert.Equal("db1-mcp", callResourceName);
Assert.Equal("list_schemas", callToolName);
}

[Fact]
public async Task McpServer_CallTool_ListAppHosts_ReturnsResult()
{
Expand Down Expand Up @@ -347,6 +410,92 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult()
Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method);
}

[Fact]
public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification()
{
// Arrange - Create a mock backchannel with a resource that has MCP tools
// This simulates the db-mcp scenario where resource tools become available
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
Hash = "test-apphost-hash",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "db-mcp-abcd1234",
DisplayName = "db-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "query_database",
Description = "Query a database"
}
]
}
}
]
};

// Register the mock backchannel so resource tools will be discovered
_backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);

// Set up a channel to detect any tools/list_changed notifications
var notificationCount = 0;
await using var notificationHandler = _mcpClient.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, cancellationToken) =>
{
Interlocked.Increment(ref notificationCount);
return default;
});

// Act - Call ListTools which should discover the resource tools via refresh
// but should NOT send a tools/list_changed notification (that would cause an infinite loop)
var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();

// Assert - tools should include the resource tool
Assert.NotNull(tools);
var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database");
Assert.NotNull(dbMcpTool);

// Assert - no tools/list_changed notification should have been sent.
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var channelHandler = _mcpClient.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, _) =>
{
notificationChannel.Writer.TryWrite(notification);
return default;
});

var received = false;
try
{
await notificationChannel.Reader.ReadAsync(timeoutCts.Token);
received = true;
}
catch (OperationCanceledException)
{
// Expected — no notification arrived within the timeout
}

Assert.False(received, "tools/list_changed notification should not be sent during tools/list handling");
Assert.Equal(0, notificationCount);
}

[Fact]
public async Task McpServer_CallTool_UnknownTool_ReturnsError()
{
Expand Down
Loading