Skip to content

ClientSession.Ping cannot succeed on a 2026-07-28 session (omits SEP-2575 _meta; stateless path fails session-not-found; failure poisons the connection) #1130

Description

@robfischer1

Describe the bug

On a session negotiated at 2026-07-28, ClientSession.Ping cannot succeed by any route, against the SDK's own server.

Ping is the one remaining Modern-eligible client method that does not call injectRequestMeta, so it sends ping with the MCP-Protocol-Version: 2026-07-28 header and no SEP-2575 _meta, and a conformant server rejects it. Supplying _meta by hand does not rescue it: the request then fails inside the transport with failed to connect (session ID: ): session not found, because a v2 session is necessarily stateless and stateless mode neither reads nor sets Mcp-Session-Id.

Two further consequences make it worse than a failed health check:

  1. Every failed ping takes the whole connection with it. After the failed ping, unrelated calls on the same session fail with connection closed: ... client is closing: sending "ping": .... A caller doing periodic liveness pings therefore kills the sessions it is checking, and in-flight application calls die reporting a ping error they never issued. (Likely the same underlying behaviour as StreamableClientTransport: Connection poisoned by transient errors #683.)
  2. Supplying the envelope unconditionally breaks pre-v2 sessions. A 2025-11-25 session accepts the bare ping and rejects one carrying the reserved _meta keys, so a client cannot simply always send it.

This is adjacent to #1116, but not the same. That issue was closed because SetLoggingLevel was removed from the 2026-07-28 protocol, so its missing injection no longer matters. ping is still in the protocol, so the same omission is a live defect. On current main, injectRequestMeta has 10 call sites and Ping (mcp/client.go:1222) is absent from them:

func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error {
	_, err := handleSend[*emptyResult](ctx, methodPing, newClientRequest(cs, orZero[Params](params)))
	return err
}

To Reproduce

Self-contained; client and server are both this SDK. A fresh session per case, because a failed ping poisons the connection and contaminates any later probe on it.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func try(label string, stateless, envelope bool) {
	srv := mcp.NewServer(&mcp.Implementation{Name: "s", Version: "1"}, nil)
	ts := httptest.NewServer(mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return srv },
		&mcp.StreamableHTTPOptions{Stateless: stateless},
	))
	defer ts.Close()

	c := mcp.NewClient(&mcp.Implementation{Name: "repro", Version: "1"}, nil)
	sess, err := c.Connect(context.Background(),
		&mcp.StreamableClientTransport{Endpoint: ts.URL}, nil)
	if err != nil {
		fmt.Printf("%-28s connect failed: %v\n", label, err)
		return
	}
	defer sess.Close()

	neg := ""
	if r := sess.InitializeResult(); r != nil {
		neg = r.ProtocolVersion
	}

	var params *mcp.PingParams
	if envelope {
		params = &mcp.PingParams{Meta: mcp.Meta{
			mcp.MetaKeyProtocolVersion:    neg,
			mcp.MetaKeyClientCapabilities: map[string]any{},
		}}
	}
	perr := sess.Ping(context.Background(), params)
	_, lerr := sess.ListTools(context.Background(), nil)

	ok := func(e error) string { if e == nil { return "OK" }; return "ERR" }
	fmt.Printf("%-28s negotiated=%-11s ping=%-4s listTools_after=%s\n",
		label, neg, ok(perr), ok(lerr))
	if perr != nil { fmt.Printf("      ping err: %v\n", perr) }
	if lerr != nil { fmt.Printf("      poisoned: %v\n", lerr) }
}

func main() {
	try("stateful  + bare ping", false, false)
	try("stateful  + envelope", false, true)
	try("stateless + bare ping", true, false)
	try("stateless + envelope", true, true)
}

Output on v1.7.0 (identical code on current main):

stateful  + bare ping        negotiated=2025-11-25  ping=OK   listTools_after=OK
stateful  + envelope         negotiated=2025-11-25  ping=ERR  listTools_after=ERR
      ping err: calling "ping": sending "ping": Bad Request
      poisoned: connection closed: calling "tools/list": client is closing: sending "ping": Bad Request
stateless + bare ping        negotiated=2026-07-28  ping=ERR  listTools_after=ERR
      ping err: calling "ping": sending "ping": missing or invalid _meta field "io.modelcontextprotocol/protocolVersion": Bad Request
      poisoned: connection closed: calling "tools/list": client is closing: sending "ping": missing or invalid _meta field "io.modelcontextprotocol/protocolVersion": Bad Request
stateless + envelope         negotiated=2026-07-28  ping=ERR  listTools_after=ERR
      ping err: calling "ping": sending "ping": failed to connect (session ID: ): session not found
      poisoned: connection closed: calling "tools/list": client is closing: sending "ping": failed to connect (session ID: ): session not found

Expected behavior

Ping succeeds on a 2026-07-28 session against the SDK's own server, and a failed ping does not tear down the connection.

Observed in the wild

A gateway using Ping as its periodic liveness probe against ~30 upstreams: after those upstreams moved to 2026-07-28, every probe failed, dropped the session, and the next sweep redialed — 1,059 reconnects in 37 minutes, roughly one per upstream per sweep. Because the failure also poisons the connection, unrelated in-flight calls intermittently failed with calling "tools/call": sending "ping": .... Both symptoms stopped once the probe skipped v2 sessions entirely.

Additional context

  • go-sdk v1.7.0; reproduced against current main (same code at mcp/client.go:1222).
  • Fix presumably mirrors the other ten call sites: guard on cs.usesNewProtocol() and injectRequestMeta. That alone would resolve the bare-ping case, but the stateless session not found path looks separate and would still need addressing for Ping to work on a v2 session.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions