Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mcp/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions mcp/sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
}
7 changes: 7 additions & 0 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
74 changes: 74 additions & 0 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down