DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

MCP C# Task Polling: Stop Infinite input_required Loops

MCP Tasks let a tool finish asynchronously, but they also introduce a failure mode that a normal request timeout does not describe well: the task is alive, yet every poll returns the same input_required request. For MCP C# task polling, I want a bounded definition of “no progress,” not an endless loop or a user prompt that appears again and again.

The stable C# Tasks extension already provides that guard. The key is to set maxConsecutiveStuckPolls deliberately and test what happens when a server never advances.

Why MCP C# task polling can stall

In the MCP 2026-07-28 Tasks extension, a tool call can return a task instead of its final tool result. The client then uses tasks/get until the task completes, fails, is cancelled, or asks for input.

An input_required result contains keyed requests. A simplified response looks like this:

{
  "taskId": "task-1",
  "status": "input_required",
  "pollInterval": 1,
  "inputRequests": {
    "approval": {
      "method": "elicitation/create"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The key matters. If the next poll returns approval again, it is not a new question. Presenting it twice can produce duplicate confirmations or conflicting responses. Polling forever is not better; it hides a server that has stopped making useful progress.

The official C# SDK Tasks guide says CallToolWithPollingAsync deduplicates input-request keys. It also detects repeated input_required polls that contain no new keys, makes a best-effort tasks/cancel call, and throws McpException. The default stuck-poll threshold is 60.

Put a bound on MCP C# task polling

The extension method keeps the policy close to the call:

try
{
    CallToolResult result = await client.CallToolWithPollingAsync(
        new CallToolRequestParams { Name = "long-running-tool" },
        maxConsecutiveStuckPolls: 3,
        cancellationToken: cancellationToken);

    // Consume the completed tool result.
}
catch (McpException exception)
{
    logger.LogWarning(exception, "MCP task stopped making progress");
}
Enter fullscreen mode Exit fullscreen mode

I use 3 in a fast verifier, not as a universal production value. The practical time bound is approximately the threshold multiplied by the server's poll interval. A task polled every second and a task polled every 30 seconds should not automatically share the same threshold.

This guard complements the caller's CancellationToken. Caller cancellation answers “does my operation still need this result?” The stuck-poll guard answers “is the server returning any new work or state?” Those are different decisions, and keeping both makes the failure easier to diagnose.

Reproduce the repeated input_required loop offline

My complete sample on main uses ModelContextProtocol.Extensions.Tasks 2.2.0 and an in-memory transport. It advertises MCP 2026-07-28, returns a task from tools/call, and then returns the same approval request key on every tasks/get call.

The elicitation handler declines the request and counts how often it runs:

Handlers = new McpClientHandlers
{
    ElicitationHandler = (request, cancellationToken) =>
    {
        elicitationCalls++;
        return ValueTask.FromResult(
            new ElicitResult { Action = "decline" });
    },
};
Enter fullscreen mode Exit fullscreen mode

With a stuck limit of three, the observed contract is precise:

  • The handler runs once for the approval key.
  • The client sends one tasks/update containing approval: decline for task-1.
  • There are four tasks/get calls: one that introduces the key, then three with no new key.
  • The client sends one best-effort tasks/cancel for task-1.
  • The call throws McpException instead of polling again.

The verifier runs twice and compares the output byte for byte. It requires no MCP host, model account, credentials, clock, random values, or runtime network access. The merged pull request also records the exact restore, format, build, run, package, and vulnerability-audit commands.

The package used here is the stable ModelContextProtocol.Extensions.Tasks 2.2.0 release. Tasks are part of the final MCP 2026-07-28 extension model; the official release notes explain the poll-based lifecycle.

Choose the threshold—and know its limits

A low threshold is useful in a unit test because it turns a potential hang into a quick deterministic failure. In production, the same value could cancel a healthy task while a person is considering an approval prompt. I would choose it from the expected poll interval, normal response latency, and the cost of leaving remote work active.

Cancellation is cooperative and eventually consistent. A successful tasks/cancel response does not prove that remote work stopped at that exact instant, so callers must tolerate a late state transition and avoid assuming rollback.

This pattern is also not a replacement for an overall deadline, retry policy, or server-side task expiry. It specifically protects the polling loop when input_required repeats without a new key. Log task IDs and state transitions for diagnosis, but keep elicitation answers and credentials out of logs.

What stuck-poll threshold fits your server's poll interval and expected human response time?

Happy coding!

Top comments (3)

Collapse
 
arkforge-ceo profile image
ArkForge

The threshold math shifts when elicitation handlers wait on a real human. If a person typically takes 45 seconds to review an approval prompt and the poll interval is 5 seconds, maxConsecutiveStuckPolls: 3 cancels a healthy task before they finish reading. The minimum safe value is ceil(worst_case_human_response_time / poll_interval), not "fast enough to catch a broken server." Your verifier's synchronous decline makes the counter deterministic, but production elicitation is closer to Task.Delay(humanThinkTime) - which means the stuck guard and the human UX timeout need to be designed together, not independently.

Collapse
 
reneza profile image
René Zander

The word carrying that guard is best-effort. On the MCP servers I run I assume the cancel does not land, because a client that gives up on the poll has said nothing about whether the server finished the write, and a retry on top of that does the work twice. So the task id is the idempotency key and the terminal state stays readable after McpException, which makes abandoning the poll a decision about waiting rather than a claim about the outcome. How are you telling a genuinely stuck task apart from one whose cancel never landed?

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

That distinction—abandoning the wait versus establishing the remote outcome—is the one I’d preserve in telemetry. I’d emit separate poll_abandoned, cancel_accepted, and eventual terminal-state events, then make the integration test force completed after the cancel acknowledgement. For side-effecting tools, a retry should first read the task by ID and only create new work when the recorded outcome is absent. Does the current extension expose enough lifecycle hooks for that reconciliation, or are you wrapping CallToolWithPollingAsync?