mcp: a client DELETE waits for a pending keepalive ping
维护者通常 1 天内回复
评估
这个 Issue 还没有评估数据。
描述
What
On a streamable HTTP server with ServerOptions.KeepAlive set, a client DELETE does not get its answer until any pending keepalive ping times out. The wait is up to KeepAlive/2. When the ping times out, the server also logs keepalive ping failed; closing session for a session that the client closed on purpose.
The cause is in startKeepalive (mcp/shared.go, line 890 in v1.8.0, line 994 on main). Each ping gets its context from context.Background(), not from the keepalive context:
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
err := session.Ping(pingCtx, nil)
The DELETE branch of StreamableHTTPHandler calls ServerSession.Close. Close calls keepaliveCancel(), but that cancel does not reach the pending ping. Then jsonrpc2.Connection.Close waits until no outgoing calls are in flight. So Close, and with it the DELETE response, waits until the ping times out.
A ping is pending whenever the client no longer answers. A client that ends its session (for example, an agent at the end of a turn) usually stops reading the standalone stream first. So this happens on a large share of normal closes, not only with broken clients.
Impact
We run a streamable HTTP server with KeepAlive: 30s. Our agent client opens one session per turn and sends DELETE at the end of the turn. The DELETE took 15 to 30 s on many turns. During that time each closing session holds a goroutine, an open request, and its place in any per-session limit. Each of these closes also logs a false keepalive failure.
Reproduce
The program below uses only the SDK. It initializes a session, opens the standalone stream, waits for the server's ping, does not answer it, and then sends DELETE.
package main
import (
"bufio"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
interval := 2 * time.Second
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
server := mcp.NewServer(&mcp.Implementation{Name: "repro", Version: "0"}, &mcp.ServerOptions{KeepAlive: interval, Logger: logger})
ts := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil))
defer ts.Close()
post := func(sid, body string) *http.Response {
req, _ := http.NewRequest(http.MethodPost, ts.URL, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if sid != "" {
req.Header.Set("Mcp-Session-Id", sid)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
return resp
}
resp := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"0"}}}`)
sid := resp.Header.Get("Mcp-Session-Id")
resp.Body.Close()
post(sid, `{"jsonrpc":"2.0","method":"notifications/initialized"}`).Body.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Mcp-Session-Id", sid)
stream, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
scanner := bufio.NewScanner(stream.Body)
for scanner.Scan() {
if strings.Contains(scanner.Text(), `"method":"ping"`) {
break
}
}
fmt.Println("ping received, not answering; sending DELETE")
req, _ = http.NewRequest(http.MethodDelete, ts.URL, nil)
req.Header.Set("Mcp-Session-Id", sid)
start := time.Now()
del, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
del.Body.Close()
fmt.Printf("DELETE -> %d after %v\n", del.StatusCode, time.Since(start).Round(time.Millisecond))
time.Sleep(500 * time.Millisecond)
}
Output on v1.8.0 (the same on v1.6.1):
ping received, not answering; sending DELETE
level=ERROR msg="keepalive ping failed; closing session" error="context deadline exceeded" consecutiveFailures=1 failureThreshold=1
DELETE -> 204 after 1.001s
The DELETE waits the full ping timeout (KeepAlive/2 = 1 s), and the close is logged as a keepalive failure.
Suggested fix
Get the ping context from the keepalive context. If that context is cancelled, stop the loop without a failure:
case <-ticker.C:
- pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
+ pingCtx, pingCancel := context.WithTimeout(ctx, interval/2)
err := session.Ping(pingCtx, nil)
pingCancel()
+ if ctx.Err() != nil {
+ return
+ }
if err == nil {
The ctx.Err() check is necessary. Without it, the cancelled ping counts as a failure, and the server logs keepalive ping failed ... error="context canceled" on every close that it interrupts.
We tested this change against v1.8.0. With the program above, the DELETE returns at once (DELETE -> 204 after 0s) and nothing is logged. go test ./mcp/ passes. A cancelled ping leaves the in-flight set through conn.Retire in call (mcp/transport.go), so Connection.Close no longer waits for it.
ClientSession uses the same startKeepalive, so the change also applies to the client. We did not test the client path.
Related
- #1281 / #1282: the level of the keepalive failure log. This issue removes one common source of those records, a session that the client closed on purpose.
- #1183: the client-side
Closewaits for itsDELETE. That is a different code path.
- 主要语言
- Go
- 星标
- 5.3k
- 派生
- 606
- 平均合并
- 1 天 14 小时
- 30 天内合并 PR
- 78
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
modelcontextprotocol/go-sdk 的其他 Issue
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensions可能已有人在做 @ajuijas 于 229 天前认领。 未关闭needs investigation
难度 2/5 1-3 小时 新手友好度 72/100
modelcontextprotocol/go-sdk#745 · 13 条评论 · 1 个 reaction ·
维护者通常 1 天内回复
-
mcp: LoggingHandler drops records logged at custom slog levels可能已有人在做 @kaankucukx 于 6 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 45/100
modelcontextprotocol/go-sdk#1445 ·
维护者通常 1 天内回复
-
mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice可能已有人在做 @kerthans 于 9 天前认领。 未关闭
难度 3/5 1-2 天 新手友好度 35/100
modelcontextprotocol/go-sdk#1423 · 1 条评论 ·
维护者通常 1 天内回复
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401可能已有人在做 @skosovsky 于 14 天前认领。 未关闭
难度 3/5 1-2 天 新手友好度 25/100
modelcontextprotocol/go-sdk#1404 · 2 条评论 ·
维护者通常 1 天内回复
-
mcp: rejected request IDs receive no JSON-RPC error envelope可能已有人在做 @skosovsky 于 17 天前认领。 未关闭
难度 4/5 1-2 天 新手友好度 68/100
modelcontextprotocol/go-sdk#1391 ·
维护者通常 1 天内回复
查看 modelcontextprotocol/go-sdk 的全部 Issue
相似的 Issue
-
feat(gmail): make gmail get --format raw text output the exact message, like drafts get --format raw未关闭clawsweeper:needs-product-decision clawsweeper:no-new-fix-pr clawsweeper:source-repro impact:ux-friction issue-rating: 🦞 diamond lobster P3
难度 2/5 1-3 小时 新手友好度 78/100
openclaw/gogcli#1276 · 1 条评论 · 1 个 reaction ·
维护者通常 1 天内回复
-
enhancement ready-for-agent
难度 2/5 1-3 小时 新手友好度 76/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 88/100
hiero-ledger/hiero-sdk-go#1886 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 68/100
cloudevents/sdk-go#1346 ·