You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
call() awaits cancelCall() before returning, so a cancelled call does not return to its caller until the best-effort notifications/cancelled message has been delivered — or has itself timed out. On v1.7.0 that means a caller's context deadline can be overrun by up to notifyCancellationTimeout (5 s). Before #885 the notification context carried no deadline at all and the overrun was unbounded.
The invariant is already written down in this file, one constant above the code that breaks it:
// notifyCancellationTimeout bounds the cancellation notification we send to// the peer when the caller's context is cancelled. The notification is// best-effort: a degraded connection (e.g. an OAuth flow that has been// abandoned) must not be able to block the caller's return path or// re-trigger expensive recovery on its behalf. See issue #882.constnotifyCancellationTimeout=5*time.Second
The bound makes the blocking finite. It does not stop the notification from blocking the caller's return path — the code still does precisely what the comment forbids, just for no longer than five seconds.
Why this is a defect and not a tuning question
The message being delivered is "I have changed my mind about this request". It is sent synchronously, on the critical path of the caller who has already changed their mind. That caller's deadline is a promise it made to its own caller; spending that budget on a courtesy message to an unresponsive peer breaks the promise. And there is nothing to wait for: a best-effort notification produces no result the caller can act on. Today its error is joined into the returned error, where it is pure noise — see the reproduction output below.
Two things are wrong here, and only the second one is the bug:
the caller's return is gated on that delivery (still present).
(1) is what made (2) spectacular. (2) is what is actually wrong. Stated positively: a best-effort notification must not block the caller's return, and must carry a bounded context. Half of that now holds.
Reproduction
go-sdk v1.7.0, Go 1.26, linux. A Streamable HTTP server that is slow to accept the cancellation POST — which is the normal case, since it is by construction busy with the very request being cancelled:
caller deadline: 200ms
[server] holding notifications/cancelled for 20s
CallTool returned after 5.202s (overshoot 5.002s)
err: context deadline exceeded
sending "notifications/cancelled": rejected by transport: Post "http://127.0.0.1:35295": context deadline exceeded
The overshoot is exactly notifyCancellationTimeout, and the caller's error now carries a second line about a delivery it never asked to wait for.
For scale, the same mechanism on v0.6.0 (before the bound existed): a declared 30 s tool-call limit was measured returning at 155.06–155.08 s in production against a busy Streamable HTTP MCP server — repeatedly, with the same figure to two decimal places.
Related: the eager retire is delayed by the very thing it protects against
cancelCall's doc comment justifies eager retirement as the mitigation for hanging shutdown when a peer cannot be reached. But conn.Retire(call, ctx.Err()) runs after the notify, so the eager retire is itself delayed by up to 5 s by exactly the unresponsive peer it was introduced to handle.
Suggested direction
Retire first, deliver off the caller's path. The pattern already exists a few lines above, in callSubscriptionsListen:
with the notify error logged rather than joined into the caller's error. context.WithoutCancel(ctx) still preserves values (tracing, auth) without preserving a dead deadline, exactly as #885 established — the bounded context stays; only the waiting goes away. If some callers genuinely want to wait for delivery, that reads better as an explicit option than as the default for everyone.
Environment
github.com/modelcontextprotocol/go-sdk v1.7.0; same code path on main @ 958dfcc
Go 1.26, linux
Originally observed through github.com/inhuman/mcp-multiplexer on go-sdk v0.6.0
Workaround, for anyone else hitting this
Race the SDK call against your own ctx.Done() and return on your own deadline. The price is an orphaned goroutine holding one connection until the SDK unwinds, so it is worth counting those rather than hiding them.
Summary
call()awaitscancelCall()before returning, so a cancelled call does not return to its caller until the best-effortnotifications/cancelledmessage has been delivered — or has itself timed out. On v1.7.0 that means a caller's context deadline can be overrun by up tonotifyCancellationTimeout(5 s). Before #885 the notification context carried no deadline at all and the overrun was unbounded.go-sdk/mcp/transport.go
Lines 271 to 309 in 958dfcc
The invariant is already written down in this file, one constant above the code that breaks it:
The bound makes the blocking finite. It does not stop the notification from blocking the caller's return path — the code still does precisely what the comment forbids, just for no longer than five seconds.
Why this is a defect and not a tuning question
The message being delivered is "I have changed my mind about this request". It is sent synchronously, on the critical path of the caller who has already changed their mind. That caller's deadline is a promise it made to its own caller; spending that budget on a courtesy message to an unresponsive peer breaks the promise. And there is nothing to wait for: a best-effort notification produces no result the caller can act on. Today its error is joined into the returned error, where it is pure noise — see the reproduction output below.
Two things are wrong here, and only the second one is the bug:
(1) is what made (2) spectacular. (2) is what is actually wrong. Stated positively: a best-effort notification must not block the caller's return, and must carry a bounded context. Half of that now holds.
Reproduction
go-sdk v1.7.0, Go 1.26, linux. A Streamable HTTP server that is slow to accept the cancellation POST — which is the normal case, since it is by construction busy with the very request being cancelled:
Output:
The overshoot is exactly
notifyCancellationTimeout, and the caller's error now carries a second line about a delivery it never asked to wait for.For scale, the same mechanism on v0.6.0 (before the bound existed): a declared 30 s tool-call limit was measured returning at 155.06–155.08 s in production against a busy Streamable HTTP MCP server — repeatedly, with the same figure to two decimal places.
Related: the eager retire is delayed by the very thing it protects against
cancelCall's doc comment justifies eager retirement as the mitigation for hanging shutdown when a peer cannot be reached. Butconn.Retire(call, ctx.Err())runs after the notify, so the eager retire is itself delayed by up to 5 s by exactly the unresponsive peer it was introduced to handle.Suggested direction
Retire first, deliver off the caller's path. The pattern already exists a few lines above, in
callSubscriptionsListen:Applied to
call(), roughly:with the notify error logged rather than joined into the caller's error.
context.WithoutCancel(ctx)still preserves values (tracing, auth) without preserving a dead deadline, exactly as #885 established — the bounded context stays; only the waiting goes away. If some callers genuinely want to wait for delivery, that reads better as an explicit option than as the default for everyone.Environment
github.com/modelcontextprotocol/go-sdkv1.7.0; same code path onmain@ 958dfccgithub.com/inhuman/mcp-multiplexeron go-sdk v0.6.0Workaround, for anyone else hitting this
Race the SDK call against your own
ctx.Done()and return on your own deadline. The price is an orphaned goroutine holding one connection until the SDK unwinds, so it is worth counting those rather than hiding them.