From 2fdabde1396adc95e195cbefc17ca1ec3a3f8bfd Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 1 Sep 2026 15:30:20 +0200 Subject: [PATCH] mcp: do not check metatada on notifications (#1215) Fixes #1212 --- mcp/server.go | 2 +- mcp/shared.go | 8 +++ mcp/streamable.go | 5 +- mcp/streamable_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index b935385..9e8a1b5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1962,7 +1962,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !validatedMeta.usesNewProtocol { + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) } diff --git a/mcp/shared.go b/mcp/shared.go index 5d0c1c2..2258752 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -560,7 +560,15 @@ type validatedMeta struct { // a non-nil error. clientInfo is optional; if present but invalid, an error is // returned. Otherwise, it returns usesNewProtocol set to true and the populated // initializeParams. +// Notifications always report usesNewProtocol false and never error. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { + // SEP-2575 defines the `_meta` triple for calls only: NotificationParams + // declares `_meta` optional with no protocolVersion, so a notification + // neither selects the new protocol nor is discarded for carrying an + // incomplete triple. + if !req.IsCall() { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } meta := extractRequestMeta(req.Params) if meta == nil { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil diff --git a/mcp/streamable.go b/mcp/streamable.go index c8cd28b..8ce5e01 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1530,12 +1530,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // The new (>= 2026-07-28) protocol is supported on the HTTP transport // only when [StreamableHTTPOptions.Stateless] is true. // + // The `_meta` triple is defined for calls only, notifications are + // excluded. + // // TODO: this validation can be moved within validateMcpHeaders. var metaVersion string if meta := extractRequestMeta(jreq.Params); meta != nil { metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } - if protocolVersion >= protocolVersion20260728 || metaVersion != "" { + if (jreq.IsCall() && protocolVersion >= protocolVersion20260728) || metaVersion != "" { // Extract again the protocol version from the context to see what the client // is advertising in the Mcp-Protocol-Version HTTP header. headerVersion := protocolVersionFromContext(req.Context()) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 58e5ca7..fed3522 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3712,6 +3712,126 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } } +// TestStreamableStateless_NotificationMetaValidation checks that the SEP-2575 +// per-request `_meta` triple is required of calls only. NotificationParams +// declares `_meta` optional with no protocolVersion, so a notification that +// omits it is well-formed and must be accepted with 202. +func TestStreamableStateless_NotificationMetaValidation(t *testing.T) { + newProtocolMeta := map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{}, + } + + tests := []struct { + name string + message map[string]any + wantStatus int + }{ + { + name: "cancelled without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{"requestID": 1, "reason": "context canceled"}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "progress without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationProgress, + "params": map[string]any{"progressToken": "t", "progress": 1}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "notification with matching meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": newProtocolMeta, + "requestID": 1, + }, + }, + wantStatus: http.StatusAccepted, + }, + { + // A notification that volunteers a version is still held to the + // header-match rule, so relaxing the requirement does not open a + // hole for inconsistent messages. + name: "notification with mismatched meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "2025-06-18", + MetaKeyClientCapabilities: map[string]any{}, + }, + "requestID": 1, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + // Calls remain subject to the requirement. + name: "call without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + } + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := json.Marshal(test.message) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, test.message["method"].(string)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, respBody) + } + if test.wantStatus == http.StatusAccepted && len(respBody) > 0 { + t.Errorf("accepted notification returned body %q, want empty", respBody) + } + }) + } +} + // TestStreamableClientUnsupportedVersionFallback exercises the full // SEP-2575 fallback. The client requests protocolVersion20260728, which the // server is configured to NOT advertise in supportedProtocolVersions for the