Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

mcp: a client DELETE waits for a pending keepalive ping

已关闭
#1,470 2 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

维护者通常 1 天内回复

@vshalpnjabi 已经在做这个了。

开始于 2026年10月29日。

  • #1478 来自 @vshalpnjabi —— 已合并

评估

这个 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 Close waits for its DELETE. That is a different code path.
主要语言
Go
星标
5.3k
派生
606
平均合并
1 天 14 小时
30 天内合并 PR
78

环境准备

在 Codespaces 中打开

在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

modelcontextprotocol/go-sdk 的其他 Issue

查看 modelcontextprotocol/go-sdk 的全部 Issue

相似的 Issue

更多 Go Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。