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
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ internal interface IAuxiliaryBackchannelMonitor
/// </summary>
IAppHostAuxiliaryBackchannel? SelectedConnection { get; }

/// <summary>
/// Gets the AppHost path of the currently resolved connection, or <c>null</c> if no connection is available.
/// </summary>
string? ResolvedAppHostPath => SelectedConnection?.AppHostInfo?.AppHostPath;

/// <summary>
/// Gets all connections that are within the scope of the specified working directory.
/// </summary>
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal sealed class McpResourceToolRefreshService : IMcpResourceToolRefreshSer
private McpServer? _server;
private Dictionary<string, ResourceToolEntry> _resourceToolMap = new(StringComparer.Ordinal);
private bool _invalidated = true;
private string? _selectedAppHostPath;
private string? _lastRefreshedAppHostPath;

public McpResourceToolRefreshService(
IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
Expand All @@ -35,7 +35,7 @@ public bool TryGetResourceToolMap(out IReadOnlyDictionary<string, ResourceToolEn
{
lock (_lock)
{
if (_invalidated || _selectedAppHostPath != _auxiliaryBackchannelMonitor.SelectedAppHostPath)
if (_invalidated || _lastRefreshedAppHostPath != _auxiliaryBackchannelMonitor.ResolvedAppHostPath)
{
resourceToolMap = null!;
return false;
Expand Down Expand Up @@ -150,7 +150,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel
}

_resourceToolMap = refreshedMap;
_selectedAppHostPath = selectedAppHostPath;
_lastRefreshedAppHostPath = selectedAppHostPath;
_invalidated = false;
return (_resourceToolMap, changed);
}
Expand Down
60 changes: 60 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,66 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification()
Assert.Equal(0, notificationCount);
}

[Fact]
public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchanged()
{
// Arrange - Create a mock backchannel and track how many times GetResourceSnapshotsAsync is called
var getResourceSnapshotsCallCount = 0;
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
Hash = "test-apphost-hash",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
GetResourceSnapshotsHandler = (ct) =>
{
Interlocked.Increment(ref getResourceSnapshotsCallCount);
return Task.FromResult(new List<ResourceSnapshot>
{
new ResourceSnapshot
{
Name = "db-mcp-xyz",
DisplayName = "db-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "query_db",
Description = "Query the database"
}
]
}
}
});
}
};

_backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);

// Act - Call ListTools twice
var tools1 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
var tools2 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();

// Assert - Both calls return the resource tool
Assert.Contains(tools1, t => t.Name == "db_mcp_query_db");
Assert.Contains(tools2, t => t.Name == "db_mcp_query_db");

// The resource tool map should be cached after the first call,
// so GetResourceSnapshotsAsync should only be called once (during the first refresh).
// Before the fix, TryGetResourceToolMap always returned false due to
// SelectedAppHostPath vs SelectedConnection path mismatch, causing every
// ListTools call to trigger a full refresh.
Assert.Equal(1, getResourceSnapshotsCallCount);
}

[Fact]
public async Task McpServer_CallTool_UnknownTool_ReturnsError()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,24 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha
/// </summary>
public Func<string, string, IReadOnlyDictionary<string, JsonElement>?, CancellationToken, Task<CallToolResult>>? CallResourceMcpToolHandler { get; set; }

/// <summary>
/// Gets or sets the function to call when GetResourceSnapshotsAsync is invoked.
/// If null, returns the ResourceSnapshots list.
/// </summary>
public Func<CancellationToken, Task<List<ResourceSnapshot>>>? GetResourceSnapshotsHandler { get; set; }

public Task<DashboardUrlsState?> GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(DashboardUrlsState);
}

public Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default)
{
if (GetResourceSnapshotsHandler is not null)
{
return GetResourceSnapshotsHandler(cancellationToken);
}

return Task.FromResult(ResourceSnapshots);
}

Expand Down
Loading