diff --git a/api/envoy/extensions/filters/http/mcp/v3/mcp.proto b/api/envoy/extensions/filters/http/mcp/v3/mcp.proto index 67b15f0d2070a..ccd76c3a7b4ee 100644 --- a/api/envoy/extensions/filters/http/mcp/v3/mcp.proto +++ b/api/envoy/extensions/filters/http/mcp/v3/mcp.proto @@ -53,8 +53,13 @@ message Mcp { google.protobuf.UInt32Value max_request_body_size = 3 [(validate.rules).uint32 = {lte: 10485760}]; } -// McpOverride for MCP filter +// Per-route override configuration for MCP filter message McpOverride { // Optional per-route traffic mode override Mcp.TrafficMode traffic_mode = 1 [(validate.rules).enum = {defined_only: true}]; + + // Optional per-route max request body size override. + // When set, this overrides the global max_request_body_size for this route. + // It defaults to 8KB (8192 bytes) and the maximum allowed value is 10MB (10485760 bytes). + google.protobuf.UInt32Value max_request_body_size = 2 [(validate.rules).uint32 = {lte: 10485760}]; } diff --git a/source/extensions/filters/http/mcp/mcp_filter.cc b/source/extensions/filters/http/mcp/mcp_filter.cc index cf193c42ac677..441818ac9cefd 100644 --- a/source/extensions/filters/http/mcp/mcp_filter.cc +++ b/source/extensions/filters/http/mcp/mcp_filter.cc @@ -80,6 +80,17 @@ bool McpFilter::shouldRejectRequest() const { return config_->shouldRejectNonMcp(); } +uint32_t McpFilter::getMaxRequestBodySize() const { + const auto* override_config = + Http::Utility::resolveMostSpecificPerFilterConfig(decoder_callbacks_); + + if (override_config && override_config->maxRequestBodySize().has_value()) { + return override_config->maxRequestBodySize().value(); + } + + return config_->maxRequestBodySize(); +} + Http::FilterHeadersStatus McpFilter::decodeHeaders(Http::RequestHeaderMap& headers, bool end_stream) { @@ -99,7 +110,7 @@ Http::FilterHeadersStatus McpFilter::decodeHeaders(Http::RequestHeaderMap& heade is_mcp_request_ = true; // Set the buffer limit - Envoy will automatically send 413 if exceeded - const uint32_t max_size = config_->maxRequestBodySize(); + const uint32_t max_size = getMaxRequestBodySize(); if (max_size > 0) { decoder_callbacks_->setDecoderBufferLimit(max_size); ENVOY_LOG(debug, "set decoder buffer limit to {} bytes", max_size); @@ -127,7 +138,7 @@ Http::FilterDataStatus McpFilter::decodeData(Buffer::Instance& data, bool end_st if (end_stream) { // Check if the complete request body exceeds the limit - const uint32_t max_size = config_->maxRequestBodySize(); + const uint32_t max_size = getMaxRequestBodySize(); if (max_size > 0) { decoder_callbacks_->addDecodedData(data, false); const uint64_t total_size = decoder_callbacks_->decodingBuffer()->length(); diff --git a/source/extensions/filters/http/mcp/mcp_filter.h b/source/extensions/filters/http/mcp/mcp_filter.h index ddda51f6df4a9..e8e271dc4add2 100644 --- a/source/extensions/filters/http/mcp/mcp_filter.h +++ b/source/extensions/filters/http/mcp/mcp_filter.h @@ -63,14 +63,21 @@ class McpOverrideConfig : public Router::RouteSpecificFilterConfig { public: explicit McpOverrideConfig( const envoy::extensions::filters::http::mcp::v3::McpOverride& proto_config) - : traffic_mode_(proto_config.traffic_mode()) {} + : traffic_mode_(proto_config.traffic_mode()), + max_request_body_size_( + proto_config.has_max_request_body_size() + ? absl::optional(proto_config.max_request_body_size().value()) + : absl::nullopt) {} envoy::extensions::filters::http::mcp::v3::Mcp::TrafficMode trafficMode() const { return traffic_mode_; } + absl::optional maxRequestBodySize() const { return max_request_body_size_; } + private: const envoy::extensions::filters::http::mcp::v3::Mcp::TrafficMode traffic_mode_; + const absl::optional max_request_body_size_; }; using McpFilterConfigSharedPtr = std::shared_ptr; @@ -103,6 +110,7 @@ class McpFilter : public Http::PassThroughFilter, public Logger::LoggableclearRouteCache()); } +// Test per-route max body size override with smaller limit +TEST_F(McpFilterTest, PerRouteMaxBodySizeSmallerLimit) { + // Global config with 1024 bytes + envoy::extensions::filters::http::mcp::v3::Mcp proto_config; + proto_config.mutable_max_request_body_size()->set_value(1024); + config_ = std::make_shared(proto_config); + filter_ = std::make_unique(config_); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + + // Per-route config with smaller limit (100 bytes) + envoy::extensions::filters::http::mcp::v3::McpOverride override_config; + override_config.mutable_max_request_body_size()->set_value(100); + auto route_config = std::make_shared(override_config); + + EXPECT_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillRepeatedly(Return(route_config.get())); + + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + // Should use per-route limit of 100 bytes, not global 1024 + EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(100)); + filter_->decodeHeaders(headers, false); + + // Create a body that exceeds per-route limit (100) but under global (1024) + std::string json = + R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value", "extra": "data to exceed 100 bytes"}, "id": 1})"; + Buffer::OwnedImpl buffer(json); + Buffer::OwnedImpl decoding_buffer; + + EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false)) + .WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); }); + EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer)); + + // Should be rejected based on per-route limit + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Http::Code::PayloadTooLarge, + testing::HasSubstr("Request body size exceeds maximum allowed size"), + _, _, "mcp_filter_body_too_large")); + + EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(buffer, true)); +} + +// Test per-route max body size override with larger limit +TEST_F(McpFilterTest, PerRouteMaxBodySizeLargerLimit) { + // Global config with 100 bytes + envoy::extensions::filters::http::mcp::v3::Mcp proto_config; + proto_config.mutable_max_request_body_size()->set_value(100); + config_ = std::make_shared(proto_config); + filter_ = std::make_unique(config_); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + + // Per-route config with larger limit (2048 bytes) + envoy::extensions::filters::http::mcp::v3::McpOverride override_config; + override_config.mutable_max_request_body_size()->set_value(2048); + auto route_config = std::make_shared(override_config); + + EXPECT_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillRepeatedly(Return(route_config.get())); + + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + // Should use per-route limit of 2048 bytes, not global 100 + EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(2048)); + filter_->decodeHeaders(headers, false); + + // Create a body that exceeds global limit (100) but under per-route (2048) + std::string json = + R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value", "extra": "data to exceed 100 bytes limit"}, "id": 1})"; + Buffer::OwnedImpl buffer(json); + Buffer::OwnedImpl decoding_buffer; + + EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false)) + .WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); }); + EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer)); + EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _)); + + // Should succeed based on per-route limit + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true)); +} + +// Test fallback to global max body size when no per-route override +TEST_F(McpFilterTest, PerRouteMaxBodySizeFallbackToGlobal) { + // Global config with 512 bytes + envoy::extensions::filters::http::mcp::v3::Mcp proto_config; + proto_config.mutable_max_request_body_size()->set_value(512); + config_ = std::make_shared(proto_config); + filter_ = std::make_unique(config_); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + + // Per-route config WITHOUT max_request_body_size override (only traffic mode) + envoy::extensions::filters::http::mcp::v3::McpOverride override_config; + override_config.set_traffic_mode(envoy::extensions::filters::http::mcp::v3::Mcp::PASS_THROUGH); + auto route_config = std::make_shared(override_config); + + EXPECT_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillRepeatedly(Return(route_config.get())); + + Http::TestRequestHeaderMapImpl headers{{":method", "POST"}, + {"content-type", "application/json"}, + {"accept", "application/json"}, + {"accept", "text/event-stream"}}; + + // Should fallback to global limit of 512 bytes + EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(512)); + filter_->decodeHeaders(headers, false); + + // Create a valid JSON-RPC body under 512 bytes + std::string json = R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value"}, "id": 1})"; + Buffer::OwnedImpl buffer(json); + Buffer::OwnedImpl decoding_buffer; + + EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false)) + .WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); }); + EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer)); + EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _)); + + // Should succeed based on global limit + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true)); +} + } // namespace } // namespace Mcp } // namespace HttpFilters