Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions docs/rough_edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ v2.
wrapper) we need to first unmarshal into a `map[string]any` in order to do
server-side validation of required fields. CallToolParams could have just had
a map[string]any.

- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of
the SDK API. Cross-origin protection is a general HTTP concern, not specific
to MCP, and can be applied as standard HTTP middleware.
4 changes: 4 additions & 0 deletions internal/docs/rough_edges.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ v2.
wrapper) we need to first unmarshal into a `map[string]any` in order to do
server-side validation of required fields. CallToolParams could have just had
a map[string]any.

- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of
the SDK API. Cross-origin protection is a general HTTP concern, not specific
to MCP, and can be applied as standard HTTP middleware.
30 changes: 6 additions & 24 deletions mcp/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"crypto/rand"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
Expand Down Expand Up @@ -64,14 +65,6 @@ type SSEOptions struct {
// Only disable this if you understand the security implications.
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
DisableLocalhostProtection bool

// CrossOriginProtection allows to customize cross-origin protection.
// The deny handler set in the CrossOriginProtection through SetDenyHandler
// is ignored.
// If nil, default (zero-value) cross-origin protection will be used.
// Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter
// to disable the default protection until v1.7.0.
CrossOriginProtection *http.CrossOriginProtection
}

// NewSSEHandler returns a new [SSEHandler] that creates and manages MCP
Expand All @@ -97,10 +90,6 @@ func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptio
s.opts = *opts
}

if s.opts.CrossOriginProtection == nil {
s.opts.CrossOriginProtection = &http.CrossOriginProtection{}
}

return s
}

Expand Down Expand Up @@ -212,20 +201,13 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
}

if disablecrossoriginprotection != "1" {
// Verify the 'Origin' header to protect against CSRF attacks.
if err := h.opts.CrossOriginProtection.Check(req); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
// Validate 'Content-Type' header.
if req.Method == http.MethodPost {
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
return
}
// Validate 'Content-Type' header.
if req.Method == http.MethodPost {
contentType := req.Header.Get("Content-Type")
if contentType != "application/json" {
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
return
}
}
}

sessionID := req.URL.Query().Get("sessionid")
Expand Down
75 changes: 0 additions & 75 deletions mcp/sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

Expand Down Expand Up @@ -320,77 +319,3 @@ func TestSSELocalhostProtection(t *testing.T) {
})
}
}

func TestSSEOriginProtection(t *testing.T) {
server := NewServer(testImpl, nil)

tests := []struct {
name string
protection *http.CrossOriginProtection
requestOrigin string
wantStatusCode int
}{
{
name: "default protection with Origin header",
protection: nil,
requestOrigin: "https://example.com",
wantStatusCode: http.StatusForbidden,
},
{
name: "custom protection with trusted origin and same Origin",
protection: func() *http.CrossOriginProtection {
p := http.NewCrossOriginProtection()
if err := p.AddTrustedOrigin("https://example.com"); err != nil {
t.Fatal(err)
}
return p
}(),
requestOrigin: "https://example.com",
wantStatusCode: http.StatusNotFound, // origin accepted; session not found
},
{
name: "custom protection with trusted origin and different Origin",
protection: func() *http.CrossOriginProtection {
p := http.NewCrossOriginProtection()
if err := p.AddTrustedOrigin("https://example.com"); err != nil {
t.Fatal(err)
}
return p
}(),
requestOrigin: "https://malicious.com",
wantStatusCode: http.StatusForbidden,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &SSEOptions{
CrossOriginProtection: tt.protection,
}
handler := NewSSEHandler(func(req *http.Request) *Server { return server }, opts)
httpServer := httptest.NewServer(handler)
defer httpServer.Close()

// Use POST with a valid session-like URL to test origin protection
// without creating a hanging GET connection.
reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"?sessionid=nonexistent", reqReader)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", tt.requestOrigin)

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()

if got := resp.StatusCode; got != tt.wantStatusCode {
body, _ := io.ReadAll(resp.Body)
t.Errorf("Status code: got %d, want %d (body: %s)", got, tt.wantStatusCode, body)
}
})
}
}
42 changes: 25 additions & 17 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,15 @@ type StreamableHTTPOptions struct {
// CrossOriginProtection allows to customize cross-origin protection.
// The deny handler set in the CrossOriginProtection through SetDenyHandler
// is ignored.
// If nil, default (zero-value) cross-origin protection will be used.
// Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter
// to disable the default protection until v1.7.0.
// If nil, no cross-origin protection is applied. Use the `enableoriginverification`
// MCPGODEBUG compatibility parameter to enable the default protection until v1.8.0.
//
// Deprecated: wrap the handler with cross-origin protection middleware
// instead. For example:
//
// handler := mcp.NewStreamableHTTPHandler(...)
// protection := http.NewCrossOriginProtection()
// protectedHandler := protection.Handler(handler)
CrossOriginProtection *http.CrossOriginProtection
}

Expand All @@ -202,7 +208,7 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea

h.opts.Logger = ensureLogger(h.opts.Logger)

if h.opts.CrossOriginProtection == nil {
if h.opts.CrossOriginProtection == nil && enableoriginverification == "1" {
h.opts.CrossOriginProtection = &http.CrossOriginProtection{}
}

Expand Down Expand Up @@ -235,15 +241,16 @@ func (h *StreamableHTTPHandler) closeAll() {
// disablelocalhostprotection is a compatibility parameter that allows to disable
// DNS rebinding protection, which was added in the 1.4.0 version of the SDK.
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in the 1.7.0 version of the SDK.
// The option will be removed in the 1.6.0 version of the SDK.
var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")

// disablecrossoriginprotection is a compatibility parameter that allows to disable
// the verification of the 'Origin' and 'Content-Type' headers, which was added in
// the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package
// for instructions how to enable it.
// The option will be removed in the 1.7.0 version of the SDK.
var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection")
// enableoriginverification is a compatibility parameter that restores the
// default cross-origin protection behavior from v1.4.1-v1.5.0. When set to
// "1", a zero-value CrossOriginProtection will be applied if none is
// explicitly provided in StreamableHTTPOptions.
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in the 1.8.0 version of the SDK.
var enableoriginverification = mcpgodebug.Value("enableoriginverification")

func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// DNS rebinding protection: auto-enabled for localhost servers.
Expand All @@ -257,17 +264,18 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
}
}

if disablecrossoriginprotection != "1" {
if h.opts.CrossOriginProtection != nil {
// Verify the 'Origin' header to protect against CSRF attacks.
if err := h.opts.CrossOriginProtection.Check(req); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
// Validate 'Content-Type' header.
if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" {
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
return
}
}

// Validate 'Content-Type' header.
if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" {
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
return
}

// Allow multiple 'Accept' headers.
Expand Down
4 changes: 2 additions & 2 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2587,10 +2587,10 @@ func TestStreamableOriginProtection(t *testing.T) {
wantStatusCode int
}{
{
name: "default protection with Origin header",
name: "no protection with Origin header",
protection: nil,
requestOrigin: "https://example.com",
wantStatusCode: http.StatusForbidden,
wantStatusCode: http.StatusOK,
},
{
name: "custom protection with trusted origin and same Origin",
Expand Down
Loading