Skip to content

Stdio dispatch hangs after exactly 2 sequential tools/call requests (PowerShell client, sequential not concurrent) #1578

@mvonw

Description

@mvonw

Draft: upstream issue for modelcontextprotocol/csharp-sdk

To be filed at: https://github.com/modelcontextprotocol/csharp-sdk/issues/new


Title

Stdio dispatch hangs after exactly 2 sequential tools/call requests (PowerShell client, sequential not concurrent)

Body

Summary

Using .AddMcpServer().WithStdioServerTransport().WithTools<...>() from a PowerShell client (System.Diagnostics.Process redirected stdio), exactly two sequential tools/call requests succeed; the third hangs forever in the framework — the third tool's [McpServerTool]-decorated method is never invoked. Hangs on both 1.2.0 and 1.3.0. Calls are strictly sequential (response read before next request sent), not concurrent (so #88 doesn't apply).

Reproducible characterization

Variable Result
Three identical lightweight tool calls (e.g. one returning a small JSON string) Same hang at #3
Different tools per call (e.g. info, info, info vs info, heavy, info) Same hang at #3
500 ms inter-call delay client-side Same hang at #3
ModelContextProtocol 1.2.0 → 1.3.0 Same hang at #3
Client-side: StreamReader.Peek()+Read() vs ReadLineAsync().Wait() Same hang at #3
Removing a custom IPC decorator we wrap our handlers with Same hang at #3

Environment

  • ModelContextProtocol 1.2.0 and 1.3.0 (both reproduce)
  • Server: .NET 8 self-contained single-file Windows x64 process
  • Client: PowerShell 7.x (pwsh) on Windows 11, Process.Start with RedirectStandardInput/Output = true
  • Server uses WithStdioServerTransport() and registers ~50 tools via WithTools<T>()

Server setup (relevant excerpt)

builder.Services.AddV1ToolSingletons();        // registers each Tool class as singleton
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .AddV1Tools();                              // calls WithTools<T>() ~50 times

Minimal client repro (PowerShell)

$exe = '<path to McpServer.exe>'
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)

function Send-Rpc {
    param([string]$Method, $Params, $Id, [switch]$Notification)
    $msg = @{ jsonrpc='2.0'; method=$Method }
    if ($Params) { $msg.params = $Params }
    if (-not $Notification) { $msg.id = $Id }
    $proc.StandardInput.WriteLine(($msg | ConvertTo-Json -Compress -Depth 30))
    $proc.StandardInput.Flush()
}
function Read-Rpc {
    param([int]$TimeoutMs = 30000)
    $task = $proc.StandardOutput.ReadLineAsync()
    if (-not $task.Wait([TimeSpan]::FromMilliseconds($TimeoutMs))) { return '[TIMEOUT]' }
    return $task.Result
}

Send-Rpc -Method 'initialize' -Id 1 -Params @{
    protocolVersion='2024-11-05'; capabilities=@{}; clientInfo=@{name='probe'; version='1'}
}
$null = Read-Rpc 5000
Send-Rpc -Method 'notifications/initialized' -Notification

for ($i = 1; $i -le 5; $i++) {
    $ts = Get-Date
    Send-Rpc -Method 'tools/call' -Id (Get-Random) -Params @{ name='YOUR_TOOL'; arguments=@{} }
    $r = Read-Rpc 30000
    $el = ((Get-Date) - $ts).TotalSeconds
    if ($r -eq '[TIMEOUT]') { Write-Host "call #$i HUNG" -ForegroundColor Red; break }
    Write-Host "call #$i OK elapsed=${el}s"
}

Observed output

call #1 OK elapsed=0.06s
call #2 OK elapsed=0.01s
call #3 HUNG (timed out at 30s)

Server-side trace at hang

The tool method body for call #3 is never invoked (we instrumented [McpServerTool] methods to log on entry — the log line for call #3 never appears). So the framework's stdio reader or tool-router stalls before dispatch.

The server process sits idle at ~0.5 s CPU for many minutes after call #2 completes — so it's blocked on an await, not spinning. 10 threads.

What we ruled out

  • Our IPC layer downstream of the tool method (heavily instrumented; no activity on call Ensure all test client instances are cleaned up #3 because the method never runs)
  • Concurrency / SemaphoreSlim exhaustion in our code
  • A specific tool's parameter binding (any tool reproduces it as the 3rd call)
  • Pipe buffer overflow (each response is ~500 bytes)
  • The framework version (1.2.0 + 1.3.0 both repro)
  • Issue Expected usage for concurrent calls #88 — that's about parallel calls, ours are strictly sequential

Asks

  1. Is this a known limitation of the stdio transport?
  2. Is there a configuration to change buffering/flushing behavior?
  3. Could there be an internal queue/channel with capacity 2 that would explain the cliff at exactly call Ensure all test client instances are cleaned up #3?
  4. Does the framework expect the client to do anything specific after each response (e.g. call notifications/initialized again, or send a heartbeat)?

Happy to provide a fully self-contained minimal-server repro on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions