Skip to content

Commit 6d9a080

Browse files
authored
mcp: add support for max size buffer body (#41962)
Commit Message: add support for max size buffer body in mcp fixes #41960 Additional Description: the recent mcp filter is relying on body buffering in order to parse the relevant data we receive. A simple option could allow to limit the body size buffering when such filter is used in an edge environment where clients are not trusted. Risk Level: Testing: success case: ``` $ curl -v -w "Status: %{http_code}\n" -H -X POST https://mcphost/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"data\":\"$(python3 -c 'print("x"*8092)')\"}, \"id\":1}" [...] > Content-Length: 8160 Status: 200 ``` error case: ``` $ curl -v -w "Status: %{http_code}\n" -H -X POST https://mcphost/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"data\":\"$(python3 -c 'print("x"*8192)')\"}, \"id\":1}" [...] > Content-Length: 8260 Status: 413 ``` Docs Changes: Release Notes: Platform Specific Features: [Optional Runtime guard:] [Optional Fixes #Issue] [Optional Fixes commit #PR or SHA] [Optional Deprecated:] [Optional [API Considerations](https://github.com/envoyproxy/envoy/blob/main/api/review_checklist.md):] --------- Signed-off-by: William Dauchy <[email protected]>
1 parent eef470f commit 6d9a080

File tree

4 files changed

+277
-8
lines changed

4 files changed

+277
-8
lines changed

api/envoy/extensions/filters/http/mcp/v3/mcp.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ syntax = "proto3";
22

33
package envoy.extensions.filters.http.mcp.v3;
44

5+
import "google/protobuf/wrappers.proto";
6+
57
import "xds/annotations/v3/status.proto";
68

79
import "udpa/annotations/status.proto";
@@ -40,6 +42,15 @@ message Mcp {
4042
// This allows the route to be re-selected based on the MCP metadata (e.g., method, params).
4143
// Defaults to false.
4244
bool clear_route_cache = 2;
45+
46+
// Maximum size of the request body to buffer for JSON-RPC validation.
47+
// If the request body exceeds this size, the request is rejected with ``413 Payload Too Large``.
48+
// This limit applies to both ``REJECT_NO_MCP`` and ``PASS_THROUGH`` modes to prevent unbounded buffering.
49+
//
50+
// It defaults to 8KB (8192 bytes) and the maximum allowed value is 10MB (10485760 bytes).
51+
//
52+
// Setting it to 0 would disable the limit. It is not recommended to do so in production.
53+
google.protobuf.UInt32Value max_request_body_size = 3 [(validate.rules).uint32 = {lte: 10485760}];
4354
}
4455

4556
// McpOverride for MCP filter

source/extensions/filters/http/mcp/mcp_filter.cc

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ Http::FilterHeadersStatus McpFilter::decodeHeaders(Http::RequestHeaderMap& heade
9797
} else {
9898
// Need to buffer the body to check for JSON-RPC 2.0
9999
is_mcp_request_ = true;
100+
101+
// Set the buffer limit - Envoy will automatically send 413 if exceeded
102+
const uint32_t max_size = config_->maxRequestBodySize();
103+
if (max_size > 0) {
104+
decoder_callbacks_->setDecoderBufferLimit(max_size);
105+
ENVOY_LOG(debug, "set decoder buffer limit to {} bytes", max_size);
106+
}
107+
100108
return Http::FilterHeadersStatus::StopIteration;
101109
}
102110
}
@@ -118,7 +126,24 @@ Http::FilterDataStatus McpFilter::decodeData(Buffer::Instance& data, bool end_st
118126
}
119127

120128
if (end_stream) {
121-
decoder_callbacks_->addDecodedData(data, true);
129+
// Check if the complete request body exceeds the limit
130+
const uint32_t max_size = config_->maxRequestBodySize();
131+
if (max_size > 0) {
132+
decoder_callbacks_->addDecodedData(data, false);
133+
const uint64_t total_size = decoder_callbacks_->decodingBuffer()->length();
134+
135+
if (total_size > max_size) {
136+
ENVOY_LOG(debug, "request body size {} exceeds maximum {}", total_size, max_size);
137+
decoder_callbacks_->sendLocalReply(
138+
Http::Code::PayloadTooLarge,
139+
absl::StrCat("Request body size exceeds maximum allowed size of ", max_size, " bytes"),
140+
nullptr, absl::nullopt, "mcp_filter_body_too_large");
141+
return Http::FilterDataStatus::StopIterationNoBuffer;
142+
}
143+
} else {
144+
decoder_callbacks_->addDecodedData(data, false);
145+
}
146+
122147
std::string json = decoder_callbacks_->decodingBuffer()->toString();
123148
if (metadata_ == nullptr) {
124149
metadata_ = std::make_unique<Protobuf::Struct>();

source/extensions/filters/http/mcp/mcp_filter.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ class McpFilterConfig {
3333
public:
3434
explicit McpFilterConfig(const envoy::extensions::filters::http::mcp::v3::Mcp& proto_config)
3535
: traffic_mode_(proto_config.traffic_mode()),
36-
clear_route_cache_(proto_config.clear_route_cache()) {}
36+
clear_route_cache_(proto_config.clear_route_cache()),
37+
max_request_body_size_(proto_config.has_max_request_body_size()
38+
? proto_config.max_request_body_size().value()
39+
: 8192) {} // Default: 8KB
3740

3841
envoy::extensions::filters::http::mcp::v3::Mcp::TrafficMode trafficMode() const {
3942
return traffic_mode_;
@@ -45,9 +48,12 @@ class McpFilterConfig {
4548

4649
bool clearRouteCache() const { return clear_route_cache_; }
4750

51+
uint32_t maxRequestBodySize() const { return max_request_body_size_; }
52+
4853
private:
4954
const envoy::extensions::filters::http::mcp::v3::Mcp::TrafficMode traffic_mode_;
5055
const bool clear_route_cache_;
56+
const uint32_t max_request_body_size_;
5157
};
5258

5359
/**

test/extensions/filters/http/mcp/mcp_filter_test.cc

Lines changed: 233 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ TEST_F(McpFilterTest, RejectModeRejectsNonJsonRpc) {
150150
Buffer::OwnedImpl buffer(body);
151151
Buffer::OwnedImpl decoding_buffer;
152152

153-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
153+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
154154
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
155155
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
156156

@@ -192,7 +192,7 @@ TEST_F(McpFilterTest, DynamicMetadataSet) {
192192
Buffer::OwnedImpl buffer(json);
193193
Buffer::OwnedImpl decoding_buffer;
194194

195-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
195+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
196196
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
197197
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
198198

@@ -241,7 +241,7 @@ TEST_F(McpFilterTest, WrongJsonRpcVersion) {
241241
Buffer::OwnedImpl buffer(wrong_version);
242242
Buffer::OwnedImpl decoding_buffer;
243243

244-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
244+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
245245
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
246246
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
247247

@@ -282,6 +282,233 @@ TEST_F(McpFilterTest, PostWithWrongContentType) {
282282
EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(headers, false));
283283
}
284284

285+
// Test default max body size configuration
286+
TEST_F(McpFilterTest, DefaultMaxBodySizeIsEightKB) {
287+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
288+
// Don't set max_request_body_size, should default to 8KB
289+
auto config = std::make_shared<McpFilterConfig>(proto_config);
290+
EXPECT_EQ(8192u, config->maxRequestBodySize());
291+
}
292+
293+
// Test custom max body size configuration
294+
TEST_F(McpFilterTest, CustomMaxBodySizeConfiguration) {
295+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
296+
proto_config.mutable_max_request_body_size()->set_value(16384);
297+
auto config = std::make_shared<McpFilterConfig>(proto_config);
298+
EXPECT_EQ(16384u, config->maxRequestBodySize());
299+
}
300+
301+
// Test disabled max body size (0 = no limit)
302+
TEST_F(McpFilterTest, DisabledMaxBodySizeConfiguration) {
303+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
304+
proto_config.mutable_max_request_body_size()->set_value(0);
305+
auto config = std::make_shared<McpFilterConfig>(proto_config);
306+
EXPECT_EQ(0u, config->maxRequestBodySize());
307+
}
308+
309+
// Test request body under the limit succeeds
310+
TEST_F(McpFilterTest, RequestBodyUnderLimitSucceeds) {
311+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
312+
proto_config.mutable_max_request_body_size()->set_value(1024); // 1KB limit
313+
config_ = std::make_shared<McpFilterConfig>(proto_config);
314+
filter_ = std::make_unique<McpFilter>(config_);
315+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
316+
317+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
318+
{"content-type", "application/json"},
319+
{"accept", "application/json"},
320+
{"accept", "text/event-stream"}};
321+
322+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(1024));
323+
filter_->decodeHeaders(headers, false);
324+
325+
// Create a JSON-RPC body that's under 1KB
326+
std::string json = R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value"}, "id": 1})";
327+
Buffer::OwnedImpl buffer(json);
328+
Buffer::OwnedImpl decoding_buffer;
329+
330+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
331+
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
332+
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
333+
EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _));
334+
335+
EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true));
336+
}
337+
338+
// Test request body exceeding the limit gets 413
339+
TEST_F(McpFilterTest, RequestBodyExceedingLimitRejected) {
340+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
341+
proto_config.mutable_max_request_body_size()->set_value(100); // Very small limit
342+
config_ = std::make_shared<McpFilterConfig>(proto_config);
343+
filter_ = std::make_unique<McpFilter>(config_);
344+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
345+
346+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
347+
{"content-type", "application/json"},
348+
{"accept", "application/json"},
349+
{"accept", "text/event-stream"}};
350+
351+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(100));
352+
filter_->decodeHeaders(headers, false);
353+
354+
// Create a JSON body that exceeds 100 bytes
355+
std::string json =
356+
R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value", "longkey": "this is a very long string to exceed the limit"}, "id": 1})";
357+
Buffer::OwnedImpl buffer(json);
358+
Buffer::OwnedImpl decoding_buffer;
359+
360+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
361+
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
362+
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
363+
364+
EXPECT_CALL(decoder_callbacks_,
365+
sendLocalReply(Http::Code::PayloadTooLarge,
366+
testing::HasSubstr("Request body size exceeds maximum allowed size"),
367+
_, _, "mcp_filter_body_too_large"));
368+
369+
EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(buffer, true));
370+
}
371+
372+
// Test request body with limit disabled (0 = no limit) allows large bodies
373+
TEST_F(McpFilterTest, RequestBodyWithDisabledLimitAllowsLargeBodies) {
374+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
375+
proto_config.mutable_max_request_body_size()->set_value(0); // Disable limit
376+
config_ = std::make_shared<McpFilterConfig>(proto_config);
377+
filter_ = std::make_unique<McpFilter>(config_);
378+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
379+
380+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
381+
{"content-type", "application/json"},
382+
{"accept", "application/json"},
383+
{"accept", "text/event-stream"}};
384+
385+
// Should NOT call setDecoderBufferLimit when limit is 0
386+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(_)).Times(0);
387+
filter_->decodeHeaders(headers, false);
388+
389+
// Create a large JSON-RPC body
390+
std::string large_data(50000, 'x'); // 50KB of data
391+
std::string json = R"({"jsonrpc": "2.0", "method": "test", "params": {"data": ")" + large_data +
392+
R"("}, "id": 1})";
393+
Buffer::OwnedImpl buffer(json);
394+
Buffer::OwnedImpl decoding_buffer;
395+
396+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
397+
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
398+
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
399+
EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _));
400+
401+
// Should succeed even with large body
402+
EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true));
403+
}
404+
405+
// Test request body exactly at the limit succeeds
406+
TEST_F(McpFilterTest, RequestBodyExactlyAtLimitSucceeds) {
407+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
408+
proto_config.mutable_max_request_body_size()->set_value(100); // 100 byte limit
409+
config_ = std::make_shared<McpFilterConfig>(proto_config);
410+
filter_ = std::make_unique<McpFilter>(config_);
411+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
412+
413+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
414+
{"content-type", "application/json"},
415+
{"accept", "application/json"},
416+
{"accept", "text/event-stream"}};
417+
418+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(100));
419+
filter_->decodeHeaders(headers, false);
420+
421+
// Create a JSON body that's exactly 100 bytes
422+
std::string json =
423+
R"({"jsonrpc": "2.0", "method": "testMethod", "params": {"key": "val"}, "id": 1})"; // 81
424+
// bytes
425+
// Pad to exactly 100 bytes
426+
while (json.size() < 100) {
427+
json.insert(json.size() - 1, " ");
428+
}
429+
json = json.substr(0, 100);
430+
431+
Buffer::OwnedImpl buffer(json);
432+
Buffer::OwnedImpl decoding_buffer;
433+
434+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
435+
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
436+
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
437+
438+
// Should NOT be rejected
439+
EXPECT_CALL(decoder_callbacks_, sendLocalReply(Http::Code::PayloadTooLarge, _, _, _, _)).Times(0);
440+
441+
// Note: This might fail JSON parsing due to padding, but should not trigger size limit
442+
filter_->decodeData(buffer, true);
443+
}
444+
445+
// Test that buffer limit is set for valid MCP POST requests
446+
TEST_F(McpFilterTest, BufferLimitSetForValidMcpPostRequest) {
447+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
448+
proto_config.mutable_max_request_body_size()->set_value(8192);
449+
config_ = std::make_shared<McpFilterConfig>(proto_config);
450+
filter_ = std::make_unique<McpFilter>(config_);
451+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
452+
453+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
454+
{"content-type", "application/json"},
455+
{"accept", "application/json"},
456+
{"accept", "text/event-stream"}};
457+
458+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(8192));
459+
EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, false));
460+
}
461+
462+
// Test that buffer limit is NOT set when limit is disabled
463+
TEST_F(McpFilterTest, BufferLimitNotSetWhenDisabled) {
464+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
465+
proto_config.mutable_max_request_body_size()->set_value(0); // Disabled
466+
config_ = std::make_shared<McpFilterConfig>(proto_config);
467+
filter_ = std::make_unique<McpFilter>(config_);
468+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
469+
470+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
471+
{"content-type", "application/json"},
472+
{"accept", "application/json"},
473+
{"accept", "text/event-stream"}};
474+
475+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(_)).Times(0);
476+
EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, false));
477+
}
478+
479+
// Test body size check in PASS_THROUGH mode
480+
TEST_F(McpFilterTest, BodySizeLimitInPassThroughMode) {
481+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
482+
proto_config.set_traffic_mode(envoy::extensions::filters::http::mcp::v3::Mcp::PASS_THROUGH);
483+
proto_config.mutable_max_request_body_size()->set_value(50); // Small limit
484+
config_ = std::make_shared<McpFilterConfig>(proto_config);
485+
filter_ = std::make_unique<McpFilter>(config_);
486+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
487+
488+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
489+
{"content-type", "application/json"},
490+
{"accept", "application/json"},
491+
{"accept", "text/event-stream"}};
492+
493+
EXPECT_CALL(decoder_callbacks_, setDecoderBufferLimit(50));
494+
filter_->decodeHeaders(headers, false);
495+
496+
// Large body should be rejected even in PASS_THROUGH mode
497+
std::string json =
498+
R"({"jsonrpc": "2.0", "method": "test", "params": {"key": "value with lots of data"}, "id": 1})";
499+
Buffer::OwnedImpl buffer(json);
500+
Buffer::OwnedImpl decoding_buffer;
501+
502+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
503+
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
504+
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
505+
506+
EXPECT_CALL(decoder_callbacks_,
507+
sendLocalReply(Http::Code::PayloadTooLarge, _, _, _, "mcp_filter_body_too_large"));
508+
509+
EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(buffer, true));
510+
}
511+
285512
// Test route cache is NOT cleared by default when metadata is set
286513
TEST_F(McpFilterTest, RouteCacheNotClearedByDefault) {
287514
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
@@ -295,7 +522,7 @@ TEST_F(McpFilterTest, RouteCacheNotClearedByDefault) {
295522
Buffer::OwnedImpl buffer(json);
296523
Buffer::OwnedImpl decoding_buffer;
297524

298-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
525+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
299526
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
300527
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
301528

@@ -323,7 +550,7 @@ TEST_F(McpFilterTest, RouteCacheNotClearedWhenDisabled) {
323550
Buffer::OwnedImpl buffer(json);
324551
Buffer::OwnedImpl decoding_buffer;
325552

326-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
553+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
327554
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
328555
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
329556

@@ -351,7 +578,7 @@ TEST_F(McpFilterTest, RouteCacheClearedWhenExplicitlyEnabled) {
351578
Buffer::OwnedImpl buffer(json);
352579
Buffer::OwnedImpl decoding_buffer;
353580

354-
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, true))
581+
EXPECT_CALL(decoder_callbacks_, addDecodedData(_, false))
355582
.WillOnce([&decoding_buffer](Buffer::Instance& data, bool) { decoding_buffer.move(data); });
356583
EXPECT_CALL(decoder_callbacks_, decodingBuffer()).WillRepeatedly(Return(&decoding_buffer));
357584

0 commit comments

Comments
 (0)