Skip to content

Commit 52e03de

Browse files
mcp: add Allow header to 405 responses per RFC 9110 §15.5.6 (#757)
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: GET, POST` header for unsupported methods - **StreamableHTTPHandler**: Add Allow header for GET-without-session case: - Stateless mode: `Allow: POST, DELETE` (GET is never valid) - Stateful mode: `Allow: GET, POST, DELETE` (GET is valid once you have a session) - Add tests to verify Allow header presence in all 405 responses ## RFC 9110 Reference [Section 15.5.6 (405 Method Not Allowed)](https://httpwg.org/specs/rfc9110.html#status.405): > The origin server MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods. ## Testing All existing tests pass, plus new tests added: - `TestSSE405AllowHeader` - verifies SSE handler compliance - `TestStreamable405AllowHeader` - verifies Streamable handler compliance in both stateful and stateless modes --------- Co-authored-by: omgitsads <[email protected]>
1 parent c2c7edc commit 52e03de

File tree

4 files changed

+153
-5
lines changed

4 files changed

+153
-5
lines changed

mcp/sse.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
202202
}
203203

204204
if req.Method != http.MethodGet {
205-
http.Error(w, "invalid method", http.StatusMethodNotAllowed)
205+
w.Header().Set("Allow", "GET, POST")
206+
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
206207
return
207208
}
208209

mcp/sse_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,38 @@ func TestSSEClientTransport_HTTPErrors(t *testing.T) {
186186
})
187187
}
188188
}
189+
190+
// TestSSE405AllowHeader verifies RFC 9110 §15.5.6 compliance:
191+
// 405 Method Not Allowed responses MUST include an Allow header.
192+
func TestSSE405AllowHeader(t *testing.T) {
193+
server := NewServer(testImpl, nil)
194+
195+
handler := NewSSEHandler(func(req *http.Request) *Server { return server }, nil)
196+
httpServer := httptest.NewServer(handler)
197+
defer httpServer.Close()
198+
199+
methods := []string{"PUT", "PATCH", "DELETE", "OPTIONS"}
200+
for _, method := range methods {
201+
t.Run(method, func(t *testing.T) {
202+
req, err := http.NewRequest(method, httpServer.URL, nil)
203+
if err != nil {
204+
t.Fatal(err)
205+
}
206+
207+
resp, err := http.DefaultClient.Do(req)
208+
if err != nil {
209+
t.Fatal(err)
210+
}
211+
defer resp.Body.Close()
212+
213+
if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want {
214+
t.Errorf("status code: got %d, want %d", got, want)
215+
}
216+
217+
allow := resp.Header.Get("Allow")
218+
if allow != "GET, POST" {
219+
t.Errorf("Allow header: got %q, want %q", allow, "GET, POST")
220+
}
221+
})
222+
}
223+
}

mcp/streamable.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,27 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
275275
switch req.Method {
276276
case http.MethodPost, http.MethodGet:
277277
if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") {
278-
http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed)
278+
if h.opts.Stateless {
279+
// Per MCP spec: server MUST return 405 if it doesn't offer SSE stream.
280+
// In stateless mode, GET (SSE streaming) is not supported.
281+
// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
282+
w.Header().Set("Allow", "POST")
283+
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
284+
} else {
285+
// In stateful mode, GET is supported but requires a session ID.
286+
// This is a precondition error, similar to DELETE without session.
287+
http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest)
288+
}
279289
return
280290
}
281291
default:
282-
w.Header().Set("Allow", "GET, POST, DELETE")
283-
http.Error(w, "Method Not Allowed: streamable MCP servers support GET, POST, and DELETE requests", http.StatusMethodNotAllowed)
292+
// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
293+
if h.opts.Stateless {
294+
w.Header().Set("Allow", "POST")
295+
} else {
296+
w.Header().Set("Allow", "GET, POST, DELETE")
297+
}
298+
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
284299
return
285300
}
286301

mcp/streamable_test.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1829,7 +1829,9 @@ func TestStreamableGET(t *testing.T) {
18291829
if err != nil {
18301830
t.Fatal(err)
18311831
}
1832-
if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want {
1832+
// GET without session should return 400 Bad Request (not 405) because
1833+
// GET is a valid method - it just requires a session ID.
1834+
if got, want := resp.StatusCode, http.StatusBadRequest; got != want {
18331835
t.Errorf("initial GET: got status %d, want %d", got, want)
18341836
}
18351837
defer resp.Body.Close()
@@ -1877,6 +1879,101 @@ func TestStreamableGET(t *testing.T) {
18771879
}
18781880
}
18791881

1882+
// TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance:
1883+
// 405 Method Not Allowed responses MUST include an Allow header.
1884+
func TestStreamable405AllowHeader(t *testing.T) {
1885+
server := NewServer(testImpl, nil)
1886+
1887+
tests := []struct {
1888+
name string
1889+
stateless bool
1890+
method string
1891+
wantStatus int
1892+
wantAllow string
1893+
}{
1894+
{
1895+
name: "unsupported method stateful",
1896+
stateless: false,
1897+
method: "PUT",
1898+
wantStatus: http.StatusMethodNotAllowed,
1899+
wantAllow: "GET, POST, DELETE",
1900+
},
1901+
{
1902+
name: "GET in stateless mode",
1903+
stateless: true,
1904+
method: "GET",
1905+
wantStatus: http.StatusMethodNotAllowed,
1906+
wantAllow: "POST",
1907+
},
1908+
{
1909+
// DELETE without session returns 400 Bad Request (not 405)
1910+
// because DELETE is a valid method, just requires a session ID.
1911+
name: "DELETE without session stateless",
1912+
stateless: true,
1913+
method: "DELETE",
1914+
wantStatus: http.StatusBadRequest,
1915+
wantAllow: "", // No Allow header for 400 responses
1916+
},
1917+
}
1918+
1919+
for _, tt := range tests {
1920+
t.Run(tt.name, func(t *testing.T) {
1921+
opts := &StreamableHTTPOptions{Stateless: tt.stateless}
1922+
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts)
1923+
httpServer := httptest.NewServer(mustNotPanic(t, handler))
1924+
defer httpServer.Close()
1925+
1926+
req, err := http.NewRequest(tt.method, httpServer.URL, nil)
1927+
if err != nil {
1928+
t.Fatal(err)
1929+
}
1930+
req.Header.Set("Accept", "application/json, text/event-stream")
1931+
1932+
resp, err := http.DefaultClient.Do(req)
1933+
if err != nil {
1934+
t.Fatal(err)
1935+
}
1936+
defer resp.Body.Close()
1937+
1938+
if got := resp.StatusCode; got != tt.wantStatus {
1939+
t.Errorf("status code: got %d, want %d", got, tt.wantStatus)
1940+
}
1941+
1942+
allow := resp.Header.Get("Allow")
1943+
if allow != tt.wantAllow {
1944+
t.Errorf("Allow header: got %q, want %q", allow, tt.wantAllow)
1945+
}
1946+
})
1947+
}
1948+
}
1949+
1950+
// TestStreamableGETWithoutSession verifies that GET without session ID in stateful mode
1951+
// returns 400 Bad Request (not 405), since GET is a supported method that requires a session.
1952+
func TestStreamableGETWithoutSession(t *testing.T) {
1953+
server := NewServer(testImpl, nil)
1954+
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
1955+
httpServer := httptest.NewServer(mustNotPanic(t, handler))
1956+
defer httpServer.Close()
1957+
1958+
req, err := http.NewRequest("GET", httpServer.URL, nil)
1959+
if err != nil {
1960+
t.Fatal(err)
1961+
}
1962+
req.Header.Set("Accept", "text/event-stream")
1963+
1964+
resp, err := http.DefaultClient.Do(req)
1965+
if err != nil {
1966+
t.Fatal(err)
1967+
}
1968+
defer resp.Body.Close()
1969+
1970+
// GET without session should return 400 Bad Request, not 405 Method Not Allowed,
1971+
// because GET is a valid method - it just requires a session ID.
1972+
if got, want := resp.StatusCode, http.StatusBadRequest; got != want {
1973+
t.Errorf("status code: got %d, want %d", got, want)
1974+
}
1975+
}
1976+
18801977
func TestStreamableClientContextPropagation(t *testing.T) {
18811978
type contextKey string
18821979
const testKey = contextKey("test-key")

0 commit comments

Comments
 (0)