diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
index ceb176d9460..75ebb7ec18a 100644
--- a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
+++ b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
@@ -43,6 +43,45 @@
+
+
+
+ org.eclipse.jetty
+ jetty-server
+ 9.4.57.v20241219
+
+
+ org.postgresql
+ postgresql
+ 42.7.2
+
+
+ io.netty
+ netty-codec-http2
+ 4.1.124.Final
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+ 9.37.2
+
+
+ io.projectreactor.netty
+ reactor-netty-core
+ 1.0.39
+
+
+ io.projectreactor.netty
+ reactor-netty-http
+ 1.0.39
+
+
+ io.netty
+ netty-handler
+ 4.1.118.Final
+
+
+
17
17
diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
index bb239d4ee48..3aff0911c2a 100644
--- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
@@ -147,8 +147,12 @@ private async ValueTask HandleListToolsAsync(RequestContext new Tool
@@ -193,8 +197,12 @@ private async ValueTask HandleCallToolAsync(RequestContext
/// The cancellation token.
- /// The refreshed resource tool map.
- Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
+ /// A tuple containing the refreshed resource tool map and a flag indicating whether the tool set changed.
+ Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
///
/// Sends a tools list changed notification to connected MCP clients.
diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
index 95ecf656214..e3d3c368333 100644
--- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
+++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
@@ -71,7 +71,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel
}
///
- public async Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
+ public async Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing resource tool map.");
@@ -95,10 +95,15 @@ public async Task> 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);
}
@@ -117,10 +122,38 @@ public async Task> 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);
}
}
+
}
diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
index c8d3d5c4fef..e0a919c7333 100644
--- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
@@ -19,7 +19,7 @@ public override JsonElement GetInputSchema()
public override async ValueTask 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;
diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
index dd7cb3e3587..a4110121431 100644
--- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
@@ -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
@@ -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");
@@ -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
@@ -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()
{
@@ -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();
+ 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()
{