Skip to content

Fix MCP server tools/list infinite loop caused by notification race condition#14494

Open
sebastienros wants to merge 5 commits intorelease/13.2from
sebros/inifinite-loop-mcp
Open

Fix MCP server tools/list infinite loop caused by notification race condition#14494
sebastienros wants to merge 5 commits intorelease/13.2from
sebros/inifinite-loop-mcp

Conversation

@sebastienros
Copy link
Member

Description

Replaces #14376, retargeted to release/13.2.

HandleListToolsAsync was sending tools/list_changed notifications as a side effect of handling tools/list requests. When container MCP tools (e.g., db-mcp) oscillated between available/unavailable during enumeration, this created a tight feedback loop: tools/listtools/list_changedtools/list → … (~1,742 calls in ~2m44s until the client killed the server).

Changes:

  • Remove notification from list handlerHandleListToolsAsync no longer sends tools/list_changed. The client already receives the fresh list since it initiated the request.
  • Add change detection to RefreshResourceToolMapAsync — Returns (ToolMap, Changed) tuple. Compares old vs new tool key sets via zero-allocation iteration (count check + bidirectional ContainsKey) before reporting a change.
  • Gate notifications in HandleCallToolAsync — Only sends tools/list_changed when the tool set actually changed during a refresh triggered by a tool call.
  • RefreshToolsTool unchanged semantically — Still always notifies since it's an explicit user action.

Fixes #14375

Copilot AI and others added 4 commits February 13, 2026 10:40
…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>
Copilot AI review requested due to automatic review settings February 13, 2026 18:49
@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14494

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14494"

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_changed notifications from HandleListToolsAsync to prevent recursive list loops.
  • Add tool-set change detection to RefreshResourceToolMapAsync by returning (ToolMap, Changed) and comparing key sets.
  • Gate tools/list_changed notifications in HandleCallToolAsync based on the Changed flag, 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.

Comment on lines 410 to 421
// 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;
});
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

🎬 CLI E2E Test Recordings

The following terminal recordings are available for commit 6535ab8:

Test Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
CreateAndDeployToDockerCompose ▶️ View Recording
CreateAndDeployToDockerComposeInteractive ▶️ View Recording
CreateAndPublishToKubernetes ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateEmptyAppHostProject ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateStartWaitAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
ResourcesCommandShowsRunningResources ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording

📹 Recordings uploaded automatically from CI run #22000114233

Removed comments about using TryRead for notifications.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants