mcp: a client DELETE waits for a pending keepalive ping
I maintainer di solito rispondono entro 1 giorno
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
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.
- Lingua principale
- Go
- Stelle
- 5.3k
- Fork
- 606
- Merge medio
- 1g 14h
- PR unite (30g)
- 78
Preparare l'ambiente
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di modelcontextprotocol/go-sdk
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensionsForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Apertaneeds investigation
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
modelcontextprotocol/go-sdk#745 · 13 commenti · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
mcp: LoggingHandler drops records logged at custom slog levelsForse già presa @kaankucukx l’ha presa 6 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 45/100
modelcontextprotocol/go-sdk#1445 ·
I maintainer di solito rispondono entro 1 giorno
-
mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twiceForse già presa @kerthans l’ha presa 10 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 35/100
modelcontextprotocol/go-sdk#1423 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401Forse già presa @skosovsky l’ha presa 15 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 25/100
modelcontextprotocol/go-sdk#1404 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
mcp: rejected request IDs receive no JSON-RPC error envelopeForse già presa @skosovsky l’ha presa 18 giorni fa. Aperta
Difficoltà 4/5 1-2 giorni Idoneità per principianti 68/100
modelcontextprotocol/go-sdk#1391 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di modelcontextprotocol/go-sdk
Issue simili
-
docs(prompts): IF BLOCKED workflow-scope branch says to comment on the issue on jira/local trackersApertaagent-butler-finding bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
jordansmall/spindrift#6238 ·
I maintainer di solito rispondono entro 1 giorno
-
bug: microcks test get swallows test failure details and operation results in text output modeAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
microcks/microcks-cli#608 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
bug from-studio
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
esengine/DeepSeek-Reasonix#14868 ·
I maintainer di solito rispondono entro 1 giorno
-
Install notes print "Camunda REST API: https://" without a host when global.host is setForse già presa @irisfield l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
camunda/camunda-platform-helm#7790 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
wso2/agent-manager#2220 ·
I maintainer di solito rispondono entro 1 giorno