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
1 change: 1 addition & 0 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func NewHTTPMcpHandler(

func (h *Handler) RegisterMiddleware(r chi.Router) {
r.Use(
middleware.NormalizeContentType,
middleware.ExtractUserToken(h.oauthCfg),
middleware.WithRequestConfig,
middleware.WithMCPParse(),
Expand Down
96 changes: 96 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http/httptest"
"slices"
"sort"
"strings"
"testing"

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

// TestContentTypeHandling verifies that the MCP StreamableHTTP handler
// accepts Content-Type values with additional parameters like charset=utf-8.
// This is a regression test for https://github.com/github/github-mcp-server/issues/2333
// where the Go SDK performs strict string matching against "application/json"
// and rejects requests with "application/json; charset=utf-8".
func TestContentTypeHandling(t *testing.T) {
tests := []struct {
name string
contentType string
expectUnsupportedMedia bool
}{
{
name: "exact application/json is accepted",
contentType: "application/json",
expectUnsupportedMedia: false,
},
{
name: "application/json with charset=utf-8 should be accepted",
contentType: "application/json; charset=utf-8",
expectUnsupportedMedia: false,
},
{
name: "application/json with charset=UTF-8 should be accepted",
contentType: "application/json; charset=UTF-8",
expectUnsupportedMedia: false,
},
{
name: "completely wrong content type is rejected",
contentType: "text/plain",
expectUnsupportedMedia: true,
},
{
name: "empty content type is rejected",
contentType: "",
expectUnsupportedMedia: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a minimal MCP server factory
mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
}

// Create a simple inventory factory
inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) {
return inventory.NewBuilder().
SetTools(testTools()).
WithToolsets([]string{"all"}).
Build()
}

apiHost, err := utils.NewAPIHost("https://api.github.com")
require.NoError(t, err)

handler := NewHTTPMcpHandler(
context.Background(),
&ServerConfig{Version: "test"},
nil,
translations.NullTranslationHelper,
slog.Default(),
apiHost,
WithInventoryFactory(inventoryFactory),
WithGitHubMCPServerFactory(mcpServerFactory),
WithScopeFetcher(allScopesFetcher{}),
)

r := chi.NewRouter()
handler.RegisterMiddleware(r)
handler.RegisterRoutes(r)

// Send an MCP initialize request as a POST with the given Content-Type
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
req.Header.Set("Accept", "application/json, text/event-stream")
Comment thread
omgitsads marked this conversation as resolved.
Outdated
if tt.contentType != "" {
req.Header.Set(headers.ContentTypeHeader, tt.contentType)
}

rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)

if tt.expectUnsupportedMedia {
assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code,
"expected 415 Unsupported Media Type for Content-Type: %q", tt.contentType)
} else {
assert.NotEqual(t, http.StatusUnsupportedMediaType, rr.Code,
"should not get 415 for Content-Type: %q, got status %d", tt.contentType, rr.Code)
}
})
}
}

// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory
// but uses the provided mock tools instead of calling github.AllTools.
func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
Expand Down
29 changes: 29 additions & 0 deletions pkg/http/middleware/content_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package middleware

import (
"mime"
"net/http"
)

// NormalizeContentType is a middleware that normalizes the Content-Type header
// by stripping optional parameters (e.g. charset=utf-8) when the media type
// is "application/json". This works around strict Content-Type matching in
// the Go MCP SDK's StreamableHTTP handler which rejects valid JSON media
// types that include parameters.
//
// Per RFC 8259, JSON text exchanged between systems that are not part of a
// closed ecosystem MUST be encoded using UTF-8, so the charset parameter is
// redundant but MUST be accepted per HTTP semantics.
//
// See: https://github.com/github/github-mcp-server/issues/2333
func NormalizeContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "" {
mediaType, _, err := mime.ParseMediaType(ct)
if err == nil && mediaType == "application/json" {
r.Header.Set("Content-Type", "application/json")
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NormalizeContentType hard-codes both the header name and the JSON media type string. Since pkg/http/headers already defines ContentTypeHeader and ContentTypeJSON constants, using them here would avoid duplication/typos and keep header handling consistent across the HTTP stack.

Copilot uses AI. Check for mistakes.
}
next.ServeHTTP(w, r)
})
}
72 changes: 72 additions & 0 deletions pkg/http/middleware/content_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestNormalizeContentType(t *testing.T) {
tests := []struct {
name string
inputCT string
expectedCT string
}{
{
name: "exact application/json unchanged",
inputCT: "application/json",
expectedCT: "application/json",
},
{
name: "strips charset=utf-8",
inputCT: "application/json; charset=utf-8",
expectedCT: "application/json",
},
{
name: "strips charset=UTF-8",
inputCT: "application/json; charset=UTF-8",
expectedCT: "application/json",
},
{
name: "strips multiple parameters",
inputCT: "application/json; charset=utf-8; boundary=something",
expectedCT: "application/json",
},
{
name: "non-json content type left unchanged",
inputCT: "text/plain; charset=utf-8",
expectedCT: "text/plain; charset=utf-8",
},
{
name: "text/event-stream left unchanged",
inputCT: "text/event-stream",
expectedCT: "text/event-stream",
},
{
name: "empty content type left unchanged",
inputCT: "",
expectedCT: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedCT string
inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedCT = r.Header.Get("Content-Type")
})

handler := NormalizeContentType(inner)
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tt.inputCT != "" {
req.Header.Set("Content-Type", tt.inputCT)
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses raw string literals for the Content-Type header name and expected JSON content type. Consider switching to pkg/http/headers.ContentTypeHeader and headers.ContentTypeJSON to keep tests aligned with the shared header constants and reduce the chance of typos if values ever change.

Copilot uses AI. Check for mistakes.

handler.ServeHTTP(httptest.NewRecorder(), req)

assert.Equal(t, tt.expectedCT, capturedCT)
})
}
}
Loading