From 9d5d7cb2a20f204d2dcb7c11af5b6edcc9083ece Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 10 Mar 2026 09:20:04 +0000 Subject: [PATCH 1/4] mcp: add strict validation of "Content-Type" header. --- mcp/streamable.go | 9 +++++++++ mcp/streamable_test.go | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 0b11eff0..4ae88ad7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -238,6 +238,15 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } + // 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 + } + } + // Allow multiple 'Accept' headers. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",") diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 7a504a8a..6714a9c3 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -832,6 +832,36 @@ func TestStreamableServerTransport(t *testing.T) { }, wantSessions: 0, }, + { + name: "content type headers", + requests: []streamableRequest{ + initialize, + initialized, + { + // Request with incorrect Content-Type should be rejected. + method: "POST", + headers: http.Header{"Content-Type": {"text/plain"}}, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusUnsupportedMediaType, + }, + { + // Request with empty Content-Type should be rejected. + method: "POST", + headers: http.Header{"Content-Type": {""}}, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusUnsupportedMediaType, + }, + { + // Correct Content-Type should pass. + method: "POST", + headers: http.Header{"Content-Type": {"application/json"}}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, + }, + }, + wantSessions: 1, + }, { name: "accept headers", requests: []streamableRequest{ @@ -1409,10 +1439,16 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, if sessionID != "" { req.Header.Set(sessionIDHeader, sessionID) } - req.Header.Set("Content-Type", "application/json") + if s.method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } req.Header.Set("Accept", "application/json, text/event-stream") maps.Copy(req.Header, s.headers) + if req.Header.Get("Content-Type") == "" { + req.Header.Del("Content-Type") + } + resp, err := http.DefaultClient.Do(req) if err != nil { return "", 0, nil, fmt.Errorf("request failed: %v", err) From e58a1ab1953edac550f58fd2ad140738b9fc25d4 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 10 Mar 2026 14:49:16 +0000 Subject: [PATCH 2/4] Verify the 'Origin' header. --- go.mod | 2 +- mcp/streamable.go | 27 ++++++++++++++-- mcp/streamable_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f3f327b4..a6aae2b8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/modelcontextprotocol/go-sdk -go 1.24.0 +go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/mcp/streamable.go b/mcp/streamable.go index 4ae88ad7..f6adb4b8 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -174,6 +174,12 @@ type StreamableHTTPOptions 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. + CrossOriginProtection *http.CrossOriginProtection } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -190,8 +196,10 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts = *opts } - if h.opts.Logger == nil { // ensure we have a logger - h.opts.Logger = ensureLogger(nil) + h.opts.Logger = ensureLogger(h.opts.Logger) + + if h.opts.CrossOriginProtection == nil { + h.opts.CrossOriginProtection = &http.CrossOriginProtection{} } return h @@ -226,6 +234,12 @@ func (h *StreamableHTTPHandler) closeAll() { // The option will be removed in the 1.6.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") +// disableoriginverification is a compatibility parameter that allows to disable +// the verification of the 'Origin' header, which was added in the 1.5.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. +var disableoriginverification = mcpgodebug.Value("disableoriginverification") + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise @@ -238,6 +252,15 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } + // Verify the 'Origin' header to protect against CSRF attacks. + if disableoriginverification != "1" { + 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 { contentType := req.Header.Get("Content-Type") diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6714a9c3..f688a908 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2472,3 +2472,76 @@ func TestStreamableLocalhostProtection(t *testing.T) { }) } } + +func TestStreamableOriginProtection(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.StatusOK, + }, + { + 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 := &StreamableHTTPOptions{ + Stateless: true, // avoid session ID requirement + CrossOriginProtection: tt.protection, + } + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, reqReader) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", tt.requestOrigin) + 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.wantStatusCode { + t.Errorf("Status code: got %d, want %d", got, tt.wantStatusCode) + } + }) + } +} From 9a3ae3cadd988029a8c3259955e7f834a1589c29 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Thu, 12 Mar 2026 15:44:41 +0000 Subject: [PATCH 3/4] Address review feedback. --- docs/README.md | 5 ++++ docs/mcpgodebug.md | 41 +++++++++++++++++++++++++++++++++ internal/docs/README.src.md | 5 ++++ internal/docs/doc.go | 1 + internal/docs/mcpgodebug.src.md | 40 ++++++++++++++++++++++++++++++++ mcp/streamable.go | 33 +++++++++++++------------- 6 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 docs/mcpgodebug.md create mode 100644 internal/docs/mcpgodebug.src.md diff --git a/docs/README.md b/docs/README.md index 0e8259c0..e155e49e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,11 @@ protocol. See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. +# Backwards compatibility + +See [mcpgodebug.md](mcpgodebug.md) for a list of backwards incompatible behavior changes +and description how they can be temporarily undone. + # Rough edges See [rough_edges.md](rough_edges.md) for a list of rough edges or API diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md new file mode 100644 index 00000000..f53ef6d3 --- /dev/null +++ b/docs/mcpgodebug.md @@ -0,0 +1,41 @@ + + # Backwards compatibility and MCPGODEBUG + + According to our compatibility promise, we can't break backward compatibility + of the SDK API. However, sometimes we need to change the behavior of the SDK + in a backward-incompatible way in order to fix bugs or security issues. + In those cases we introduce temporary compatibility parameters, that can be + used to opt-out of the new behavior. They are usually maintained for two + minor release cycles and then removed. + + The compatibility parameters are provided via the `MCPGODEBUG` environment + variable. The value of the variable is a comma-separated list of parameter + value assignments, e.g.: + + ``` + MCPGODEBUG=parameter1=value1,parameter2=value2 + ``` + +## `MCPGODEBUG` history + +### 1.4.1 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin + protection will be disabled. The default behavior was changed to enable + cross-origin protection. + +### 1.4.0 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `jsonescaping` added. If set to `1`, JSON marshaling will preserve the previous + behavior of escaping HTML characters in JSON strings. The default behavior + was changed to not escape HTML characters, to be consistent with other SDKs. + +- `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding + protection will be disabled. The default behavior was changed to enable DNS rebinding + protection. The protection can also be disabled by setting the + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to + `true`, which is the recommended way to disable the protection long term. diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index e70596b8..16d93db3 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -38,6 +38,11 @@ protocol. See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. +# Backwards compatibility + +See [mcpgodebug.md](mcpgodebug.md) for a list of backwards incompatible behavior changes +and description how they can be temporarily undone. + # Rough edges See [rough_edges.md](rough_edges.md) for a list of rough edges or API diff --git a/internal/docs/doc.go b/internal/docs/doc.go index 3d87686c..ede0d42a 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -9,6 +9,7 @@ //go:generate weave -o ../../docs/server.md ./server.src.md //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md //go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md +//go:generate weave -o ../../docs/mcpgodebug.md ./mcpgodebug.src.md // The doc package generates the documentation at /doc, via go:generate. // diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md new file mode 100644 index 00000000..60b321b1 --- /dev/null +++ b/internal/docs/mcpgodebug.src.md @@ -0,0 +1,40 @@ + # Backwards compatibility and MCPGODEBUG + + According to our compatibility promise, we can't break backward compatibility + of the SDK API. However, sometimes we need to change the behavior of the SDK + in a backward-incompatible way in order to fix bugs or security issues. + In those cases we introduce temporary compatibility parameters, that can be + used to opt-out of the new behavior. They are usually maintained for two + minor release cycles and then removed. + + The compatibility parameters are provided via the `MCPGODEBUG` environment + variable. The value of the variable is a comma-separated list of parameter + value assignments, e.g.: + + ``` + MCPGODEBUG=parameter1=value1,parameter2=value2 + ``` + +## `MCPGODEBUG` history + +### 1.4.1 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin + protection will be disabled. The default behavior was changed to enable + cross-origin protection. + +### 1.4.0 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `jsonescaping` added. If set to `1`, JSON marshaling will preserve the previous + behavior of escaping HTML characters in JSON strings. The default behavior + was changed to not escape HTML characters, to be consistent with other SDKs. + +- `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding + protection will be disabled. The default behavior was changed to enable DNS rebinding + protection. The protection can also be disabled by setting the + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to + `true`, which is the recommended way to disable the protection long term. \ No newline at end of file diff --git a/mcp/streamable.go b/mcp/streamable.go index f6adb4b8..16bca070 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -179,6 +179,8 @@ type StreamableHTTPOptions struct { // 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.6.0. CrossOriginProtection *http.CrossOriginProtection } @@ -234,11 +236,12 @@ func (h *StreamableHTTPHandler) closeAll() { // The option will be removed in the 1.6.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") -// disableoriginverification is a compatibility parameter that allows to disable -// the verification of the 'Origin' header, which was added in the 1.5.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. -var disableoriginverification = mcpgodebug.Value("disableoriginverification") +// 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.6.0 version of the SDK. +var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection") func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. @@ -252,21 +255,19 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } - // Verify the 'Origin' header to protect against CSRF attacks. - if disableoriginverification != "1" { + 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) 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 + // 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 + } } } From bb02c8b3eef114db1e9135625cec01594eaf2d3f Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Fri, 13 Mar 2026 09:59:42 +0000 Subject: [PATCH 4/4] Adjust github action workflows. --- .github/workflows/codeql.yml | 3 +++ .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 ++ .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 ++++-- docs/protocol.md | 2 +- internal/docs/protocol.src.md | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30b4c575..8d8dfb3b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,9 @@ on: schedule: - cron: '31 9 * * 4' +# Declare default permissions as read only. +permissions: read-all + jobs: analyze: name: Analyze (${{ matrix.language }}) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 17bdf7ef..c33a0f98 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: Start everything-server run: | go run ./conformance/everything-server/main.go -http=":3001" & @@ -45,7 +45,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: "Run conformance tests" uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 9b9e93f2..ee6f5968 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: "^1.26" - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check docs are up-to-date diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e10d2aa7..098a9a98 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0344d35d..a6824b85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: ["1.24", "1.25"] + go: ["1.25", "1.26"] steps: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -52,6 +52,8 @@ jobs: run: go test -v ./... race-test: + # Temporarily disable until fixes are prepared. + if: false runs-on: ubuntu-latest steps: - name: Check out code @@ -59,6 +61,6 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "1.24" + go-version: "1.25" - name: Test with -race run: go test -v -race ./... diff --git a/docs/protocol.md b/docs/protocol.md index aeff5e97..f316e3f8 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -360,7 +360,7 @@ and step-up authentication (when the server returns `insufficient_scope` error). ## Security Here we discuss the mitigations described under -the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. +the MCP's [Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices) section, and how we handle them. ### Confused Deputy diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 5134a181..98078619 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -285,7 +285,7 @@ and step-up authentication (when the server returns `insufficient_scope` error). ## Security Here we discuss the mitigations described under -the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. +the MCP's [Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices) section, and how we handle them. ### Confused Deputy