Skip to content

Commit 35f1b07

Browse files
mcp: accept parameterized Content-Type types (#890)
Use a shared helper for Content-Type parsing in streamable transport request validation and client response handling. Follow up to #853, where we did this for `Accept` headers. This allows the streamable mcp server to validate the following header `Content-Type: application/json;charset=utf-8` Co-authored-by: Maciej Kisiel <mkisiel@google.com>
1 parent b37e497 commit 35f1b07

3 files changed

Lines changed: 40 additions & 12 deletions

File tree

mcp/streamable.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,9 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
264264
return
265265
}
266266
// Validate 'Content-Type' header.
267-
if req.Method == http.MethodPost {
268-
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
269-
if err != nil || mediaType != "application/json" {
270-
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
271-
return
272-
}
267+
if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" {
268+
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
269+
return
273270
}
274271
}
275272

@@ -560,6 +557,14 @@ func streamableAccepts(values []string) (jsonOK, streamOK bool) {
560557
return jsonOK, streamOK
561558
}
562559

560+
func baseMediaType(value string) string {
561+
mediaType, _, err := mime.ParseMediaType(value)
562+
if err != nil {
563+
return ""
564+
}
565+
return mediaType
566+
}
567+
563568
// A StreamableServerTransport implements the server side of the MCP streamable
564569
// transport.
565570
//
@@ -1671,7 +1676,7 @@ func (c *streamableClientConn) connectStandaloneSSE() {
16711676
resp.Body.Close()
16721677
return
16731678
}
1674-
if resp.Header.Get("Content-Type") != "text/event-stream" {
1679+
if baseMediaType(resp.Header.Get("Content-Type")) != "text/event-stream" {
16751680
// modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with
16761681
// non-SSE content type instead of text/event-stream for the standalone
16771682
// SSE stream.
@@ -1855,7 +1860,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
18551860
return nil
18561861
}
18571862

1858-
contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
1863+
contentType := baseMediaType(resp.Header.Get("Content-Type"))
18591864
switch contentType {
18601865
case "application/json":
18611866
go c.handleJSON(requestSummary, resp)

mcp/streamable_client_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
264264
contentType string
265265
}{
266266
{http.StatusOK, "", "text/event-stream"},
267+
{http.StatusOK, "", "text/event-stream; charset=utf-8"},
267268
{http.StatusMethodNotAllowed, "", "text/event-stream"},
268269
//// The client error status code is not treated as an error in non-strict
269270
//// mode.
@@ -274,7 +275,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
274275
}
275276

276277
for _, test := range tests {
277-
t.Run(fmt.Sprintf("status=%d", test.status), func(t *testing.T) {
278+
t.Run(fmt.Sprintf("status=%d content_type=%q", test.status, test.contentType), func(t *testing.T) {
278279
fake := &fakeStreamableServer{
279280
t: t,
280281
responses: fakeResponses{

mcp/streamable_test.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,9 +1506,9 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
15061506

15071507
newSessionID := resp.Header.Get(sessionIDHeader)
15081508

1509-
contentType := resp.Header.Get("Content-Type")
1509+
contentType := baseMediaType(resp.Header.Get("Content-Type"))
15101510
var respBody []byte
1511-
if strings.HasPrefix(contentType, "text/event-stream") {
1511+
if contentType == "text/event-stream" {
15121512
r := readerInto{resp.Body, new(bytes.Buffer)}
15131513
for evt, err := range scanEvents(r) {
15141514
if err != nil {
@@ -1525,7 +1525,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
15251525
}
15261526
}
15271527
respBody = r.w.Bytes()
1528-
} else if strings.HasPrefix(contentType, "application/json") {
1528+
} else if contentType == "application/json" {
15291529
data, err := io.ReadAll(resp.Body)
15301530
if err != nil {
15311531
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err)
@@ -2047,6 +2047,28 @@ func TestStreamableGETWithoutEventStreamAccept(t *testing.T) {
20472047
}
20482048
}
20492049

2050+
func TestBaseMediaType(t *testing.T) {
2051+
tests := []struct {
2052+
name string
2053+
value string
2054+
want string
2055+
}{
2056+
{name: "empty", want: ""},
2057+
{name: "json", value: "application/json", want: "application/json"},
2058+
{name: "json with params", value: "Application/JSON; charset=utf-8", want: "application/json"},
2059+
{name: "event stream with params", value: "Text/Event-Stream; charset=utf-8", want: "text/event-stream"},
2060+
{name: "invalid", value: "application/json; charset", want: ""},
2061+
}
2062+
2063+
for _, test := range tests {
2064+
t.Run(test.name, func(t *testing.T) {
2065+
if got := baseMediaType(test.value); got != test.want {
2066+
t.Errorf("baseMediaType(%q) = %q, want %q", test.value, got, test.want)
2067+
}
2068+
})
2069+
}
2070+
}
2071+
20502072
func TestStreamableClientContextPropagation(t *testing.T) {
20512073
type contextKey string
20522074
const testKey = contextKey("test-key")

0 commit comments

Comments
 (0)