From ac627b03b94ffc6721e5b72ebbc6cb53536c871a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 20 Jan 2026 16:28:32 +0100 Subject: [PATCH 1/4] =?UTF-8?q?mcp:=20add=20Allow=20header=20to=20405=20re?= =?UTF-8?q?sponses=20per=20RFC=209110=20=C2=A715.5.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 405 Method Not Allowed responses MUST include an Allow header listing supported methods, per RFC 9110 Section 15.5.6. This fixes issues with strict HTTP gateways (like Apigee) that treat 405 responses without an Allow header as malformed, returning 502 Bad Gateway errors. Changes: - SSEHandler: Add Allow header (GET, POST) for unsupported methods - StreamableHTTPHandler: Add Allow header for GET-without-session case, differentiating between stateless mode (POST, DELETE) and stateful mode (GET, POST, DELETE) - Add tests to verify Allow header presence in all 405 responses Co-authored-by: omgitsads <4619+omgitsads@users.noreply.github.com> --- mcp/sse.go | 3 +- mcp/sse_test.go | 35 ++++++++++++++++++++ mcp/streamable.go | 7 ++++ mcp/streamable_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/mcp/sse.go b/mcp/sse.go index a668c6d0..ae65c16c 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -202,7 +202,8 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } if req.Method != http.MethodGet { - http.Error(w, "invalid method", http.StatusMethodNotAllowed) + w.Header().Set("Allow", "GET, POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 132a0317..2de4153d 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -186,3 +186,38 @@ func TestSSEClientTransport_HTTPErrors(t *testing.T) { }) } } + +// TestSSE405AllowHeader verifies RFC 9110 §15.5.6 compliance: +// 405 Method Not Allowed responses MUST include an Allow header. +func TestSSE405AllowHeader(t *testing.T) { + server := NewServer(testImpl, nil) + + handler := NewSSEHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + methods := []string{"PUT", "PATCH", "DELETE", "OPTIONS"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req, err := http.NewRequest(method, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != http.StatusMethodNotAllowed { + t.Errorf("status code: got %d, want %d", got, http.StatusMethodNotAllowed) + } + + allow := resp.Header.Get("Allow") + if allow != "GET, POST" { + t.Errorf("Allow header: got %q, want %q", allow, "GET, POST") + } + }) + } +} diff --git a/mcp/streamable.go b/mcp/streamable.go index 167d6847..7a496e66 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -275,6 +275,13 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque switch req.Method { case http.MethodPost, http.MethodGet: if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + // In stateless mode, only POST is allowed (no persistent sessions for GET/DELETE). + if h.opts.Stateless { + w.Header().Set("Allow", "POST") + } else { + w.Header().Set("Allow", "GET, POST, DELETE") + } http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index b1c3f074..f4604473 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1877,6 +1877,80 @@ func TestStreamableGET(t *testing.T) { } } +// TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: +// 405 Method Not Allowed responses MUST include an Allow header. +func TestStreamable405AllowHeader(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + stateless bool + method string + wantAllow string + wantStatus int + withSession bool + }{ + { + name: "unsupported method (PUT) stateful", + stateless: false, + method: "PUT", + wantAllow: "GET, POST, DELETE", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "GET without session stateful", + stateless: false, + method: "GET", + wantAllow: "GET, POST, DELETE", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "GET in stateless mode", + stateless: true, + method: "GET", + wantAllow: "POST", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "unsupported method (PATCH) stateless", + stateless: true, + method: "PATCH", + wantAllow: "GET, POST, DELETE", + wantStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &StreamableHTTPOptions{Stateless: tt.stateless} + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + req, err := http.NewRequest(tt.method, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("status code: got %d, want %d", got, tt.wantStatus) + } + + allow := resp.Header.Get("Allow") + if allow != tt.wantAllow { + t.Errorf("Allow header: got %q, want %q", allow, tt.wantAllow) + } + }) + } +} + func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") From 16ae536a93791dd301744cc996cfc6c5afce261f Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 21 Jan 2026 10:27:31 +0100 Subject: [PATCH 2/4] address review feedback: remove unused fields, avoid repetition - Remove unused withSession and wantStatus fields from streamable test - Use got, want pattern for status code checks to avoid repetition --- mcp/sse_test.go | 4 ++-- mcp/streamable_test.go | 50 +++++++++++++++++++----------------------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 2de4153d..8746cc8b 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -210,8 +210,8 @@ func TestSSE405AllowHeader(t *testing.T) { } defer resp.Body.Close() - if got := resp.StatusCode; got != http.StatusMethodNotAllowed { - t.Errorf("status code: got %d, want %d", got, http.StatusMethodNotAllowed) + if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + t.Errorf("status code: got %d, want %d", got, want) } allow := resp.Header.Get("Allow") diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f4604473..c2142fed 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1883,40 +1883,34 @@ func TestStreamable405AllowHeader(t *testing.T) { server := NewServer(testImpl, nil) tests := []struct { - name string - stateless bool - method string - wantAllow string - wantStatus int - withSession bool + name string + stateless bool + method string + wantAllow string }{ { - name: "unsupported method (PUT) stateful", - stateless: false, - method: "PUT", - wantAllow: "GET, POST, DELETE", - wantStatus: http.StatusMethodNotAllowed, + name: "unsupported method (PUT) stateful", + stateless: false, + method: "PUT", + wantAllow: "GET, POST, DELETE", }, { - name: "GET without session stateful", - stateless: false, - method: "GET", - wantAllow: "GET, POST, DELETE", - wantStatus: http.StatusMethodNotAllowed, + name: "GET without session stateful", + stateless: false, + method: "GET", + wantAllow: "GET, POST, DELETE", }, { - name: "GET in stateless mode", - stateless: true, - method: "GET", - wantAllow: "POST", - wantStatus: http.StatusMethodNotAllowed, + name: "GET in stateless mode", + stateless: true, + method: "GET", + wantAllow: "POST", }, { - name: "unsupported method (PATCH) stateless", - stateless: true, - method: "PATCH", - wantAllow: "GET, POST, DELETE", - wantStatus: http.StatusMethodNotAllowed, + name: "unsupported method (PATCH) stateless", + stateless: true, + method: "PATCH", + wantAllow: "GET, POST, DELETE", }, } @@ -1939,8 +1933,8 @@ func TestStreamable405AllowHeader(t *testing.T) { } defer resp.Body.Close() - if got := resp.StatusCode; got != tt.wantStatus { - t.Errorf("status code: got %d, want %d", got, tt.wantStatus) + if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + t.Errorf("status code: got %d, want %d", got, want) } allow := resp.Header.Get("Allow") From 3f789c173a28efd4bd1704402b1f7e7f53a13539 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 21 Jan 2026 13:05:55 +0100 Subject: [PATCH 3/4] address review: fix HTTP status codes per MCP spec Per MCP spec and reviewer feedback: - In stateful mode, GET without session now returns 400 Bad Request (like DELETE), not 405 - because GET IS a supported method, it just requires a session ID as a precondition. - In stateless mode, GET correctly returns 405 Method Not Allowed since the server doesn't offer SSE streaming. - Unsupported methods (PUT, PATCH, etc.) now return the correct Allow header based on mode: - Stateless: Allow: POST - Stateful: Allow: GET, POST, DELETE This aligns with MCP spec section 'Listening for Messages from the Server': 'The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint.' --- mcp/streamable.go | 20 ++++++++++++++------ mcp/streamable_test.go | 43 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 7a496e66..f2a28955 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -275,19 +275,27 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque switch req.Method { case http.MethodPost, http.MethodGet: if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { - // RFC 9110 §15.5.6: 405 responses MUST include Allow header. - // In stateless mode, only POST is allowed (no persistent sessions for GET/DELETE). if h.opts.Stateless { + // Per MCP spec: server MUST return 405 if it doesn't offer SSE stream. + // In stateless mode, GET (SSE streaming) is not supported. + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } else { - w.Header().Set("Allow", "GET, POST, DELETE") + // In stateful mode, GET is supported but requires a session ID. + // This is a precondition error, similar to DELETE without session. + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) } - http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed) return } default: - w.Header().Set("Allow", "GET, POST, DELETE") - http.Error(w, "Method Not Allowed: streamable MCP servers support GET, POST, and DELETE requests", http.StatusMethodNotAllowed) + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + if h.opts.Stateless { + w.Header().Set("Allow", "POST") + } else { + w.Header().Set("Allow", "GET, POST, DELETE") + } + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c2142fed..927452b4 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1829,7 +1829,9 @@ func TestStreamableGET(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + // GET without session should return 400 Bad Request (not 405) because + // GET is a valid method - it just requires a session ID. + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("initial GET: got status %d, want %d", got, want) } defer resp.Body.Close() @@ -1895,9 +1897,9 @@ func TestStreamable405AllowHeader(t *testing.T) { wantAllow: "GET, POST, DELETE", }, { - name: "GET without session stateful", + name: "unsupported method (PATCH) stateful", stateless: false, - method: "GET", + method: "PATCH", wantAllow: "GET, POST, DELETE", }, { @@ -1910,7 +1912,13 @@ func TestStreamable405AllowHeader(t *testing.T) { name: "unsupported method (PATCH) stateless", stateless: true, method: "PATCH", - wantAllow: "GET, POST, DELETE", + wantAllow: "POST", + }, + { + name: "unsupported method (PUT) stateless", + stateless: true, + method: "PUT", + wantAllow: "POST", }, } @@ -1945,6 +1953,33 @@ func TestStreamable405AllowHeader(t *testing.T) { } } +// TestStreamableGETWithoutSession verifies that GET without session ID in stateful mode +// returns 400 Bad Request (not 405), since GET is a supported method that requires a session. +func TestStreamableGETWithoutSession(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + req, err := http.NewRequest("GET", httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // GET without session should return 400 Bad Request, not 405 Method Not Allowed, + // because GET is a valid method - it just requires a session ID. + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { + t.Errorf("status code: got %d, want %d", got, want) + } +} + func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") From 89cbc86f30b6e842c712509163398e2103c49196 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 21 Jan 2026 16:37:45 +0100 Subject: [PATCH 4/4] simplify tests: remove redundant PUT/PATCH, add DELETE stateless case --- mcp/streamable_test.go | 54 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 927452b4..56c0a49b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1885,40 +1885,34 @@ func TestStreamable405AllowHeader(t *testing.T) { server := NewServer(testImpl, nil) tests := []struct { - name string - stateless bool - method string - wantAllow string + name string + stateless bool + method string + wantStatus int + wantAllow string }{ { - name: "unsupported method (PUT) stateful", - stateless: false, - method: "PUT", - wantAllow: "GET, POST, DELETE", + name: "unsupported method stateful", + stateless: false, + method: "PUT", + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "GET, POST, DELETE", }, { - name: "unsupported method (PATCH) stateful", - stateless: false, - method: "PATCH", - wantAllow: "GET, POST, DELETE", + name: "GET in stateless mode", + stateless: true, + method: "GET", + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "POST", }, { - name: "GET in stateless mode", - stateless: true, - method: "GET", - wantAllow: "POST", - }, - { - name: "unsupported method (PATCH) stateless", - stateless: true, - method: "PATCH", - wantAllow: "POST", - }, - { - name: "unsupported method (PUT) stateless", - stateless: true, - method: "PUT", - wantAllow: "POST", + // DELETE without session returns 400 Bad Request (not 405) + // because DELETE is a valid method, just requires a session ID. + name: "DELETE without session stateless", + stateless: true, + method: "DELETE", + wantStatus: http.StatusBadRequest, + wantAllow: "", // No Allow header for 400 responses }, } @@ -1941,8 +1935,8 @@ func TestStreamable405AllowHeader(t *testing.T) { } defer resp.Body.Close() - if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { - t.Errorf("status code: got %d, want %d", got, want) + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("status code: got %d, want %d", got, tt.wantStatus) } allow := resp.Header.Get("Allow")