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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions