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() {