Skip to content

Commit 302c35b

Browse files
Fix Content-Type rejection for application/json; charset=utf-8
Add NormalizeContentType middleware that strips optional parameters (e.g. charset=utf-8) from application/json Content-Type headers before the request reaches the Go SDK's StreamableHTTP handler, which performs strict string matching. Per RFC 8259, the charset parameter is redundant for JSON but must be accepted per HTTP semantics. Fixes #2333 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 569a48d commit 302c35b

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

pkg/http/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func NewHTTPMcpHandler(
127127

128128
func (h *Handler) RegisterMiddleware(r chi.Router) {
129129
r.Use(
130+
middleware.NormalizeContentType,
130131
middleware.ExtractUserToken(h.oauthCfg),
131132
middleware.WithRequestConfig,
132133
middleware.WithMCPParse(),

pkg/http/handler_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httptest"
88
"slices"
99
"sort"
10+
"strings"
1011
"testing"
1112

1213
ghcontext "github.com/github/github-mcp-server/pkg/context"
@@ -631,6 +632,101 @@ func TestStaticConfigEnforcement(t *testing.T) {
631632
}
632633
}
633634

635+
// TestContentTypeHandling verifies that the MCP StreamableHTTP handler
636+
// accepts Content-Type values with additional parameters like charset=utf-8.
637+
// This is a regression test for https://github.com/github/github-mcp-server/issues/2333
638+
// where the Go SDK performs strict string matching against "application/json"
639+
// and rejects requests with "application/json; charset=utf-8".
640+
func TestContentTypeHandling(t *testing.T) {
641+
tests := []struct {
642+
name string
643+
contentType string
644+
expectUnsupportedMedia bool
645+
}{
646+
{
647+
name: "exact application/json is accepted",
648+
contentType: "application/json",
649+
expectUnsupportedMedia: false,
650+
},
651+
{
652+
name: "application/json with charset=utf-8 should be accepted",
653+
contentType: "application/json; charset=utf-8",
654+
expectUnsupportedMedia: false,
655+
},
656+
{
657+
name: "application/json with charset=UTF-8 should be accepted",
658+
contentType: "application/json; charset=UTF-8",
659+
expectUnsupportedMedia: false,
660+
},
661+
{
662+
name: "completely wrong content type is rejected",
663+
contentType: "text/plain",
664+
expectUnsupportedMedia: true,
665+
},
666+
{
667+
name: "empty content type is rejected",
668+
contentType: "",
669+
expectUnsupportedMedia: true,
670+
},
671+
}
672+
673+
for _, tt := range tests {
674+
t.Run(tt.name, func(t *testing.T) {
675+
// Create a minimal MCP server factory
676+
mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
677+
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
678+
}
679+
680+
// Create a simple inventory factory
681+
inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) {
682+
return inventory.NewBuilder().
683+
SetTools(testTools()).
684+
WithToolsets([]string{"all"}).
685+
Build()
686+
}
687+
688+
apiHost, err := utils.NewAPIHost("https://api.github.com")
689+
require.NoError(t, err)
690+
691+
handler := NewHTTPMcpHandler(
692+
context.Background(),
693+
&ServerConfig{Version: "test"},
694+
nil,
695+
translations.NullTranslationHelper,
696+
slog.Default(),
697+
apiHost,
698+
WithInventoryFactory(inventoryFactory),
699+
WithGitHubMCPServerFactory(mcpServerFactory),
700+
WithScopeFetcher(allScopesFetcher{}),
701+
)
702+
703+
r := chi.NewRouter()
704+
handler.RegisterMiddleware(r)
705+
handler.RegisterRoutes(r)
706+
707+
// Send an MCP initialize request as a POST with the given Content-Type
708+
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
709+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
710+
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
711+
req.Header.Set("Accept", "application/json, text/event-stream")
712+
if tt.contentType != "" {
713+
req.Header.Set(headers.ContentTypeHeader, tt.contentType)
714+
}
715+
716+
rr := httptest.NewRecorder()
717+
r.ServeHTTP(rr, req)
718+
719+
if tt.expectUnsupportedMedia {
720+
assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code,
721+
"expected 415 Unsupported Media Type for Content-Type: %q", tt.contentType)
722+
} else {
723+
assert.NotEqual(t, http.StatusUnsupportedMediaType, rr.Code,
724+
"should not get 415 for Content-Type: %q, got status %d", tt.contentType, rr.Code)
725+
}
726+
})
727+
}
728+
}
729+
634730
// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory
635731
// but uses the provided mock tools instead of calling github.AllTools.
636732
func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package middleware
2+
3+
import (
4+
"mime"
5+
"net/http"
6+
)
7+
8+
// NormalizeContentType is a middleware that normalizes the Content-Type header
9+
// by stripping optional parameters (e.g. charset=utf-8) when the media type
10+
// is "application/json". This works around strict Content-Type matching in
11+
// the Go MCP SDK's StreamableHTTP handler which rejects valid JSON media
12+
// types that include parameters.
13+
//
14+
// Per RFC 8259, JSON text exchanged between systems that are not part of a
15+
// closed ecosystem MUST be encoded using UTF-8, so the charset parameter is
16+
// redundant but MUST be accepted per HTTP semantics.
17+
//
18+
// See: https://github.com/github/github-mcp-server/issues/2333
19+
func NormalizeContentType(next http.Handler) http.Handler {
20+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
if ct := r.Header.Get("Content-Type"); ct != "" {
22+
mediaType, _, err := mime.ParseMediaType(ct)
23+
if err == nil && mediaType == "application/json" {
24+
r.Header.Set("Content-Type", "application/json")
25+
}
26+
}
27+
next.ServeHTTP(w, r)
28+
})
29+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestNormalizeContentType(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
inputCT string
15+
expectedCT string
16+
}{
17+
{
18+
name: "exact application/json unchanged",
19+
inputCT: "application/json",
20+
expectedCT: "application/json",
21+
},
22+
{
23+
name: "strips charset=utf-8",
24+
inputCT: "application/json; charset=utf-8",
25+
expectedCT: "application/json",
26+
},
27+
{
28+
name: "strips charset=UTF-8",
29+
inputCT: "application/json; charset=UTF-8",
30+
expectedCT: "application/json",
31+
},
32+
{
33+
name: "strips multiple parameters",
34+
inputCT: "application/json; charset=utf-8; boundary=something",
35+
expectedCT: "application/json",
36+
},
37+
{
38+
name: "non-json content type left unchanged",
39+
inputCT: "text/plain; charset=utf-8",
40+
expectedCT: "text/plain; charset=utf-8",
41+
},
42+
{
43+
name: "text/event-stream left unchanged",
44+
inputCT: "text/event-stream",
45+
expectedCT: "text/event-stream",
46+
},
47+
{
48+
name: "empty content type left unchanged",
49+
inputCT: "",
50+
expectedCT: "",
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
var capturedCT string
57+
inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
58+
capturedCT = r.Header.Get("Content-Type")
59+
})
60+
61+
handler := NormalizeContentType(inner)
62+
req := httptest.NewRequest(http.MethodPost, "/", nil)
63+
if tt.inputCT != "" {
64+
req.Header.Set("Content-Type", tt.inputCT)
65+
}
66+
67+
handler.ServeHTTP(httptest.NewRecorder(), req)
68+
69+
assert.Equal(t, tt.expectedCT, capturedCT)
70+
})
71+
}
72+
}

0 commit comments

Comments
 (0)