Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
25 changes: 25 additions & 0 deletions internal/mcpproxy/content_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package mcpproxy

import (
"mime"
"net/http"
"strings"
)

func hasMediaType(header http.Header, want string) bool {
got, _, err := mime.ParseMediaType(header.Get("Content-Type"))
if err == nil {
Comment on lines +13 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Can we add this helper function inside the handler file itself?

return got == want
}

raw := header.Get("Content-Type")
if i := strings.IndexByte(raw, ';'); i >= 0 {
raw = raw[:i]
}
return strings.TrimSpace(raw) == want
}
64 changes: 64 additions & 0 deletions internal/mcpproxy/content_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package mcpproxy

import (
"net/http"
"testing"

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

func TestHasMediaType(t *testing.T) {
t.Parallel()

tests := []struct {
name string
contentType string
want string
match bool
}{
{
name: "exact match",
contentType: "application/json",
want: "application/json",
match: true,
},
{
name: "match with parameters",
contentType: "application/json; charset=utf-8",
want: "application/json",
match: true,
},
{
name: "fallback on malformed parameters",
contentType: "application/json; charset==utf-8",
want: "application/json",
match: true,
},
{
name: "fallback trims whitespace",
contentType: " text/event-stream ; charset==utf-8",
want: "text/event-stream",
match: true,
},
{
name: "different media type",
contentType: "text/plain",
want: "application/json",
match: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

header := http.Header{"Content-Type": []string{tt.contentType}}
require.Equal(t, tt.match, hasMediaType(header, tt.want))
})
}
}
4 changes: 2 additions & 2 deletions internal/mcpproxy/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ func (m *mcpRequestContext) handleToolCallRequest(ctx context.Context, s *sessio
}

func copyProxyHeaders(resp *http.Response, w http.ResponseWriter) {
isJSONResponse := resp.Header.Get("Content-Type") == "application/json"
isJSONResponse := hasMediaType(resp.Header, "application/json")
for k, v := range resp.Header {
// Skip content-length header for non JSON response since we might modify the response.
if !isJSONResponse && strings.EqualFold(k, "content-length") {
Expand All @@ -799,7 +799,7 @@ func (m *mcpRequestContext) proxyResponseBody(ctx context.Context, s *session, w
// Try to decode as a single JSON-RPC message first; if that fails, fall through to the
// SSE parser using the already-read bytes.
var sseReader io.Reader = resp.Body
if resp.Header.Get("Content-Type") == "application/json" {
if hasMediaType(resp.Header, "application/json") {
body, err := io.ReadAll(resp.Body)
if err != nil {
m.l.Error("failed to read response body", slog.String("error", err.Error()))
Expand Down
65 changes: 65 additions & 0 deletions internal/mcpproxy/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,34 @@ func TestProxyResponseBody_JSONResponseWithBOM(t *testing.T) {
require.Contains(t, rr.Body.String(), id.Raw())
}

func TestProxyResponseBody_JSONResponseWithCharset(t *testing.T) {
proxy := newTestMCPProxy()

reqID := mustJSONRPCRequestID()
backendURI := "file://backend-resource"
respBody := fmt.Sprintf(`{"jsonrpc":"2.0","id":"backend-id","result":{"contents":[{"uri":%q,"mimeType":"text/plain","text":"hello"}]}}`, backendURI)
httpResp := &http.Response{
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(respBody)),
StatusCode: http.StatusOK,
}

rr := httptest.NewRecorder()
req := &jsonrpc.Request{ID: reqID, Method: "resources/read"}

err := proxy.proxyResponseBody(t.Context(), nil, rr, httpResp, req, filterapi.MCPBackend{Name: "backend1"})
require.NoError(t, err)

rawMsg, err := jsonrpc.DecodeMessage(rr.Body.Bytes())
require.NoError(t, err)
msg, ok := rawMsg.(*jsonrpc.Response)
require.True(t, ok)
require.Equal(t, reqID, msg.ID)
require.JSONEq(t, fmt.Sprintf(`{
"contents":[{"uri":%q,"mimeType":"text/plain","text":"hello"}]
}`, downstreamResourceURI(backendURI, "backend1")), string(msg.Result))
}

func TestProxyResponseBody_SSEResponse(t *testing.T) {
proxy := newTestMCPProxy()

Expand Down Expand Up @@ -1736,6 +1764,43 @@ func TestMCPPRoxy_handleResourceReadRequest(t *testing.T) {
require.Contains(t, rr.Body.String(), `{"jsonrpc":"2.0","id":"id","result":{"contents":[]}}`)
}

func TestMCPPRoxy_handleResourceReadRequest_JSONCharsetResponse(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "backend1", r.Header.Get(internalapi.MCPBackendHeader))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Contains(t, string(body), `"uri":"file://foo-resource"`)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"id","result":{"contents":[{"uri":"file://foo-resource","mimeType":"text/plain","text":"hello"}]}}`))
}))

t.Cleanup(testServer.Close)

reqID, _ := jsonrpc.MakeID("id")
proxy := newTestMCPProxy()
proxy.backendListenerAddr = testServer.URL
rr := httptest.NewRecorder()
s := &session{
reqCtx: proxy,
perBackendSessions: map[filterapi.MCPBackendName]*compositeSessionEntry{"backend1": {sessionID: "test-session"}},
route: "test-route",
}
_, err := proxy.handleResourceReadRequest(t.Context(), s, rr, &jsonrpc.Request{ID: reqID, Method: "resources/read"}, &mcp.ReadResourceParams{
URI: downstreamResourceURI("file://foo-resource", "backend1"),
})
require.NoError(t, err)

require.Equal(t, http.StatusOK, rr.Code)
require.JSONEq(t, `{
"jsonrpc":"2.0",
"id":"id",
"result":{
"contents":[{"uri":"backend1+file://foo-resource","mimeType":"text/plain","text":"hello"}]
}
}`, rr.Body.String())
}

func TestMCPProxy_maybeUpdateProgressTokenMetadata(t *testing.T) {
proxy := newTestMCPProxy()
metadata := mcp.Meta{}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcpproxy/mcpproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ func (m *mcpRequestContext) initializeSession(ctx context.Context, routeName fil

var rawMsg jsonrpc.Message
var sseReader io.Reader = resp.Body
if resp.Header.Get("Content-Type") != "text/event-stream" {
if !hasMediaType(resp.Header, "text/event-stream") {
body, _ := io.ReadAll(resp.Body)
msg, ok := tryDecodeJSONRPCMessage(body)
if ok {
Expand Down
2 changes: 1 addition & 1 deletion internal/mcpproxy/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ func (s *session) sendRequestPerBackend(ctx context.Context, eventChan chan<- *b
return fmt.Errorf("MCP GET request failed with status code %d, body=%s", httpResp.StatusCode, string(body))
}

if httpResp.Header.Get("Content-Type") == "application/json" {
if hasMediaType(httpResp.Header, "application/json") {
// Try to decode as a single JSON-RPC message first.
var respBody []byte
respBody, err = io.ReadAll(bodyReader)
Expand Down
34 changes: 34 additions & 0 deletions internal/mcpproxy/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,40 @@ func TestSendRequestPerBackend_BOMPrefixedJSON(t *testing.T) {
require.Equal(t, id1, resp.ID)
}

func TestSendRequestPerBackend_JSONContentTypeWithCharset(t *testing.T) {
id1, _ := jsonrpc.MakeID("1")
msg1, _ := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id1, Result: []byte(`{"ok":true}`)})

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(msg1)
}))
defer server.Close()

proxy := newTestMCPProxy()
proxy.backendListenerAddr = server.URL
s := &session{reqCtx: proxy}
ch := make(chan *backendEvent, 10)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
err := s.sendRequestPerBackend(ctx, ch, "route1", filterapi.MCPBackend{Name: "backend1"}, &compositeSessionEntry{
sessionID: "sess1",
}, http.MethodGet, nil, nil)
require.NoError(t, err)
close(ch)
var events []*backendEvent
for e := range ch {
events = append(events, e)
}
require.Len(t, events, 1, "expected 1 event from JSON response with charset parameter")
require.Equal(t, "message", events[0].event)
require.Len(t, events[0].messages, 1)
resp, ok := events[0].messages[0].(*jsonrpc.Response)
require.True(t, ok)
require.Equal(t, id1, resp.ID)
}

func TestHandleNotificationsPerBackend_SSE(t *testing.T) {
// Provide two SSE events with valid JSON-RPC requests then close.
id1, _ := jsonrpc.MakeID("1")
Expand Down
Loading