Skip to content

notifications/cancelled omits the SEP-2575 _meta envelope; the server's rejection permanently fails a 2026-07-28 session #1212

Description

@yhxlele

Describe the bug

On a session negotiated at 2026-07-28, the notifications/cancelled the client emits when a call's context is canceled is missing the SEP-2575 per-request _meta envelope. The two cancellation-notify sites (cancelCall and the async-notify branch of call, both in mcp/transport.go) build CancelledParams directly and are the only client sends that skip injectRequestMeta — the transport-layer counterpart of #1130/#1116's Ping/SetLoggingLevel omissions, which the earlier sweeps missed because it is not a ClientSession method.

A conformant server — this SDK's own stateless handler included — rejects the notification with -32602 "missing or invalid _meta field \"io.modelcontextprotocol/protocolVersion\"" over HTTP 400. Because the rejection responds to a notification, its JSON-RPC error body has no id, so checkResponse's decode-and-wrap-in-ErrRejected path (#1118) cannot classify it as a per-call rejection: the client treats it as a fatal write error and permanently fails the whole connection (c.fail).

Practical consequences on a stateless 2026-07-28 session:

  1. ClientSession.Unsubscribe poisons the session. Unsubscribe cancels the per-URI subscriptions/listen call, which fires the doomed notification. Every subsequent awaited call returns connection closed: ... client is closing: sending "notifications/cancelled": Bad Request, and fire-and-forget sends — a re-Subscribe included — vanish with no error surfacing anywhere. An Unsubscribe + Subscribe recovery/rotation cycle therefore kills the session it is trying to refresh.
  2. Canceling any in-flight request does the same. A timed-out or abandoned tool call takes the whole session with it shortly after.

Reproduced on v1.7.0 and on current main (21c18c6) with identical output.

To Reproduce

Self-contained; client and server are both this SDK. The middleware only logs what crosses the wire.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"time"

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

type recorder struct {
	http.ResponseWriter
	code int
	buf  bytes.Buffer
}

func (r *recorder) WriteHeader(c int) { r.code = c; r.ResponseWriter.WriteHeader(c) }
func (r *recorder) Write(b []byte) (int, error) {
	if r.code >= 400 {
		r.buf.Write(b)
	}
	return r.ResponseWriter.Write(b)
}
func (r *recorder) Flush() {
	if f, ok := r.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}

func main() {
	ctx := context.Background()
	srv := mcp.NewServer(&mcp.Implementation{Name: "s", Version: "1"}, &mcp.ServerOptions{
		SubscribeHandler:   func(context.Context, *mcp.SubscribeRequest) error { return nil },
		UnsubscribeHandler: func(context.Context, *mcp.UnsubscribeRequest) error { return nil },
	})
	inner := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return srv },
		&mcp.StreamableHTTPOptions{Stateless: true},
	)
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		r.Body = io.NopCloser(bytes.NewReader(body))
		var m struct {
			Method string `json:"method"`
		}
		_ = json.Unmarshal(body, &m)
		rec := &recorder{ResponseWriter: w, code: 200}
		inner.ServeHTTP(rec, r)
		if m.Method == "notifications/cancelled" {
			fmt.Printf("[wire] %-24s -> %d  meta_present=%v  resp=%s\n",
				m.Method, rec.code, bytes.Contains(body, []byte("io.modelcontextprotocol/protocolVersion")), rec.buf.String())
		}
	}))
	defer ts.Close()

	c := mcp.NewClient(&mcp.Implementation{Name: "repro", Version: "1"}, nil)
	sess, err := c.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: ts.URL}, nil)
	if err != nil {
		fmt.Println("connect:", err)
		return
	}
	defer sess.Close()
	fmt.Printf("negotiated=%s\n", sess.InitializeResult().ProtocolVersion)

	const uri = "test://resource"
	fmt.Printf("subscribe:   %v\n", sess.Subscribe(ctx, &mcp.SubscribeParams{URI: uri}))
	fmt.Printf("unsubscribe: %v\n", sess.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: uri}))
	time.Sleep(500 * time.Millisecond) // let the async cancelled POST land

	if _, lerr := sess.ListTools(ctx, nil); lerr == nil {
		fmt.Println("listTools after unsubscribe: OK")
	} else {
		fmt.Println("listTools after unsubscribe: POISONED:", lerr)
	}
}

Output on v1.7.0 and on main (identical):

negotiated=2026-07-28
subscribe:   <nil>
unsubscribe: <nil>
[wire] notifications/cancelled  -> 400  meta_present=false  resp={"jsonrpc":"2.0","error":{"code":-32602,"message":"missing or invalid _meta field \"io.modelcontextprotocol/protocolVersion\""}}
listTools after unsubscribe: POISONED: connection closed: calling "tools/list": client is closing: sending "notifications/cancelled": Bad Request

Expected behavior

The cancellation notification carries the same SEP-2575 _meta envelope as every other client send on a 2026-07-28 session, and the server accepts it (202 with no body). Independently, a rejected best-effort cancellation notification should not tear down the connection it was trying to tidy up.

Additional context

  • Fix proposed in mcp: carry the SEP-2575 _meta envelope on notifications/cancelled #1213: thread the session's SEP-2575 _meta envelope into the two cancellation-notify sites; with it the cancellation is accepted (202) and the session stays usable.
  • Found while exercising subscription rotation (Unsubscribe + re-Subscribe) against a stateless server: every rotation killed the session.
  • The escalation half — a rejection whose JSON-RPC error body has no id (as any response to a notification) bypassing mcp: fix error propagation to prevent session closing #1118's ErrRejected classification in checkResponse — is arguably its own defect and would have contained the blast radius here; happy to file it separately if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues, valuable feature requests

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions