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 https://github.com/modelcontextprotocol/go-sdk/pull/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>
This commit is contained in:
parent
b37e497f0a
commit
35f1b07e48
3 changed files with 40 additions and 12 deletions
|
|
@ -264,12 +264,9 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Validate 'Content-Type' header.
|
// Validate 'Content-Type' header.
|
||||||
if req.Method == http.MethodPost {
|
if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" {
|
||||||
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
|
||||||
if err != nil || mediaType != "application/json" {
|
return
|
||||||
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,6 +557,14 @@ func streamableAccepts(values []string) (jsonOK, streamOK bool) {
|
||||||
return jsonOK, streamOK
|
return jsonOK, streamOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseMediaType(value string) string {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(value)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mediaType
|
||||||
|
}
|
||||||
|
|
||||||
// A StreamableServerTransport implements the server side of the MCP streamable
|
// A StreamableServerTransport implements the server side of the MCP streamable
|
||||||
// transport.
|
// transport.
|
||||||
//
|
//
|
||||||
|
|
@ -1671,7 +1676,7 @@ func (c *streamableClientConn) connectStandaloneSSE() {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if resp.Header.Get("Content-Type") != "text/event-stream" {
|
if baseMediaType(resp.Header.Get("Content-Type")) != "text/event-stream" {
|
||||||
// modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with
|
// modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with
|
||||||
// non-SSE content type instead of text/event-stream for the standalone
|
// non-SSE content type instead of text/event-stream for the standalone
|
||||||
// SSE stream.
|
// SSE stream.
|
||||||
|
|
@ -1855,7 +1860,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
|
contentType := baseMediaType(resp.Header.Get("Content-Type"))
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case "application/json":
|
case "application/json":
|
||||||
go c.handleJSON(requestSummary, resp)
|
go c.handleJSON(requestSummary, resp)
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
|
||||||
contentType string
|
contentType string
|
||||||
}{
|
}{
|
||||||
{http.StatusOK, "", "text/event-stream"},
|
{http.StatusOK, "", "text/event-stream"},
|
||||||
|
{http.StatusOK, "", "text/event-stream; charset=utf-8"},
|
||||||
{http.StatusMethodNotAllowed, "", "text/event-stream"},
|
{http.StatusMethodNotAllowed, "", "text/event-stream"},
|
||||||
//// The client error status code is not treated as an error in non-strict
|
//// The client error status code is not treated as an error in non-strict
|
||||||
//// mode.
|
//// mode.
|
||||||
|
|
@ -274,7 +275,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("status=%d", test.status), func(t *testing.T) {
|
t.Run(fmt.Sprintf("status=%d content_type=%q", test.status, test.contentType), func(t *testing.T) {
|
||||||
fake := &fakeStreamableServer{
|
fake := &fakeStreamableServer{
|
||||||
t: t,
|
t: t,
|
||||||
responses: fakeResponses{
|
responses: fakeResponses{
|
||||||
|
|
|
||||||
|
|
@ -1506,9 +1506,9 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
|
||||||
|
|
||||||
newSessionID := resp.Header.Get(sessionIDHeader)
|
newSessionID := resp.Header.Get(sessionIDHeader)
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := baseMediaType(resp.Header.Get("Content-Type"))
|
||||||
var respBody []byte
|
var respBody []byte
|
||||||
if strings.HasPrefix(contentType, "text/event-stream") {
|
if contentType == "text/event-stream" {
|
||||||
r := readerInto{resp.Body, new(bytes.Buffer)}
|
r := readerInto{resp.Body, new(bytes.Buffer)}
|
||||||
for evt, err := range scanEvents(r) {
|
for evt, err := range scanEvents(r) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1525,7 +1525,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
respBody = r.w.Bytes()
|
respBody = r.w.Bytes()
|
||||||
} else if strings.HasPrefix(contentType, "application/json") {
|
} else if contentType == "application/json" {
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err)
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err)
|
||||||
|
|
@ -2047,6 +2047,28 @@ func TestStreamableGETWithoutEventStreamAccept(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBaseMediaType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", want: ""},
|
||||||
|
{name: "json", value: "application/json", want: "application/json"},
|
||||||
|
{name: "json with params", value: "Application/JSON; charset=utf-8", want: "application/json"},
|
||||||
|
{name: "event stream with params", value: "Text/Event-Stream; charset=utf-8", want: "text/event-stream"},
|
||||||
|
{name: "invalid", value: "application/json; charset", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := baseMediaType(test.value); got != test.want {
|
||||||
|
t.Errorf("baseMediaType(%q) = %q, want %q", test.value, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamableClientContextPropagation(t *testing.T) {
|
func TestStreamableClientContextPropagation(t *testing.T) {
|
||||||
type contextKey string
|
type contextKey string
|
||||||
const testKey = contextKey("test-key")
|
const testKey = contextKey("test-key")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue