Fix MCP server tools/list infinite loop caused by notification race condition#14494
Fix MCP server tools/list infinite loop caused by notification race condition#14494sebastienros wants to merge 5 commits intorelease/13.2from
Conversation
…handler and adding change detection The MCP server was entering an infinite tools/list loop because: 1. HandleListToolsAsync sent tools/list_changed notification after refreshing 2. The client responded with another tools/list request 3. This created a feedback loop: list → changed → list → changed Fix: - Remove SendToolsListChangedNotificationAsync from HandleListToolsAsync (the client already gets the fresh list since it requested it) - Add change detection to RefreshResourceToolMapAsync (returns bool Changed) - Only send tools/list_changed in HandleCallToolAsync when tools actually changed - RefreshToolsTool always sends notification (explicit user action) Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com>
Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com>
… channel wait in test - Replace HashSet.SetEquals with count check + iterate keys + ContainsKey to avoid allocation under lock in McpResourceToolRefreshService. - Replace Task.Delay(200) in test with Channel.ReadAsync + CancellationTokenSource timeout for more deterministic negative assertion. Co-authored-by: sebastienros <1165805+sebastienros@users.noreply.github.com>
The previous change detection only iterated old→new keys, missing the case where tools are swapped (same count but different keys). Now also checks new→old to detect newly added tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14494Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14494" |
There was a problem hiding this comment.
Pull request overview
Fixes an MCP server feedback loop where tools/list handling could trigger tools/list_changed, causing some clients to repeatedly re-request tools/list until the server is killed. The fix removes the notification side effect from the list handler and introduces change detection so notifications are only sent when the tool set actually changes.
Changes:
- Stop sending
tools/list_changednotifications fromHandleListToolsAsyncto prevent recursive list loops. - Add tool-set change detection to
RefreshResourceToolMapAsyncby returning(ToolMap, Changed)and comparing key sets. - Gate
tools/list_changednotifications inHandleCallToolAsyncbased on theChangedflag, and add a regression test.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs | Adds a regression test asserting tools/list does not emit tools/list_changed. |
| src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | Adapts to new refresh service return type while preserving “always notify” semantics for explicit refresh. |
| src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs | Implements change detection and returns (ToolMap, Changed) from refresh. |
| src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs | Updates the refresh API contract to return the changed flag. |
| src/Aspire.Cli/Commands/AgentMcpCommand.cs | Removes list-handler notification; gates call-handler notification on actual tool-set changes. |
| // Assert - no tools/list_changed notification should have been sent. | ||
| // Use a bounded wait via TryRead to catch any late-arriving notification | ||
| // without relying on an arbitrary Task.Delay. | ||
| 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; | ||
| }); |
There was a problem hiding this comment.
The test comment says it uses a bounded wait via TryRead, but the code uses ReadAsync with a timeout token. Consider either updating the comment to match the behavior, or switching to a non-blocking read pattern (e.g., TryRead/WaitToReadAsync) and avoiding the second RegisterNotificationHandler since notificationCount already captures notifications across the whole ListTools call.
🎬 CLI E2E Test RecordingsThe following terminal recordings are available for commit
📹 Recordings uploaded automatically from CI run #22000114233 |
Removed comments about using TryRead for notifications.
Description
Replaces #14376, retargeted to
release/13.2.HandleListToolsAsyncwas sendingtools/list_changednotifications as a side effect of handlingtools/listrequests. When container MCP tools (e.g., db-mcp) oscillated between available/unavailable during enumeration, this created a tight feedback loop:tools/list→tools/list_changed→tools/list→ … (~1,742 calls in ~2m44s until the client killed the server).Changes:
HandleListToolsAsyncno longer sendstools/list_changed. The client already receives the fresh list since it initiated the request.RefreshResourceToolMapAsync— Returns(ToolMap, Changed)tuple. Compares old vs new tool key sets via zero-allocation iteration (count check + bidirectionalContainsKey) before reporting a change.HandleCallToolAsync— Only sendstools/list_changedwhen the tool set actually changed during a refresh triggered by a tool call.RefreshToolsToolunchanged semantically — Still always notifies since it's an explicit user action.Fixes #14375