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..5f504ede496 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."); @@ -117,10 +117,37 @@ 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..25c01088c5c 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -347,6 +347,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", + 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() {