Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

mcp: a client DELETE waits for a pending keepalive ping

Đã đóng
#1,470 2 bình luận 0 reaction 1 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@vshalpnjabi đang làm issue này rồi.

Từ ngày 29/10/2026.

  • #1478 của @vshalpnjabi — đã merge

Đá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 Close waits for its DELETE. 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

Mở trong Codespaces

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.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của modelcontextprotocol/go-sdk

Tất cả issue của modelcontextprotocol/go-sdk

Issue tương tự

Thêm issue về Go

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.