mcp: a client DELETE waits for a pending keepalive ping
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
Issue này chưa được đánh giá.
Mô tả
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.
- Ngôn ngữ chính
- Go
- Star
- 5.3k
- Fork
- 606
- Merge trung bình
- 1 ngày 14 giờ
- Pull request đã merge (30 ngày)
- 78
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của modelcontextprotocol/go-sdk
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensionsCó thể làm lại được Pull request cho issue này đã bị đóng mà không được merge. Đang mởneeds investigation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
modelcontextprotocol/go-sdk#745 · 13 bình luận · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
mcp: LoggingHandler drops records logged at custom slog levelsCó thể đã có người làm @kaankucukx đã nhận 6 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 45/100
modelcontextprotocol/go-sdk#1445 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twiceCó thể đã có người làm @kerthans đã nhận 9 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 35/100
modelcontextprotocol/go-sdk#1423 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401Có thể đã có người làm @skosovsky đã nhận 14 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 25/100
modelcontextprotocol/go-sdk#1404 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
mcp: rejected request IDs receive no JSON-RPC error envelopeCó thể đã có người làm @skosovsky đã nhận 18 ngày trước. Đang mở
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 68/100
modelcontextprotocol/go-sdk#1391 ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của modelcontextprotocol/go-sdk
Issue tương tự
-
ux
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
evcc-io/evcc#35605 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug report
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
Maintainer thường phản hồi trong vòng 1 ngày
-
type:bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
nightgauge/nightgauge#2732 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
fix below CVEsĐang mởbug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
Maintainer thường phản hồi trong vòng 5 ngày
-
good first issue help wanted
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100