diff --git a/CODEOWNERS b/CODEOWNERS index da41c442f437..aa59e4925844 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -240,7 +240,7 @@ extensions/upstreams/tcp @ggreenway @mattklein123 # HTTP API Key Auth /*/extensions/filters/http/api_key_auth @wbpcode @sanposhiho # HTTP MCP filter -/*/extensions/filters/http/mcp @botengyao @yanavlasov @wdauchy +/*/extensions/filters/http/mcp @botengyao @yanavlasov # MCP router filter /*/extensions/filters/http/mcp_router @botengyao @yanavlasov @wdauchy @agrawroh # HTTP A2A filter diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 03c85c4ee267..916de10564f5 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -55,6 +55,11 @@ minor_behavior_changes: change: | OpenTelemetry :ref:`SinkConfig ` stopped reporting empty delta counters and histograms. +- area: mcp + change: | + Relaxed the MCP filter POST Content-Type check from an exact match on ``application/json`` to a + prefix match, so that ``application/json; charset=utf-8`` and similar media-type parameters are + accepted. - area: ext_proc change: | added received_immediate_response flag in :ref:'FilterStats diff --git a/source/extensions/filters/http/mcp/mcp_filter.cc b/source/extensions/filters/http/mcp/mcp_filter.cc index 272f53f5f044..af4bfb7b4af7 100644 --- a/source/extensions/filters/http/mcp/mcp_filter.cc +++ b/source/extensions/filters/http/mcp/mcp_filter.cc @@ -72,10 +72,18 @@ bool McpFilter::isValidMcpSseRequest(const Http::RequestHeaderMap& headers) cons } bool McpFilter::isValidMcpPostRequest(const Http::RequestHeaderMap& headers) const { - // Check if this is a POST request with JSON content + // Check if this is a POST request with JSON content. + // Content-Type is JSON if it is exactly "application/json" or starts with + // "application/json" followed by ';' or ' ' (for parameters like charset). + // This rejects related but distinct types like application/json-patch+json. + const absl::string_view content_type = headers.getContentTypeValue(); + const auto& json_ct = Http::Headers::get().ContentTypeValues.Json; + bool is_json_content_type = + absl::StartsWith(content_type, json_ct) && + (content_type.size() == json_ct.size() || content_type[json_ct.size()] == ';' || + content_type[json_ct.size()] == ' '); bool is_post_request = - headers.getMethodValue() == Http::Headers::get().MethodValues.Post && - headers.getContentTypeValue() == Http::Headers::get().ContentTypeValues.Json; + headers.getMethodValue() == Http::Headers::get().MethodValues.Post && is_json_content_type; if (!is_post_request) { return false; diff --git a/test/extensions/filters/http/mcp/mcp_filter_test.cc b/test/extensions/filters/http/mcp/mcp_filter_test.cc index 775c87208c07..03edbb4a3c8f 100644 --- a/test/extensions/filters/http/mcp/mcp_filter_test.cc +++ b/test/extensions/filters/http/mcp/mcp_filter_test.cc @@ -1244,6 +1244,68 @@ TEST_F(McpFilterTest, BothStorageModeSetsBothTargets) { EXPECT_EQ(filter_state_obj->method().value(), "tools/call"); } +// Test that POST with Content-Type "application/json; charset=utf-8" is accepted +TEST_F(McpFilterTest, PostWithJsonCharsetContentTypeAccepted) { + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json; charset=utf-8"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + filter_->decodeHeaders(headers, false); + + std::string json = R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value"}, "id": 1})"; + Buffer::OwnedImpl buffer(json); + + EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("envoy.filters.http.mcp", _)); + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true)); +} + +// Test that POST with Content-Type "application/json;charset=utf-8" (no space) is accepted +TEST_F(McpFilterTest, PostWithJsonCharsetNoSpaceContentTypeAccepted) { + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json;charset=utf-8"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + filter_->decodeHeaders(headers, false); + + std::string json = R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value"}, "id": 1})"; + Buffer::OwnedImpl buffer(json); + + EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("envoy.filters.http.mcp", _)); + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true)); +} + +// Test that POST with Content-Type "application/json-patch+json" (RFC 6902) is rejected in +// REJECT_NO_MCP mode because it is not plain application/json. +TEST_F(McpFilterTest, PostWithJsonPatchContentTypeRejectedInRejectMode) { + setupRejectMode(); + + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json-patch+json"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Http::Code::BadRequest, "Only MCP traffic is allowed", _, _, _)); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, true)); +} + +// Test that POST with Content-Type "application/jsonl" is rejected in REJECT_NO_MCP mode +// because it is not plain application/json. +TEST_F(McpFilterTest, PostWithJsonlContentTypeRejectedInRejectMode) { + setupRejectMode(); + + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/jsonl"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Http::Code::BadRequest, "Only MCP traffic is allowed", _, _, _)); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, true)); +} + // Test REJECT_NO_MCP mode - allow DELETE with MCP-Session-Id (session termination) TEST_F(McpFilterTest, RejectModeAllowsDeleteWithSessionId) { setupRejectMode(); diff --git a/tools/spelling/spelling_dictionary.txt b/tools/spelling/spelling_dictionary.txt index 9650e9fba700..5456db22937c 100644 --- a/tools/spelling/spelling_dictionary.txt +++ b/tools/spelling/spelling_dictionary.txt @@ -1663,6 +1663,7 @@ effective_cpus cpus ja3 ja4 +jsonl HBONE waypoint ztunnel