From 3edbd77cd2adce6bfb71974ca1d222c5ac0749f6 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Fri, 8 May 2026 21:33:59 +0000 Subject: [PATCH 01/16] http: add tools/list configuration for MCP JSON REST bridge filter This commit implements the per-route local handling for the tools/list JSON-RPC method in the McpJsonRestBridgeFilter. When the filter matches a /mcp tools/list request and McpJsonRestBridgePerRoute configuration is present, the response is built and served locally. To maximize efficiency and avoid string copies during local response generation, the filter directly constructs the response JSON into an Envoy buffer (Buffer::OwnedImpl) and passes it directly to the response path via decoder_callbacks_->encodeHeaders and decoder_callbacks_->encodeData, bypassing sendLocalReply. The protobuf definitions for tool configuration have also been updated to fully match the final spec, incorporating ToolsListSpecificConfig (including opaque input/output schema strings) and McpServerInfo. Signed-off-by: Michael Behr --- .../v3/mcp_json_rest_bridge.proto | 36 +++++++- .../http/mcp_json_rest_bridge/config.cc | 8 ++ .../http/mcp_json_rest_bridge/config.h | 10 ++- .../mcp_json_rest_bridge_filter.cc | 82 ++++++++++++++++++- .../mcp_json_rest_bridge_filter.h | 26 +++++- .../filters/http/mcp_json_rest_bridge/BUILD | 1 + .../mcp_json_rest_bridge_filter_test.cc | 65 +++++++++++++++ .../mcp_json_rest_bridge_integration_test.cc | 66 +++++++++++++++ 8 files changed, 286 insertions(+), 8 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index a70e4cb4b4f8d..240fcad08eaf0 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -170,13 +170,40 @@ message ServerToolConfig { HttpRule tool_list_http_rule = 3; } -// Configuration for a specific MCP tool. +// Note: Based on the method in HttpRule, we will set the readOnlyHint bit in +// annotations for GET method to indicate no server state mutation. +message ToolsListSpecificConfig { + // Optional, human-readable name of the tool for display purposes. + string title = 1; + + // Human-readable description of functionality. + string description = 2; + + // The JSON Schema describing expected parameters, as a serialized JSON string. + // It is raw json without type: object which includes "properties" and "required" key + string input_schema = 3; + + // [#not-implemented-hide:] + // The validation of structured results + string output_schema = 4; +} + +// Path and host of MCP server that hosts this tool +message McpServerInfo { + string path = 1; + string host = 2; +} + message ToolConfig { - // Name of the tool. + // Unique identifier of the tool. Used both for tools/list and tools/call transcoding. string name = 1 [(validate.rules).string = {min_len: 1}]; // The HTTP configuration rules that apply to the normal backend. HttpRule http_rule = 2; + + ToolsListSpecificConfig tool_list_config = 3; + + McpServerInfo server_info = 4; } // Defines the schema of the JSON-RPC to REST mapping. It specifies how the "arguments" @@ -227,3 +254,8 @@ message HttpRule { // - If omitted: There is no HTTP request body; fields not in the path become query parameters. string body = 6; } + +// Per-route override configuration for the MCP JSON REST Bridge filter. +message McpJsonRestBridgePerRoute { + ServerToolConfig tool_config = 1; +} diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/config.cc b/source/extensions/filters/http/mcp_json_rest_bridge/config.cc index e3121b8f214c0..bbcab4d350556 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/config.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/config.cc @@ -35,6 +35,14 @@ McpJsonRestBridgeFilterConfigFactory::createFilterFactoryFromProtoTyped( /** * Static registration for the MCP JSON REST bridge filter. @see RegisterFactory. */ +absl::StatusOr +McpJsonRestBridgeFilterConfigFactory::createRouteSpecificFilterConfigTyped( + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute& + proto_config, + Server::Configuration::ServerFactoryContext&, ProtobufMessage::ValidationVisitor&) { + return std::make_shared(proto_config); +} + REGISTER_FACTORY(McpJsonRestBridgeFilterConfigFactory, Server::Configuration::NamedHttpFilterConfigFactory); diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/config.h b/source/extensions/filters/http/mcp_json_rest_bridge/config.h index d855bc5fe0c5f..b0faf3001d147 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/config.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/config.h @@ -15,7 +15,8 @@ namespace McpJsonRestBridge { */ class McpJsonRestBridgeFilterConfigFactory : public Common::ExceptionFreeFactoryBase< - envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge> { + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge, + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute> { public: McpJsonRestBridgeFilterConfigFactory() : ExceptionFreeFactoryBase("envoy.filters.http.mcp_json_rest_bridge") {} @@ -25,6 +26,13 @@ class McpJsonRestBridgeFilterConfigFactory const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge& proto_config, const std::string&, Server::Configuration::FactoryContext&) override; + + absl::StatusOr + createRouteSpecificFilterConfigTyped( + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute& + proto_config, + Server::Configuration::ServerFactoryContext& context, + ProtobufMessage::ValidationVisitor& validator) override; }; } // namespace McpJsonRestBridge diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 4d7c03d3f5139..b4f68c4bbd677 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -6,10 +6,13 @@ #include "envoy/http/filter.h" #include "envoy/http/header_map.h" +#include "source/common/http/header_map_impl.h" #include "source/common/http/headers.h" +#include "source/common/http/utility.h" #include "source/common/protobuf/utility.h" #include "source/extensions/filters/common/mcp/constants.h" #include "source/extensions/filters/http/mcp_json_rest_bridge/http_request_builder.h" +#include "source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h" #include "absl/base/no_destructor.h" #include "absl/container/flat_hash_set.h" @@ -240,8 +243,9 @@ Http::FilterDataStatus McpJsonRestBridgeFilter::decodeData(Buffer::Instance& dat if (mcp_operation_ == McpOperation::Initialization || mcp_operation_ == McpOperation::InitializationAck || - mcp_operation_ == McpOperation::OperationFailed) { - // sendLocalReply was called in handleMcpMethod for these operations. + mcp_operation_ == McpOperation::OperationFailed || + mcp_operation_ == McpOperation::ToolsListLocal) { + // sendLocalReply/encodeHeaders was called in handleMcpMethod for these operations. return Http::FilterDataStatus::StopIterationNoBuffer; } @@ -257,6 +261,7 @@ Http::FilterHeadersStatus McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseH // The response for InitializedNotification is empty body so we don't need // to modify the response headers. case McpOperation::InitializationAck: + case McpOperation::ToolsListLocal: return Http::FilterHeadersStatus::Continue; default: break; @@ -276,7 +281,8 @@ Http::FilterDataStatus McpJsonRestBridgeFilter::encodeData(Buffer::Instance& dat // No need to encode the response body for Initialization and InitializationAck. if (mcp_operation_ == McpOperation::Unspecified || mcp_operation_ == McpOperation::Initialization || - mcp_operation_ == McpOperation::InitializationAck) { + mcp_operation_ == McpOperation::InitializationAck || + mcp_operation_ == McpOperation::ToolsListLocal) { return Http::FilterDataStatus::Continue; } @@ -321,6 +327,68 @@ Http::FilterTrailersStatus McpJsonRestBridgeFilter::encodeTrailers(Http::Respons return Http::FilterTrailersStatus::Continue; } +void McpJsonRestBridgeFilter::serveToolsListLocal( + const McpJsonRestBridgePerRouteConfig& per_route_config, const nlohmann::json& json_rpc) { + std::string request_id_json = "null"; + if (json_rpc.contains("id")) { + request_id_json = json_rpc["id"].dump(); + } + + Buffer::OwnedImpl response_data; + response_data.add("{\"jsonrpc\":\"2.0\",\"id\":"); + response_data.add(request_id_json); + response_data.add(",\"result\":{\"tools\":["); + + bool first_tool = true; + for (const auto& tool : per_route_config.toolConfig().tools()) { + if (!first_tool) { + response_data.add(","); + } + first_tool = false; + + response_data.add("{"); + nlohmann::json name_json = tool.name(); + response_data.add("\"name\":"); + response_data.add(name_json.dump()); + + if (!tool.tool_list_config().title().empty()) { + nlohmann::json title_json = tool.tool_list_config().title(); + response_data.add(",\"title\":"); + response_data.add(title_json.dump()); + } + + nlohmann::json desc_json = tool.tool_list_config().description(); + response_data.add(",\"description\":"); + response_data.add(desc_json.dump()); + + response_data.add(",\"inputSchema\":"); + if (!tool.tool_list_config().input_schema().empty()) { + response_data.add(tool.tool_list_config().input_schema()); + } else { + response_data.add("{\"type\":\"object\"}"); + } + + if (!tool.tool_list_config().output_schema().empty()) { + response_data.add(",\"outputSchema\":"); + response_data.add(tool.tool_list_config().output_schema()); + } + + response_data.add("}"); + } + + response_data.add("]}}"); + + Http::ResponseHeaderMapPtr response_headers = Http::ResponseHeaderMapImpl::create(); + response_headers->setStatus(200); + response_headers->setContentType(Http::Headers::get().ContentTypeValues.Json); + response_headers->setContentLength(response_data.length()); + + decoder_callbacks_->encodeHeaders(std::move(response_headers), false, + "mcp_json_rest_bridge_tools_list"); + + decoder_callbacks_->encodeData(response_data, true); +} + void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, Http::RequestHeaderMapOptRef request_headers) { ENVOY_STREAM_LOG(debug, "Handling MCP JSON-RPC: {}", *decoder_callbacks_, json_rpc.dump()); @@ -337,6 +405,14 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, } // TODO(guoyilin42): Consider supporting local response for tools/list in addition to the GET. if (method == McpConstants::Methods::TOOLS_LIST) { + mcp_operation_ = McpOperation::ToolsListLocal; + const auto* per_route_config = + Http::Utility::resolveMostSpecificPerFilterConfig( + decoder_callbacks_); + if (per_route_config && !per_route_config->toolConfig().tools().empty()) { + serveToolsListLocal(*per_route_config, json_rpc); + return; + } absl::StatusOr http_rule = config_->getToolsListHttpRule(); if (http_rule.ok() && !http_rule->get().empty()) { diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index 3a7efeb8bc1f1..3ba9b44ea365f 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -53,6 +53,22 @@ class McpJsonRestBridgeFilterConfig : public Logger::Loggable; /** @@ -78,6 +94,10 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, void handleMcpMethod(const nlohmann::json& json_rpc, Http::RequestHeaderMapOptRef request_headers); + // Serves a local tools/list response using the per-route configuration, bypassing upstream. + void serveToolsListLocal(const McpJsonRestBridgePerRouteConfig& per_route_config, + const nlohmann::json& json_rpc); + // Modifies the response from upstream into JSON-RPC response. void encodeJsonRpcData(Http::ResponseHeaderMapOptRef response_headers); @@ -103,10 +123,12 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, InitializationAck = 3, // Clients send a tools/list request to discover available tools. ToolsList = 4, + // Clients send a tools/list request that is handled locally via per-route config. + ToolsListLocal = 5, // Clients send a tools/call request to invoke a tool. - ToolsCall = 5, + ToolsCall = 6, // MCP operation failed. - OperationFailed = 6, + OperationFailed = 7, }; McpOperation mcp_operation_ = McpOperation::Unspecified; absl::optional session_id_; diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/BUILD b/test/extensions/filters/http/mcp_json_rest_bridge/BUILD index c24da6f9e270c..6ed94531697db 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/BUILD +++ b/test/extensions/filters/http/mcp_json_rest_bridge/BUILD @@ -50,6 +50,7 @@ envoy_cc_test( "//source/extensions/filters/http/mcp_json_rest_bridge:config", "//test/integration:http_integration_lib", "//test/test_common:utility_lib", + "@envoy_api//envoy/extensions/filters/http/mcp_json_rest_bridge/v3:pkg_cc_proto", "@nlohmann_json//:json", ], ) diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 57a4e37ed0028..c059ee6c1e72b 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1276,6 +1276,71 @@ TEST_P(McpHttpMethodFilterTest, NonPostMethodsReturnMethodNotAllowed) { StrEq("POST")); } +TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge proto_config; + auto config = std::make_shared(proto_config); + filter_ = std::make_unique(config); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute + override_config; + + auto* tool1 = override_config.mutable_tool_config()->add_tools(); + tool1->set_name("my_tool"); + tool1->mutable_tool_list_config()->set_title("My Tool"); + tool1->mutable_tool_list_config()->set_description("Does something."); + tool1->mutable_tool_list_config()->set_input_schema(R"({"type":"object"})"); + + auto* tool2 = override_config.mutable_tool_config()->add_tools(); + tool2->set_name("complex_tool"); + tool2->mutable_tool_list_config()->set_title("Complex \"Tool\""); + tool2->mutable_tool_list_config()->set_description("Has\\nspecial \"chars\" & \\t stuff."); + tool2->mutable_tool_list_config()->set_input_schema( + R"({"type": "object", "properties": {"a": {"type": "string"}}})"); + + McpJsonRestBridgePerRouteConfig override(override_config); + + ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(testing::Return(&override)); + + Http::TestRequestHeaderMapImpl headers{ + {":method", "POST"}, {":path", "/mcp"}, {"content-type", "application/json"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, false)); + + std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": "req-1"})"; + Buffer::OwnedImpl data(json); + + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(testing::_, false)) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { + EXPECT_EQ("200", headers.getStatusValue()); + EXPECT_EQ("application/json", headers.getContentTypeValue()); + })); + EXPECT_CALL(decoder_callbacks_, encodeData(testing::_, true)) + .WillOnce(testing::Invoke([](Buffer::Instance& data, bool) { + auto parsed_response = nlohmann::json::parse(data.toString()); + + EXPECT_EQ(parsed_response["jsonrpc"], "2.0"); + EXPECT_EQ(parsed_response["id"], "req-1"); + + auto tools = parsed_response["result"]["tools"]; + EXPECT_EQ(tools.size(), 2); + + EXPECT_EQ(tools[0]["name"], "my_tool"); + EXPECT_EQ(tools[0]["title"], "My Tool"); + EXPECT_EQ(tools[0]["description"], "Does something."); + EXPECT_EQ(tools[0]["inputSchema"]["type"], "object"); + + EXPECT_EQ(tools[1]["name"], "complex_tool"); + EXPECT_EQ(tools[1]["title"], "Complex \"Tool\""); + EXPECT_EQ(tools[1]["description"], "Has\\nspecial \"chars\" & \\t stuff."); + EXPECT_EQ(tools[1]["inputSchema"]["type"], "object"); + EXPECT_EQ(tools[1]["inputSchema"]["properties"]["a"]["type"], "string"); + })); + + EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); +} + } // namespace } // namespace McpJsonRestBridge } // namespace HttpFilters diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc index f79be0c43454b..fe9066040ea3f 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc @@ -1,7 +1,9 @@ +#include "envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.pb.h" #include "envoy/http/codec.h" #include "envoy/network/address.h" #include "source/common/buffer/buffer_impl.h" +#include "source/common/protobuf/utility.h" #include "test/integration/http_integration.h" #include "test/test_common/environment.h" @@ -548,5 +550,69 @@ TEST_P(McpJsonRestBridgeIntegrationTest, InitializeUnsupportedProtocolVersionFal EXPECT_EQ(nlohmann::json::parse(response->body()), nlohmann::json::parse(expected_response)); } +TEST_P(McpJsonRestBridgeIntegrationTest, ToolsListLocalResponse) { + const std::string config = R"EOF( + name: envoy.filters.http.mcp_json_rest_bridge + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.mcp_json_rest_bridge.v3.McpJsonRestBridge + )EOF"; + + config_helper_.addConfigModifier([](envoy::extensions::filters::network::http_connection_manager:: + v3::HttpConnectionManager& hcm) { + auto* route = hcm.mutable_route_config()->mutable_virtual_hosts(0)->mutable_routes(0); + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute per_route; + auto* tool = per_route.mutable_tool_config()->add_tools(); + tool->set_name("my_local_tool"); + tool->mutable_tool_list_config()->set_title("My Local Tool"); + tool->mutable_tool_list_config()->set_description("Does a local thing."); + tool->mutable_tool_list_config()->set_input_schema(R"({"type":"object"})"); + + Protobuf::Any per_route_any; + MessageUtil::packFrom(per_route_any, per_route); + route->mutable_typed_per_filter_config()->insert( + {"envoy.filters.http.mcp_json_rest_bridge", per_route_any}); + }); + + initializeFilter(config); + + codec_client_ = makeHttpConnection(lookupPort("http")); + + const std::string request_body = R"({ + "jsonrpc": "2.0", + "id": 123, + "method": "tools/list" + })"; + + auto response = codec_client_->makeRequestWithBody( + Http::TestRequestHeaderMapImpl{{":method", "POST"}, + {":path", "/mcp"}, + {":scheme", "http"}, + {":authority", "host"}, + {"content-type", "application/json"}}, + request_body); + + ASSERT_TRUE(response->waitForEndStream()); + EXPECT_THAT(response->headers().getStatusValue(), StrEq("200")); + EXPECT_THAT(response->headers().getContentTypeValue(), StrEq("application/json")); + + const std::string expected_rpc_response = R"({ + "jsonrpc": "2.0", + "id": 123, + "result": { + "tools": [ + { + "name": "my_local_tool", + "title": "My Local Tool", + "description": "Does a local thing.", + "inputSchema": { + "type": "object" + } + } + ] + } + })"; + EXPECT_EQ(nlohmann::json::parse(response->body()), nlohmann::json::parse(expected_rpc_response)); +} + } // namespace } // namespace Envoy From 1305345ae54a9bc4a8306ab716f439acd43dd20d Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Mon, 11 May 2026 19:58:23 +0000 Subject: [PATCH 02/16] Improve documentation and comments. Signed-off-by: Michael Behr --- .../v3/mcp_json_rest_bridge.proto | 14 +++++++------- .../mcp_json_rest_bridge_filter.cc | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index 240fcad08eaf0..04722f619b1f8 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -170,21 +170,21 @@ message ServerToolConfig { HttpRule tool_list_http_rule = 3; } -// Note: Based on the method in HttpRule, we will set the readOnlyHint bit in -// annotations for GET method to indicate no server state mutation. +// Configuration for a specific MCP tool. message ToolsListSpecificConfig { // Optional, human-readable name of the tool for display purposes. string title = 1; // Human-readable description of functionality. - string description = 2; + string description = 2 [(validate.rules).string = {min_len: 1}]; - // The JSON Schema describing expected parameters, as a serialized JSON string. - // It is raw json without type: object which includes "properties" and "required" key + // A JSON Schema describing expected parameters, as a serialized JSON string, in the JSON Schema + // 2020-12 dialect. This should be raw JSON, including the "properties" and "required" keys, but + // not "type". Tools with no parameters may omit this to signify a tool with no constraints on the + // parameters object, or set to '"additionalProperties": false' to require empty parameters. string input_schema = 3; - // [#not-implemented-hide:] - // The validation of structured results + // Optional output schema. If present, uses the same format as input_schema. string output_schema = 4; } diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index b4f68c4bbd677..9ce095b1deeea 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -261,6 +261,7 @@ Http::FilterHeadersStatus McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseH // The response for InitializedNotification is empty body so we don't need // to modify the response headers. case McpOperation::InitializationAck: + // ToolsListLocal sends a local reply, so the headers are already correct. case McpOperation::ToolsListLocal: return Http::FilterHeadersStatus::Continue; default: @@ -278,7 +279,8 @@ Http::FilterHeadersStatus McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseH Http::FilterDataStatus McpJsonRestBridgeFilter::encodeData(Buffer::Instance& data, bool end_stream) { - // No need to encode the response body for Initialization and InitializationAck. + // No need to encode the response body for Initialization and InitializationAck. ToolsListLocal is + // a local response, and the response body is already encoded. if (mcp_operation_ == McpOperation::Unspecified || mcp_operation_ == McpOperation::Initialization || mcp_operation_ == McpOperation::InitializationAck || @@ -327,6 +329,8 @@ Http::FilterTrailersStatus McpJsonRestBridgeFilter::encodeTrailers(Http::Respons return Http::FilterTrailersStatus::Continue; } +// Send a local reply for a tools/list call. Construct the response body directly instead of +// building a JSON object, to minimize copying. void McpJsonRestBridgeFilter::serveToolsListLocal( const McpJsonRestBridgePerRouteConfig& per_route_config, const nlohmann::json& json_rpc) { std::string request_id_json = "null"; From 0a8dcfb59b8869e1d29b13b38809ae80212b6026 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Mon, 11 May 2026 20:03:32 +0000 Subject: [PATCH 03/16] test: tweak tools/list local response unit tests Signed-off-by: Michael Behr --- .../mcp_json_rest_bridge_filter_test.cc | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index c059ee6c1e72b..3efdab60c10fb 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1286,10 +1286,10 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { override_config; auto* tool1 = override_config.mutable_tool_config()->add_tools(); - tool1->set_name("my_tool"); + tool1->set_name("simple_tool"); tool1->mutable_tool_list_config()->set_title("My Tool"); tool1->mutable_tool_list_config()->set_description("Does something."); - tool1->mutable_tool_list_config()->set_input_schema(R"({"type":"object"})"); + tool1->mutable_tool_list_config()->set_input_schema(""); auto* tool2 = override_config.mutable_tool_config()->add_tools(); tool2->set_name("complex_tool"); @@ -1297,6 +1297,8 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { tool2->mutable_tool_list_config()->set_description("Has\\nspecial \"chars\" & \\t stuff."); tool2->mutable_tool_list_config()->set_input_schema( R"({"type": "object", "properties": {"a": {"type": "string"}}})"); + tool2->mutable_tool_list_config()->set_output_schema( + R"({"type": "string"})"); McpJsonRestBridgePerRouteConfig override(override_config); @@ -1326,7 +1328,7 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { auto tools = parsed_response["result"]["tools"]; EXPECT_EQ(tools.size(), 2); - EXPECT_EQ(tools[0]["name"], "my_tool"); + EXPECT_EQ(tools[0]["name"], "simple_tool"); EXPECT_EQ(tools[0]["title"], "My Tool"); EXPECT_EQ(tools[0]["description"], "Does something."); EXPECT_EQ(tools[0]["inputSchema"]["type"], "object"); @@ -1336,6 +1338,7 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { EXPECT_EQ(tools[1]["description"], "Has\\nspecial \"chars\" & \\t stuff."); EXPECT_EQ(tools[1]["inputSchema"]["type"], "object"); EXPECT_EQ(tools[1]["inputSchema"]["properties"]["a"]["type"], "string"); + EXPECT_EQ(tools[1]["outputSchema"]["type"], "string"); })); EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); From 4cba1a0a98be6fcf3f8531bf80af5e924a07aaf3 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 12 May 2026 16:23:27 +0000 Subject: [PATCH 04/16] http: unify MCP tools configuration across route scopes This introduces a ToolsListLocal message inside a tool_list_config oneof, deprecating the separate tool_list_http_rule. Additionally, this refactors the McpJsonRestBridgeFilter core logic to introduce getCombinedTools() and getTool(). These helper methods abstract iterating through the per_route_config tools list followed by the main config_->toolConfig() tools list, building an effective combined array of tools, while correctly enforcing the rule that if two tools share the same name, the per-route definition shadows the global one. Signed-off-by: Michael Behr --- .../v3/mcp_json_rest_bridge.proto | 26 ++-- changelogs/current.yaml | 5 + .../mcp_json_rest_bridge_filter.cc | 121 +++++++++++++----- .../mcp_json_rest_bridge_filter.h | 16 ++- .../mcp_json_rest_bridge_filter_test.cc | 16 ++- .../mcp_json_rest_bridge_integration_test.cc | 1 + 6 files changed, 134 insertions(+), 51 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index 04722f619b1f8..cec3b50eae062 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -147,6 +147,9 @@ message ServerInfo { google.protobuf.StringValue fallback_protocol_version = 3; } +message ToolsListLocal { +} + // Configuration for the MCP tool capability of the server. message ServerToolConfig { // List of MCP tools configurations. @@ -157,17 +160,22 @@ message ServerToolConfig { // Whether this server supports notifications for changes to the tool list. bool list_changed = 2; - // Optional configuration to transcode the tools/list requests to a standard HTTP request. + // Optional configuration for tools/list requests. If not set: The ``tools/list`` request is + // passed through. This allows subsequent extension or the backend itself to handle the tools/list + // request if they support it. // // Note: tools/list should be mapped to a GET request with an empty body. - // - // - If provided: The extension transcodes the request and forwards it down the filter chain. - // The response (whether from an upstream backend, a configured ``direct_response``, or another - // extension) MUST be a JSON body strictly matching the MCP ``ListToolsResult`` schema. - // Ref: https://modelcontextprotocol.io/specification/2025-11-25/schema#listtoolsresult - // - If not provided: The ``tools/list`` request is passed through. This allows subsequent - // extension or the backend itself to handle the tools/list request if they support it. - HttpRule tool_list_http_rule = 3; + oneof tool_list_config { + // Configuration to transcode the tools/list requests to a standard HTTP request. If provided: + // The extension transcodes the request and forwards it down the filter chain. The response + // (whether from an upstream backend, a configured ``direct_response``, or another extension) + // MUST be a JSON body strictly matching the MCP ``ListToolsResult`` schema. Ref: + // https://modelcontextprotocol.io/specification/2025-11-25/schema#listtoolsresult + HttpRule tool_list_http_rule = 3; + + // If provided: The extension sends a local response. + ToolsListLocal tool_list_local = 4; + } } // Configuration for a specific MCP tool. diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 0fc3636aa24fb..1e22dfeabf4d0 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -81,6 +81,11 @@ removed_config_or_runtime: # *Normally occurs at the end of the* :ref:`deprecation period ` new_features: +- area: mcp_json_rest_bridge + change: | + Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge filter. + Configuring ``tools_list_local`` on a per-route basis will cause the filter to directly generate and + serve the available tools list response without sending a request upstream. - area: tcp_proxy change: | Added :ref:`check_drain_close diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 9ce095b1deeea..28317361f6191 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -160,12 +160,54 @@ McpJsonRestBridgeFilterConfig::getHttpRule(absl::string_view tool_name) const { return it->second; } -absl::StatusOr -McpJsonRestBridgeFilterConfig::getToolsListHttpRule() const { - if (!proto_config_.tool_config().has_tool_list_http_rule()) { - return absl::NotFoundError("tools_list_http_rule is not configured."); +std::vector +McpJsonRestBridgeFilter::getCombinedTools() const { + absl::flat_hash_set seen_tools; + std::vector + combined; + + const auto* per_route_config = + Http::Utility::resolveMostSpecificPerFilterConfig( + decoder_callbacks_); + + if (per_route_config) { + for (const auto& tool : per_route_config->toolConfig().tools()) { + if (seen_tools.insert(tool.name()).second) { + combined.push_back(&tool); + } + } } - return proto_config_.tool_config().tool_list_http_rule(); + + for (const auto& tool : config_->toolConfig().tools()) { + if (seen_tools.insert(tool.name()).second) { + combined.push_back(&tool); + } + } + + return combined; +} + +const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* +McpJsonRestBridgeFilter::getTool(absl::string_view tool_name) const { + const auto* per_route_config = + Http::Utility::resolveMostSpecificPerFilterConfig( + decoder_callbacks_); + + if (per_route_config) { + for (const auto& tool : per_route_config->toolConfig().tools()) { + if (tool.name() == tool_name) { + return &tool; + } + } + } + + for (const auto& tool : config_->toolConfig().tools()) { + if (tool.name() == tool_name) { + return &tool; + } + } + + return nullptr; } Http::FilterHeadersStatus @@ -331,8 +373,7 @@ Http::FilterTrailersStatus McpJsonRestBridgeFilter::encodeTrailers(Http::Respons // Send a local reply for a tools/list call. Construct the response body directly instead of // building a JSON object, to minimize copying. -void McpJsonRestBridgeFilter::serveToolsListLocal( - const McpJsonRestBridgePerRouteConfig& per_route_config, const nlohmann::json& json_rpc) { +void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc) { std::string request_id_json = "null"; if (json_rpc.contains("id")) { request_id_json = json_rpc["id"].dump(); @@ -344,37 +385,37 @@ void McpJsonRestBridgeFilter::serveToolsListLocal( response_data.add(",\"result\":{\"tools\":["); bool first_tool = true; - for (const auto& tool : per_route_config.toolConfig().tools()) { + for (const auto* tool : getCombinedTools()) { if (!first_tool) { response_data.add(","); } first_tool = false; response_data.add("{"); - nlohmann::json name_json = tool.name(); + nlohmann::json name_json = tool->name(); response_data.add("\"name\":"); response_data.add(name_json.dump()); - if (!tool.tool_list_config().title().empty()) { - nlohmann::json title_json = tool.tool_list_config().title(); + if (!tool->tool_list_config().title().empty()) { + nlohmann::json title_json = tool->tool_list_config().title(); response_data.add(",\"title\":"); response_data.add(title_json.dump()); } - nlohmann::json desc_json = tool.tool_list_config().description(); + nlohmann::json desc_json = tool->tool_list_config().description(); response_data.add(",\"description\":"); response_data.add(desc_json.dump()); response_data.add(",\"inputSchema\":"); - if (!tool.tool_list_config().input_schema().empty()) { - response_data.add(tool.tool_list_config().input_schema()); + if (!tool->tool_list_config().input_schema().empty()) { + response_data.add(tool->tool_list_config().input_schema()); } else { response_data.add("{\"type\":\"object\"}"); } - if (!tool.tool_list_config().output_schema().empty()) { + if (!tool->tool_list_config().output_schema().empty()) { response_data.add(",\"outputSchema\":"); - response_data.add(tool.tool_list_config().output_schema()); + response_data.add(tool->tool_list_config().output_schema()); } response_data.add("}"); @@ -407,19 +448,29 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, generateErrorJsonResponse(-32602, "Unsupported protocol version").dump()); return; } - // TODO(guoyilin42): Consider supporting local response for tools/list in addition to the GET. if (method == McpConstants::Methods::TOOLS_LIST) { - mcp_operation_ = McpOperation::ToolsListLocal; const auto* per_route_config = Http::Utility::resolveMostSpecificPerFilterConfig( decoder_callbacks_); - if (per_route_config && !per_route_config->toolConfig().tools().empty()) { - serveToolsListLocal(*per_route_config, json_rpc); - return; + + bool has_tool_list_http_rule = config_->toolConfig().has_tool_list_http_rule(); + bool has_tool_list_local = config_->toolConfig().has_tool_list_local(); + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::HttpRule* http_rule = + has_tool_list_http_rule ? &config_->toolConfig().tool_list_http_rule() : nullptr; + + if (per_route_config) { + if (per_route_config->toolConfig().has_tool_list_http_rule()) { + has_tool_list_http_rule = true; + has_tool_list_local = false; + http_rule = &per_route_config->toolConfig().tool_list_http_rule(); + } else if (per_route_config->toolConfig().has_tool_list_local()) { + has_tool_list_local = true; + has_tool_list_http_rule = false; + http_rule = nullptr; + } } - absl::StatusOr http_rule = - config_->getToolsListHttpRule(); - if (http_rule.ok() && !http_rule->get().empty()) { + + if (has_tool_list_http_rule) { mcp_operation_ = McpOperation::ToolsList; // We don't support pagination for the tools/list request for now. if (request_headers.has_value()) { @@ -436,12 +487,17 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, if (decoder_callbacks_->downstreamCallbacks().has_value()) { decoder_callbacks_->downstreamCallbacks()->clearRouteCache(); } - } else { - // TODO(guoyilin42): Handle this more elegantly to avoid an unnecessary copy here. This can - // be addressed later when the JSON parser is updated. - mcp_operation_ = McpOperation::Unspecified; - request_body_str_ = json_rpc.dump(); + return; + } else if (has_tool_list_local && !getCombinedTools().empty()) { + mcp_operation_ = McpOperation::ToolsListLocal; + serveToolsListLocal(json_rpc); + return; } + + // TODO(guoyilin42): Handle this more elegantly to avoid an unnecessary copy here. This can + // be addressed later when the JSON parser is updated. + mcp_operation_ = McpOperation::Unspecified; + request_body_str_ = json_rpc.dump(); } else if (method == McpConstants::Methods::INITIALIZE) { mcp_operation_ = McpOperation::Initialization; if (json_rpc.contains(McpConstants::PARAMS_FIELD) && @@ -590,9 +646,8 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ } const auto& tool_name = name_it->get(); - absl::StatusOr http_rule = - config_->getHttpRule(tool_name); - if (!http_rule.ok()) { + const auto* tool = getTool(tool_name); + if (!tool || !tool->has_http_rule()) { ENVOY_STREAM_LOG(error, "Failed to get http rule for method: {}", *decoder_callbacks_, tool_name); sendErrorResponse(Http::Code::BadRequest, "mcp_json_rest_bridge_filter_unknown_tool", @@ -612,7 +667,7 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ const nlohmann::json empty_arguments = nlohmann::json::object(); const nlohmann::json& arguments = arguments_it != params.end() ? *arguments_it : empty_arguments; - absl::StatusOr http_request = buildHttpRequest(*http_rule, arguments); + absl::StatusOr http_request = buildHttpRequest(tool->http_rule(), arguments); if (!http_request.ok()) { ENVOY_STREAM_LOG(error, "Failed to build HTTP request for method: {} with status: {}", *decoder_callbacks_, tool_name, http_request.status()); diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index 3ba9b44ea365f..0765da3f48f50 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -35,14 +35,17 @@ class McpJsonRestBridgeFilterConfig : public Logger::Loggable getHttpRule(absl::string_view tool_name) const; - absl::StatusOr - getToolsListHttpRule() const; const std::string& fallbackProtocolVersion() const { return fallback_protocol_version_; } uint32_t maxRequestBodySize() const { return max_request_body_size_; } uint32_t maxResponseBodySize() const { return max_response_body_size_; } + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ServerToolConfig& + toolConfig() const { + return proto_config_.tool_config(); + } + private: absl::flat_hash_map @@ -95,8 +98,13 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, Http::RequestHeaderMapOptRef request_headers); // Serves a local tools/list response using the per-route configuration, bypassing upstream. - void serveToolsListLocal(const McpJsonRestBridgePerRouteConfig& per_route_config, - const nlohmann::json& json_rpc); + void serveToolsListLocal(const nlohmann::json& json_rpc); + + std::vector + getCombinedTools() const; + + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* + getTool(absl::string_view tool_name) const; // Modifies the response from upstream into JSON-RPC response. void encodeJsonRpcData(Http::ResponseHeaderMapOptRef response_headers); diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 3efdab60c10fb..77845b4a97ad4 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1297,18 +1297,24 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { tool2->mutable_tool_list_config()->set_description("Has\\nspecial \"chars\" & \\t stuff."); tool2->mutable_tool_list_config()->set_input_schema( R"({"type": "object", "properties": {"a": {"type": "string"}}})"); - tool2->mutable_tool_list_config()->set_output_schema( - R"({"type": "string"})"); + tool2->mutable_tool_list_config()->set_output_schema(R"({"type": "string"})"); + + override_config.mutable_tool_config()->mutable_tool_list_local(); McpJsonRestBridgePerRouteConfig override(override_config); ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) .WillByDefault(testing::Return(&override)); - Http::TestRequestHeaderMapImpl headers{ - {":method", "POST"}, {":path", "/mcp"}, {"content-type", "application/json"}}; + EXPECT_CALL(decoder_callbacks_, requestHeaders()) + .WillRepeatedly(testing::Return(Http::RequestHeaderMapOptRef(request_headers_))); + + request_headers_.setMethod("POST"); + request_headers_.setPath("/mcp"); + request_headers_.setContentType("application/json"); - EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, false)); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers_, false)); std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": "req-1"})"; Buffer::OwnedImpl data(json); diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc index fe9066040ea3f..39113c36df04b 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_integration_test.cc @@ -561,6 +561,7 @@ TEST_P(McpJsonRestBridgeIntegrationTest, ToolsListLocalResponse) { v3::HttpConnectionManager& hcm) { auto* route = hcm.mutable_route_config()->mutable_virtual_hosts(0)->mutable_routes(0); envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute per_route; + per_route.mutable_tool_config()->mutable_tool_list_local(); auto* tool = per_route.mutable_tool_config()->add_tools(); tool->set_name("my_local_tool"); tool->mutable_tool_list_config()->set_title("My Local Tool"); From a92c04759dff3b8cd95956da08230e885efe4510 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 12 May 2026 18:06:34 +0000 Subject: [PATCH 05/16] http: refine MCP tools configuration and edge cases - Renames getCombinedTools to getTools. - Adds handling for tools_list_local when no tools are defined. - Updates changelog to clarify tools_list_local scope. - Adds TODO for tool lookup optimization. Signed-off-by: Michael Behr --- changelogs/current.yaml | 2 +- .../mcp_json_rest_bridge_filter.cc | 8 ++-- .../mcp_json_rest_bridge_filter.h | 2 +- .../mcp_json_rest_bridge_filter_test.cc | 39 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 1e22dfeabf4d0..da6cd86ffa745 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -84,7 +84,7 @@ new_features: - area: mcp_json_rest_bridge change: | Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge filter. - Configuring ``tools_list_local`` on a per-route basis will cause the filter to directly generate and + Configuring ``tools_list_local`` will cause the filter to directly generate and serve the available tools list response without sending a request upstream. - area: tcp_proxy change: | diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 28317361f6191..9734ed5b3c080 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -161,7 +161,7 @@ McpJsonRestBridgeFilterConfig::getHttpRule(absl::string_view tool_name) const { } std::vector -McpJsonRestBridgeFilter::getCombinedTools() const { +McpJsonRestBridgeFilter::getTools() const { absl::flat_hash_set seen_tools; std::vector combined; @@ -189,6 +189,8 @@ McpJsonRestBridgeFilter::getCombinedTools() const { const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* McpJsonRestBridgeFilter::getTool(absl::string_view tool_name) const { + // TODO(mkbehr): Preprocess or cache a lookup table if we ever need to check many tools per + // request. const auto* per_route_config = Http::Utility::resolveMostSpecificPerFilterConfig( decoder_callbacks_); @@ -385,7 +387,7 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc response_data.add(",\"result\":{\"tools\":["); bool first_tool = true; - for (const auto* tool : getCombinedTools()) { + for (const auto* tool : getTools()) { if (!first_tool) { response_data.add(","); } @@ -488,7 +490,7 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, decoder_callbacks_->downstreamCallbacks()->clearRouteCache(); } return; - } else if (has_tool_list_local && !getCombinedTools().empty()) { + } else if (has_tool_list_local) { mcp_operation_ = McpOperation::ToolsListLocal; serveToolsListLocal(json_rpc); return; diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index 0765da3f48f50..7fb6d02d223d7 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -101,7 +101,7 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, void serveToolsListLocal(const nlohmann::json& json_rpc); std::vector - getCombinedTools() const; + getTools() const; const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* getTool(absl::string_view tool_name) const; diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 77845b4a97ad4..9c93999f27e7e 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1350,6 +1350,45 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); } +TEST_F(McpJsonRestBridgeFilterTest, ToolsListLocalEmpty) { + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge proto_config; + proto_config.mutable_tool_config()->mutable_tool_list_local(); + auto config = std::make_shared(proto_config); + filter_ = std::make_unique(config); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + + EXPECT_CALL(decoder_callbacks_, requestHeaders()) + .WillRepeatedly(testing::Return(Http::RequestHeaderMapOptRef(request_headers_))); + + request_headers_.setMethod("POST"); + request_headers_.setPath("/mcp"); + request_headers_.setContentType("application/json"); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers_, false)); + + std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": "req-1"})"; + Buffer::OwnedImpl data(json); + + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(testing::_, false)) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { + EXPECT_EQ("200", headers.getStatusValue()); + EXPECT_EQ("application/json", headers.getContentTypeValue()); + })); + EXPECT_CALL(decoder_callbacks_, encodeData(testing::_, true)) + .WillOnce(testing::Invoke([](Buffer::Instance& data, bool) { + auto parsed_response = nlohmann::json::parse(data.toString()); + + EXPECT_EQ(parsed_response["jsonrpc"], "2.0"); + EXPECT_EQ(parsed_response["id"], "req-1"); + + auto tools = parsed_response["result"]["tools"]; + EXPECT_TRUE(tools.empty()); + })); + + EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); +} } // namespace } // namespace McpJsonRestBridge } // namespace HttpFilters From e2b8687024d274e4164913b48ce803a2b14d10ff Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 12 May 2026 20:48:15 +0000 Subject: [PATCH 06/16] http: fix missing buffer_lib dependency in MCP JSON REST bridge filter Signed-off-by: Michael Behr --- source/extensions/filters/http/mcp_json_rest_bridge/BUILD | 1 + test/extensions/filters/http/mcp_json_rest_bridge/BUILD | 1 + 2 files changed, 2 insertions(+) diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/BUILD b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD index b7df6e51323bc..45af1ed7e33b2 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/BUILD +++ b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD @@ -17,6 +17,7 @@ envoy_cc_library( ":http_request_builder_lib", "//envoy/http:filter_interface", "//envoy/server:filter_config_interface", + "//source/common/buffer:buffer_lib", "//source/common/common:logger_lib", "//source/common/http:headers_lib", "//source/common/protobuf", diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/BUILD b/test/extensions/filters/http/mcp_json_rest_bridge/BUILD index 6ed94531697db..06b4c4c36b95e 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/BUILD +++ b/test/extensions/filters/http/mcp_json_rest_bridge/BUILD @@ -23,6 +23,7 @@ envoy_cc_test( name = "mcp_json_rest_bridge_filter_test", srcs = ["mcp_json_rest_bridge_filter_test.cc"], deps = [ + "//source/common/buffer:buffer_lib", "//source/extensions/filters/http/mcp_json_rest_bridge:mcp_json_rest_bridge_filter_lib", "//test/mocks/http:http_mocks", "//test/test_common:utility_lib", From 1649507166797412deafe18c062361306536721b Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 12 May 2026 21:09:37 +0000 Subject: [PATCH 07/16] http: fix MCP JSON REST bridge config factory registration metadata and comments - Resolves missing PerRoute extension type from extensions_metadata.yaml - Corrects placement of the REGISTER_FACTORY documentation comment in config.cc Signed-off-by: Michael Behr --- source/extensions/extensions_metadata.yaml | 1 + .../extensions/filters/http/mcp_json_rest_bridge/config.cc | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index d257439d04399..39829bd6ae4ba 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -643,6 +643,7 @@ envoy.filters.http.mcp_json_rest_bridge: status: alpha type_urls: - envoy.extensions.filters.http.mcp_json_rest_bridge.v3.McpJsonRestBridge + - envoy.extensions.filters.http.mcp_json_rest_bridge.v3.McpJsonRestBridgePerRoute envoy.filters.http.mcp_router: categories: - envoy.filters.http diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/config.cc b/source/extensions/filters/http/mcp_json_rest_bridge/config.cc index bbcab4d350556..d10b9b6557a2d 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/config.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/config.cc @@ -32,9 +32,6 @@ McpJsonRestBridgeFilterConfigFactory::createFilterFactoryFromProtoTyped( }; } -/** - * Static registration for the MCP JSON REST bridge filter. @see RegisterFactory. - */ absl::StatusOr McpJsonRestBridgeFilterConfigFactory::createRouteSpecificFilterConfigTyped( const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute& @@ -43,6 +40,9 @@ McpJsonRestBridgeFilterConfigFactory::createRouteSpecificFilterConfigTyped( return std::make_shared(proto_config); } +/** + * Static registration for the MCP JSON REST bridge filter. @see RegisterFactory. + */ REGISTER_FACTORY(McpJsonRestBridgeFilterConfigFactory, Server::Configuration::NamedHttpFilterConfigFactory); From 8104b9769f29d9c3f3be25fb58e8d8a92178fd6c Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Wed, 13 May 2026 19:55:15 +0000 Subject: [PATCH 08/16] Improve API comments and remove McpServerInfo. Signed-off-by: Michael Behr --- .../v3/mcp_json_rest_bridge.proto | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index cec3b50eae062..41430c77725ca 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -147,10 +147,12 @@ message ServerInfo { google.protobuf.StringValue fallback_protocol_version = 3; } +// Configuration for sending locally-generated responses to tools/list requests. message ToolsListLocal { } // Configuration for the MCP tool capability of the server. +// [#next-free-field: 5] message ServerToolConfig { // List of MCP tools configurations. repeated ToolConfig tools = 1; @@ -163,8 +165,6 @@ message ServerToolConfig { // Optional configuration for tools/list requests. If not set: The ``tools/list`` request is // passed through. This allows subsequent extension or the backend itself to handle the tools/list // request if they support it. - // - // Note: tools/list should be mapped to a GET request with an empty body. oneof tool_list_config { // Configuration to transcode the tools/list requests to a standard HTTP request. If provided: // The extension transcodes the request and forwards it down the filter chain. The response @@ -173,12 +173,14 @@ message ServerToolConfig { // https://modelcontextprotocol.io/specification/2025-11-25/schema#listtoolsresult HttpRule tool_list_http_rule = 3; - // If provided: The extension sends a local response. + // If provided: The extension sends a local response, according to each tool's + // ToolsListSpecificConfig. ToolsListLocal tool_list_local = 4; } } -// Configuration for a specific MCP tool. +// Configuration for a tool's entry in tools/list responses. +// [#next-free-field: 5] message ToolsListSpecificConfig { // Optional, human-readable name of the tool for display purposes. string title = 1; @@ -196,12 +198,7 @@ message ToolsListSpecificConfig { string output_schema = 4; } -// Path and host of MCP server that hosts this tool -message McpServerInfo { - string path = 1; - string host = 2; -} - +// [#next-free-field: 4] message ToolConfig { // Unique identifier of the tool. Used both for tools/list and tools/call transcoding. string name = 1 [(validate.rules).string = {min_len: 1}]; @@ -209,9 +206,9 @@ message ToolConfig { // The HTTP configuration rules that apply to the normal backend. HttpRule http_rule = 2; + // Config for this tool's entry in a local tools/list response. Used when tool_list_local is set + // in the ServerToolConfig. ToolsListSpecificConfig tool_list_config = 3; - - McpServerInfo server_info = 4; } // Defines the schema of the JSON-RPC to REST mapping. It specifies how the "arguments" From 1a35c9fd3c172b3ad1193909c54d02e4e91872cf Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Wed, 13 May 2026 20:38:06 +0000 Subject: [PATCH 09/16] Format fixes Signed-off-by: Michael Behr --- .../http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto | 3 --- changelogs/current.yaml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index 41430c77725ca..374162aee1749 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -152,7 +152,6 @@ message ToolsListLocal { } // Configuration for the MCP tool capability of the server. -// [#next-free-field: 5] message ServerToolConfig { // List of MCP tools configurations. repeated ToolConfig tools = 1; @@ -180,7 +179,6 @@ message ServerToolConfig { } // Configuration for a tool's entry in tools/list responses. -// [#next-free-field: 5] message ToolsListSpecificConfig { // Optional, human-readable name of the tool for display purposes. string title = 1; @@ -198,7 +196,6 @@ message ToolsListSpecificConfig { string output_schema = 4; } -// [#next-free-field: 4] message ToolConfig { // Unique identifier of the tool. Used both for tools/list and tools/call transcoding. string name = 1 [(validate.rules).string = {min_len: 1}]; diff --git a/changelogs/current.yaml b/changelogs/current.yaml index c73366ab2dee8..2066d36beef56 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -84,7 +84,7 @@ new_features: - area: mcp_json_rest_bridge change: | Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge filter. - Configuring ``tools_list_local`` will cause the filter to directly generate and + Configuring ``tools_list_local`` will cause the filter to directly generate and serve the available tools list response without sending a request upstream. - area: router change: | From ea327fc8992bf95c8c024c24dea923f3763d3dc8 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Thu, 14 May 2026 18:24:04 +0000 Subject: [PATCH 10/16] fix spelling Signed-off-by: Michael Behr --- .../http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 9734ed5b3c080..84f67070de2af 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -189,7 +189,7 @@ McpJsonRestBridgeFilter::getTools() const { const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* McpJsonRestBridgeFilter::getTool(absl::string_view tool_name) const { - // TODO(mkbehr): Preprocess or cache a lookup table if we ever need to check many tools per + // TODO(mkbehr): Pre-process or cache a lookup table if we ever need to check many tools per // request. const auto* per_route_config = Http::Utility::resolveMostSpecificPerFilterConfig( From c6a3d8e678b73b77843a0c1de3450a8460d8ecf0 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 19 May 2026 17:30:38 +0000 Subject: [PATCH 11/16] http: update mcp json rest bridge per review feedback - Remove output_schema from the configuration. - Override static config with per-route config instead of merging. - Return 204 No Content for JSON-RPC notifications (requests without an ID). - Add tests for missing coverage, including encodeTrailers. - Optimize tools/list response allocation with reserveSingleSlice. - Latch route and per_route_config at the start of decodeData. Signed-off-by: Michael Behr --- .../v3/mcp_json_rest_bridge.proto | 4 +- .../mcp_json_rest_bridge_filter.cc | 119 +++++++-------- .../mcp_json_rest_bridge_filter.h | 18 +-- .../mcp_json_rest_bridge_filter_test.cc | 135 +++++++++++++----- 4 files changed, 164 insertions(+), 112 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index 374162aee1749..61de3275d50e7 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -192,8 +192,8 @@ message ToolsListSpecificConfig { // parameters object, or set to '"additionalProperties": false' to require empty parameters. string input_schema = 3; - // Optional output schema. If present, uses the same format as input_schema. - string output_schema = 4; + reserved 4; + reserved "output_schema"; } message ToolConfig { diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 84f67070de2af..58e37e4dc488e 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -160,56 +160,23 @@ McpJsonRestBridgeFilterConfig::getHttpRule(absl::string_view tool_name) const { return it->second; } -std::vector -McpJsonRestBridgeFilter::getTools() const { - absl::flat_hash_set seen_tools; - std::vector - combined; - - const auto* per_route_config = - Http::Utility::resolveMostSpecificPerFilterConfig( - decoder_callbacks_); - - if (per_route_config) { - for (const auto& tool : per_route_config->toolConfig().tools()) { - if (seen_tools.insert(tool.name()).second) { - combined.push_back(&tool); - } - } - } - - for (const auto& tool : config_->toolConfig().tools()) { - if (seen_tools.insert(tool.name()).second) { - combined.push_back(&tool); - } +McpJsonRestBridgePerRouteConfig::McpJsonRestBridgePerRouteConfig( + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute& + proto_config) + : tool_config_(proto_config.tool_config()) { + for (const auto& tool : tool_config_.tools()) { + tool_to_http_rule_[tool.name()] = tool.http_rule(); } - - return combined; } -const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* -McpJsonRestBridgeFilter::getTool(absl::string_view tool_name) const { - // TODO(mkbehr): Pre-process or cache a lookup table if we ever need to check many tools per - // request. - const auto* per_route_config = - Http::Utility::resolveMostSpecificPerFilterConfig( - decoder_callbacks_); - - if (per_route_config) { - for (const auto& tool : per_route_config->toolConfig().tools()) { - if (tool.name() == tool_name) { - return &tool; - } - } - } - - for (const auto& tool : config_->toolConfig().tools()) { - if (tool.name() == tool_name) { - return &tool; - } +absl::StatusOr +McpJsonRestBridgePerRouteConfig::getHttpRule(absl::string_view tool_name) const { + auto it = tool_to_http_rule_.find(tool_name); + if (it == tool_to_http_rule_.end()) { + return absl::InvalidArgumentError( + fmt::format("Failed to find http rule for tool_name: {}", tool_name)); } - - return nullptr; + return it->second; } Http::FilterHeadersStatus @@ -251,6 +218,14 @@ Http::FilterDataStatus McpJsonRestBridgeFilter::decodeData(Buffer::Instance& dat return Http::FilterDataStatus::Continue; } + if (!route_) { + route_ = decoder_callbacks_->routeSharedPtr(); + if (route_) { + per_route_config_ = dynamic_cast( + route_->mostSpecificPerFilterConfig("envoy.filters.http.mcp_json_rest_bridge")); + } + } + const uint32_t max_request_body_size = config_->maxRequestBodySize(); if (max_request_body_size > 0 && (request_body_.length() + data.length()) > max_request_body_size) { @@ -373,6 +348,11 @@ Http::FilterTrailersStatus McpJsonRestBridgeFilter::encodeTrailers(Http::Respons return Http::FilterTrailersStatus::Continue; } +void McpJsonRestBridgeFilter::onDestroy() { + route_.reset(); + per_route_config_ = nullptr; +} + // Send a local reply for a tools/list call. Construct the response body directly instead of // building a JSON object, to minimize copying. void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc) { @@ -386,8 +366,12 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc response_data.add(request_id_json); response_data.add(",\"result\":{\"tools\":["); + const auto& tools = + per_route_config_ ? per_route_config_->toolConfig().tools() : config_->toolConfig().tools(); + bool first_tool = true; - for (const auto* tool : getTools()) { + for (const auto& tool_proto : tools) { + const auto* tool = &tool_proto; if (!first_tool) { response_data.add(","); } @@ -415,11 +399,6 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc response_data.add("{\"type\":\"object\"}"); } - if (!tool->tool_list_config().output_schema().empty()) { - response_data.add(",\"outputSchema\":"); - response_data.add(tool->tool_list_config().output_schema()); - } - response_data.add("}"); } @@ -451,21 +430,17 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, return; } if (method == McpConstants::Methods::TOOLS_LIST) { - const auto* per_route_config = - Http::Utility::resolveMostSpecificPerFilterConfig( - decoder_callbacks_); - bool has_tool_list_http_rule = config_->toolConfig().has_tool_list_http_rule(); bool has_tool_list_local = config_->toolConfig().has_tool_list_local(); const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::HttpRule* http_rule = has_tool_list_http_rule ? &config_->toolConfig().tool_list_http_rule() : nullptr; - if (per_route_config) { - if (per_route_config->toolConfig().has_tool_list_http_rule()) { + if (per_route_config_) { + if (per_route_config_->toolConfig().has_tool_list_http_rule()) { has_tool_list_http_rule = true; has_tool_list_local = false; - http_rule = &per_route_config->toolConfig().tool_list_http_rule(); - } else if (per_route_config->toolConfig().has_tool_list_local()) { + http_rule = &per_route_config_->toolConfig().tool_list_http_rule(); + } else if (per_route_config_->toolConfig().has_tool_list_local()) { has_tool_list_local = true; has_tool_list_http_rule = false; http_rule = nullptr; @@ -648,8 +623,18 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ } const auto& tool_name = name_it->get(); - const auto* tool = getTool(tool_name); - if (!tool || !tool->has_http_rule()) { + const auto* per_route_config = + Http::Utility::resolveMostSpecificPerFilterConfig( + decoder_callbacks_); + + absl::StatusOr http_rule; + if (per_route_config) { + http_rule = per_route_config->getHttpRule(tool_name); + } else { + http_rule = config_->getHttpRule(tool_name); + } + + if (!http_rule.ok()) { ENVOY_STREAM_LOG(error, "Failed to get http rule for method: {}", *decoder_callbacks_, tool_name); sendErrorResponse(Http::Code::BadRequest, "mcp_json_rest_bridge_filter_unknown_tool", @@ -669,7 +654,7 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ const nlohmann::json empty_arguments = nlohmann::json::object(); const nlohmann::json& arguments = arguments_it != params.end() ? *arguments_it : empty_arguments; - absl::StatusOr http_request = buildHttpRequest(tool->http_rule(), arguments); + absl::StatusOr http_request = buildHttpRequest(*http_rule, arguments); if (!http_request.ok()) { ENVOY_STREAM_LOG(error, "Failed to build HTTP request for method: {} with status: {}", *decoder_callbacks_, tool_name, http_request.status()); @@ -736,9 +721,11 @@ absl::Status McpJsonRestBridgeFilter::validateJsonRpcIdAndMethod(const nlohmann: // The notifications/initialized request is not required to have an ID // field. } else if (!session_id.ok()) { - sendErrorResponse(Http::Code::BadRequest, "mcp_json_rest_bridge_filter_id_not_found", - generateErrorJsonResponse(-32600, "Missing ID field").dump()); - return absl::InvalidArgumentError("Missing ID field"); + mcp_operation_ = McpOperation::InitializationAck; + decoder_callbacks_->sendLocalReply(Http::Code::NoContent, "", nullptr, + Grpc::Status::WellKnownGrpcStatus::Ok, + "mcp_json_rest_bridge_filter_notification"); + return absl::InvalidArgumentError("Notification request"); } return absl::OkStatus(); } diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index 7fb6d02d223d7..dc22379250bc8 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -60,8 +60,10 @@ class McpJsonRestBridgePerRouteConfig : public Router::RouteSpecificFilterConfig public: explicit McpJsonRestBridgePerRouteConfig( const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute& - proto_config) - : tool_config_(proto_config.tool_config()) {} + proto_config); + + absl::StatusOr + getHttpRule(absl::string_view tool_name) const; const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ServerToolConfig& toolConfig() const { @@ -69,6 +71,9 @@ class McpJsonRestBridgePerRouteConfig : public Router::RouteSpecificFilterConfig } private: + absl::flat_hash_map + tool_to_http_rule_; const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ServerToolConfig tool_config_; }; @@ -91,6 +96,7 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, bool end_stream) override; Http::FilterDataStatus encodeData(Buffer::Instance& data, bool end_stream) override; Http::FilterTrailersStatus encodeTrailers(Http::ResponseTrailerMap& trailers) override; + void onDestroy() override; private: // Handles "method" field in the MCP request. @@ -100,12 +106,6 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, // Serves a local tools/list response using the per-route configuration, bypassing upstream. void serveToolsListLocal(const nlohmann::json& json_rpc); - std::vector - getTools() const; - - const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ToolConfig* - getTool(absl::string_view tool_name) const; - // Modifies the response from upstream into JSON-RPC response. void encodeJsonRpcData(Http::ResponseHeaderMapOptRef response_headers); @@ -147,6 +147,8 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, std::string response_body_str_; McpJsonRestBridgeFilterConfigSharedPtr config_; + Router::RouteConstSharedPtr route_; + const McpJsonRestBridgePerRouteConfig* per_route_config_{nullptr}; }; } // namespace McpJsonRestBridge diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 9c93999f27e7e..932b90fb5edd6 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -246,34 +246,22 @@ TEST_F(McpJsonRestBridgeFilterTest, NonStringMethodReturnsError) { R"json({"error":{"code":-32601,"message":"Method field is not a string"},"id":0,"jsonrpc":"2.0"})json")); } -TEST_F(McpJsonRestBridgeFilterTest, MissingIdFieldReturnsError) { +TEST_F(McpJsonRestBridgeFilterTest, MissingIdFieldReturnsNoContent) { request_headers_ = {{":method", "POST"}, {":path", "/mcp"}}; EXPECT_EQ(filter_->decodeHeaders(request_headers_, /*end_stream=*/false), Http::FilterHeadersStatus::StopIteration); EXPECT_CALL(decoder_callbacks_, - sendLocalReply(Eq(Http::Code::BadRequest), - StrEq(R"json({"code":-32600,"message":"Missing ID field"})json"), _, _, - StrEq("mcp_json_rest_bridge_filter_id_not_found"))); + sendLocalReply(Eq(Http::Code::NoContent), StrEq(""), _, _, + StrEq("mcp_json_rest_bridge_filter_notification"))); Buffer::OwnedImpl body(R"json({"jsonrpc":"2.0","method":"tools/list"})json"); EXPECT_EQ(filter_->decodeData(body, /*end_stream=*/true), Http::FilterDataStatus::StopIterationNoBuffer); // Simulates how the router filter handles the local response. - response_headers_ = {{"content-type", "text/plain"}, {"content-length", "123456"}}; - EXPECT_EQ(filter_->encodeHeaders(response_headers_, /*end_stream=*/false), - Http::FilterHeadersStatus::StopIteration); - Buffer::OwnedImpl response_body(R"json({"code":-32600,"message":"Missing ID field"})json"); - EXPECT_EQ(filter_->encodeData(response_body, /*end_stream=*/true), - Http::FilterDataStatus::Continue); - EXPECT_THAT(response_headers_.getContentTypeValue(), StrEq("application/json")); - EXPECT_THAT(response_headers_.getContentLengthValue(), - StrEq(std::to_string(response_body.length()))); - EXPECT_EQ( - nlohmann::json::parse(response_body.toString()), - nlohmann::json::parse( - R"json({"error":{"code":-32600,"message":"Missing ID field"},"id":null,"jsonrpc":"2.0"})json")); + EXPECT_EQ(filter_->encodeHeaders(response_headers_, /*end_stream=*/true), + Http::FilterHeadersStatus::Continue); } TEST_F(McpJsonRestBridgeFilterTest, IdFieldWithNonNumericStringIsAccepted) { @@ -306,34 +294,22 @@ TEST_F(McpJsonRestBridgeFilterTest, IdFieldWithNonNumericStringIsAccepted) { R"json({"jsonrpc":"2.0","id":"string_id","result":{"tools":[{"name":"google.api.CreateApiKey"}]}})json")); } -TEST_F(McpJsonRestBridgeFilterTest, IdFieldWithFloatReturnsError) { +TEST_F(McpJsonRestBridgeFilterTest, IdFieldWithFloatReturnsNoContent) { request_headers_ = {{":method", "POST"}, {":path", "/mcp"}}; EXPECT_EQ(filter_->decodeHeaders(request_headers_, /*end_stream=*/false), Http::FilterHeadersStatus::StopIteration); EXPECT_CALL(decoder_callbacks_, - sendLocalReply(Eq(Http::Code::BadRequest), - StrEq(R"json({"code":-32600,"message":"Missing ID field"})json"), _, _, - StrEq("mcp_json_rest_bridge_filter_id_not_found"))); + sendLocalReply(Eq(Http::Code::NoContent), StrEq(""), _, _, + StrEq("mcp_json_rest_bridge_filter_notification"))); Buffer::OwnedImpl body(R"json({"jsonrpc":"2.0","id":123.45,"method":"tools/list"})json"); EXPECT_EQ(filter_->decodeData(body, /*end_stream=*/true), Http::FilterDataStatus::StopIterationNoBuffer); // Simulates how the router filter handles the local response. - response_headers_ = {{"content-type", "text/plain"}, {"content-length", "123456"}}; - EXPECT_EQ(filter_->encodeHeaders(response_headers_, /*end_stream=*/false), - Http::FilterHeadersStatus::StopIteration); - Buffer::OwnedImpl response_body(R"json({"code":-32600,"message":"Missing ID field"})json"); - EXPECT_EQ(filter_->encodeData(response_body, /*end_stream=*/true), - Http::FilterDataStatus::Continue); - EXPECT_THAT(response_headers_.getContentTypeValue(), StrEq("application/json")); - EXPECT_THAT(response_headers_.getContentLengthValue(), - StrEq(std::to_string(response_body.length()))); - EXPECT_EQ( - nlohmann::json::parse(response_body.toString()), - nlohmann::json::parse( - R"json({"error":{"code":-32600,"message":"Missing ID field"},"id":null,"jsonrpc":"2.0"})json")); + EXPECT_EQ(filter_->encodeHeaders(response_headers_, /*end_stream=*/true), + Http::FilterHeadersStatus::Continue); } TEST_F(McpJsonRestBridgeFilterTest, InvalidInputJsonReturnsError) { @@ -1278,6 +1254,12 @@ TEST_P(McpHttpMethodFilterTest, NonPostMethodsReturnMethodNotAllowed) { TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge proto_config; + + auto* static_tool = proto_config.mutable_tool_config()->add_tools(); + static_tool->set_name("static_tool"); + static_tool->mutable_tool_list_config()->set_title("Static Tool"); + static_tool->mutable_tool_list_config()->set_description("This should be overridden."); + auto config = std::make_shared(proto_config); filter_ = std::make_unique(config); filter_->setDecoderFilterCallbacks(decoder_callbacks_); @@ -1297,7 +1279,6 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { tool2->mutable_tool_list_config()->set_description("Has\\nspecial \"chars\" & \\t stuff."); tool2->mutable_tool_list_config()->set_input_schema( R"({"type": "object", "properties": {"a": {"type": "string"}}})"); - tool2->mutable_tool_list_config()->set_output_schema(R"({"type": "string"})"); override_config.mutable_tool_config()->mutable_tool_list_local(); @@ -1344,7 +1325,6 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { EXPECT_EQ(tools[1]["description"], "Has\\nspecial \"chars\" & \\t stuff."); EXPECT_EQ(tools[1]["inputSchema"]["type"], "object"); EXPECT_EQ(tools[1]["inputSchema"]["properties"]["a"]["type"], "string"); - EXPECT_EQ(tools[1]["outputSchema"]["type"], "string"); })); EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); @@ -1389,6 +1369,89 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListLocalEmpty) { EXPECT_EQ(Http::FilterDataStatus::StopIterationNoBuffer, filter_->decodeData(data, true)); } +TEST_F(McpJsonRestBridgeFilterTest, ToolsCallPerRouteConfig) { + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge proto_config; + auto* static_tool = proto_config.mutable_tool_config()->add_tools(); + static_tool->set_name("my_tool"); + static_tool->mutable_http_rule()->set_get("/static/path"); + + auto config = std::make_shared(proto_config); + filter_ = std::make_unique(config); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute + override_config; + auto* override_tool = override_config.mutable_tool_config()->add_tools(); + override_tool->set_name("my_tool"); + override_tool->mutable_http_rule()->set_get("/override/path"); + + McpJsonRestBridgePerRouteConfig override(override_config); + + ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(testing::Return(&override)); + + EXPECT_CALL(decoder_callbacks_, requestHeaders()) + .WillRepeatedly(testing::Return(Http::RequestHeaderMapOptRef(request_headers_))); + + request_headers_ = {{":path", "/mcp"}, {":method", "POST"}}; + + EXPECT_CALL(decoder_callbacks_.downstream_callbacks_, clearRouteCache()); + + EXPECT_EQ(filter_->decodeHeaders(request_headers_, /*end_stream=*/false), + Http::FilterHeadersStatus::StopIteration); + + Buffer::OwnedImpl body( + R"json({"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"my_tool","arguments":{}}})json"); + EXPECT_EQ(filter_->decodeData(body, /*end_stream=*/true), Http::FilterDataStatus::Continue); + + EXPECT_THAT(request_headers_.getPathValue(), StrEq("/override/path")); + EXPECT_THAT(request_headers_.getMethodValue(), StrEq("GET")); +} + +TEST_F(McpJsonRestBridgeFilterTest, ToolsCallPerRouteConfigOverridesStaticTool) { + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge proto_config; + auto* static_tool = proto_config.mutable_tool_config()->add_tools(); + static_tool->set_name("static_only_tool"); + static_tool->mutable_http_rule()->set_get("/static/path"); + + auto config = std::make_shared(proto_config); + filter_ = std::make_unique(config); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridgePerRoute + override_config; + + McpJsonRestBridgePerRouteConfig override(override_config); + + ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(testing::Return(&override)); + + EXPECT_CALL(decoder_callbacks_, requestHeaders()) + .WillRepeatedly(testing::Return(Http::RequestHeaderMapOptRef(request_headers_))); + + request_headers_ = {{":path", "/mcp"}, {":method", "POST"}}; + + EXPECT_EQ(filter_->decodeHeaders(request_headers_, /*end_stream=*/false), + Http::FilterHeadersStatus::StopIteration); + + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Eq(Http::Code::BadRequest), + StrEq(R"json({"code":-32602,"message":"Unknown tool"})json"), _, _, + StrEq("mcp_json_rest_bridge_filter_unknown_tool"))); + + Buffer::OwnedImpl body( + R"json({"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"static_only_tool","arguments":{}}})json"); + EXPECT_EQ(filter_->decodeData(body, /*end_stream=*/true), + Http::FilterDataStatus::StopIterationNoBuffer); +} + +TEST_F(McpJsonRestBridgeFilterTest, EncodeTrailersReturnsContinue) { + Http::TestResponseTrailerMapImpl response_trailers; + EXPECT_EQ(filter_->encodeTrailers(response_trailers), Http::FilterTrailersStatus::Continue); +} + } // namespace } // namespace McpJsonRestBridge } // namespace HttpFilters From 8cf5ce63611f0837e52208be682be2ec9bd6889f Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 19 May 2026 20:10:00 +0000 Subject: [PATCH 12/16] Code review Signed-off-by: Michael Behr --- changelogs/current.yaml | 5 ----- .../new_features/mcp_transcoder__tools_list_local.rst | 4 ++++ .../mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc | 1 + .../http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h | 3 ++- .../mcp_json_rest_bridge_filter_test.cc | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 changelogs/current/new_features/mcp_transcoder__tools_list_local.rst diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 2066d36beef56..778f4aa2c644a 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -81,11 +81,6 @@ removed_config_or_runtime: # *Normally occurs at the end of the* :ref:`deprecation period ` new_features: -- area: mcp_json_rest_bridge - change: | - Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge filter. - Configuring ``tools_list_local`` will cause the filter to directly generate and - serve the available tools list response without sending a request upstream. - area: router change: | Added support for ``refreshRouteCluster`` on weighted cluster routes. When a filter calls diff --git a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst new file mode 100644 index 0000000000000..1d7b5d4ab20d0 --- /dev/null +++ b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst @@ -0,0 +1,4 @@ +Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge +filter. Configuring ``tools_list_local`` will cause the filter to directly generate and serve the +available tools list response without sending a request upstream. This may be configured on a +per-route basis. diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 58e37e4dc488e..63915c4e36064 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -392,6 +392,7 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc response_data.add(",\"description\":"); response_data.add(desc_json.dump()); + // WARNING: assumes input_schema is trusted to be a valid JSON fragment. Does not validate. response_data.add(",\"inputSchema\":"); if (!tool->tool_list_config().input_schema().empty()) { response_data.add(tool->tool_list_config().input_schema()); diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index dc22379250bc8..6e074e8bbd766 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -131,7 +131,7 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, InitializationAck = 3, // Clients send a tools/list request to discover available tools. ToolsList = 4, - // Clients send a tools/list request that is handled locally via per-route config. + // Clients send a tools/list request that is handled locally. ToolsListLocal = 5, // Clients send a tools/call request to invoke a tool. ToolsCall = 6, @@ -146,6 +146,7 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, Buffer::OwnedImpl response_body_; std::string response_body_str_; + // Route and per-route config, latched on decodeData. McpJsonRestBridgeFilterConfigSharedPtr config_; Router::RouteConstSharedPtr route_; const McpJsonRestBridgePerRouteConfig* per_route_config_{nullptr}; diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 932b90fb5edd6..667fef795d2d3 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1284,7 +1284,7 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { McpJsonRestBridgePerRouteConfig override(override_config); - ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + ON_CALL(*decoder_callbacks_.route_, mostSpecificPerFilterConfig) .WillByDefault(testing::Return(&override)); EXPECT_CALL(decoder_callbacks_, requestHeaders()) @@ -1388,7 +1388,7 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsCallPerRouteConfig) { McpJsonRestBridgePerRouteConfig override(override_config); - ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + ON_CALL(*decoder_callbacks_.route_, mostSpecificPerFilterConfig) .WillByDefault(testing::Return(&override)); EXPECT_CALL(decoder_callbacks_, requestHeaders()) @@ -1425,7 +1425,7 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsCallPerRouteConfigOverridesStaticTool) McpJsonRestBridgePerRouteConfig override(override_config); - ON_CALL(decoder_callbacks_, mostSpecificPerFilterConfig()) + ON_CALL(*decoder_callbacks_.route_, mostSpecificPerFilterConfig) .WillByDefault(testing::Return(&override)); EXPECT_CALL(decoder_callbacks_, requestHeaders()) From 51fbd8218f26b68ad07c5399ccfe5bbeb871a4d2 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 19 May 2026 20:47:00 +0000 Subject: [PATCH 13/16] ASSERT valid response id Signed-off-by: Michael Behr --- source/extensions/filters/http/mcp_json_rest_bridge/BUILD | 1 + .../http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc | 3 +++ 2 files changed, 4 insertions(+) diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/BUILD b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD index 45af1ed7e33b2..95fcdcf55782e 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/BUILD +++ b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD @@ -18,6 +18,7 @@ envoy_cc_library( "//envoy/http:filter_interface", "//envoy/server:filter_config_interface", "//source/common/buffer:buffer_lib", + "//source/common/common:assert_lib", "//source/common/common:logger_lib", "//source/common/http:headers_lib", "//source/common/protobuf", diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 5557ad2cfcbdf..27a0782ec46eb 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -6,6 +6,7 @@ #include "envoy/http/filter.h" #include "envoy/http/header_map.h" +#include "source/common/common/assert.h" #include "source/common/http/header_map_impl.h" #include "source/common/http/headers.h" #include "source/common/http/utility.h" @@ -359,6 +360,8 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc std::string request_id_json = "null"; if (json_rpc.contains("id")) { request_id_json = json_rpc["id"].dump(); + } else { + ASSERT(false, "serveToolsListLocal requires an RPC ID"); } Buffer::OwnedImpl response_data; From 93f7d563ef580783e8fdd4f085bcecfa23bf8519 Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Tue, 19 May 2026 21:03:21 +0000 Subject: [PATCH 14/16] cleanup Signed-off-by: Michael Behr --- .../mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto | 3 --- .../new_features/mcp_transcoder__tools_list_local.rst | 4 ++-- .../mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc | 2 ++ .../mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h | 2 +- .../mcp_json_rest_bridge_filter_test.cc | 8 ++++---- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto index 27b590002af69..1ccbdbdfb8e91 100644 --- a/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto +++ b/api/envoy/extensions/filters/http/mcp_json_rest_bridge/v3/mcp_json_rest_bridge.proto @@ -211,9 +211,6 @@ message ToolsListSpecificConfig { // not "type". Tools with no parameters may omit this to signify a tool with no constraints on the // parameters object, or set to '"additionalProperties": false' to require empty parameters. string input_schema = 3; - - reserved 4; - reserved "output_schema"; } message ToolConfig { diff --git a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst index 1d7b5d4ab20d0..4e3f4dcb77010 100644 --- a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst +++ b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst @@ -1,4 +1,4 @@ -Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge -filter. Configuring ``tools_list_local`` will cause the filter to directly generate and serve the +Added local response handling for the `tools/list` JSON-RPC method in the MCP JSON REST bridge +filter. Configuring `tools_list_local` will cause the filter to directly generate and serve the available tools list response without sending a request upstream. This may be configured on a per-route basis. diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc index 27a0782ec46eb..b5c700c0d9f19 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.cc @@ -413,6 +413,8 @@ void McpJsonRestBridgeFilter::serveToolsListLocal(const nlohmann::json& json_rpc response_headers->setContentType(Http::Headers::get().ContentTypeValues.Json); response_headers->setContentLength(response_data.length()); + // Send the response via encodeHeaders/encodeData instead of sendLocalReply to avoid copying the + // response body. decoder_callbacks_->encodeHeaders(std::move(response_headers), false, "mcp_json_rest_bridge_tools_list"); diff --git a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h index d3fdca31a2ce1..92646a4c5e211 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter.h @@ -110,7 +110,7 @@ class McpJsonRestBridgeFilter : public Http::PassThroughFilter, void handleMcpMethod(const nlohmann::json& json_rpc, Http::RequestHeaderMapOptRef request_headers); - // Serves a local tools/list response using the per-route configuration, bypassing upstream. + // Serves a local tools/list response using tools' ToolsListSpecificConfig. void serveToolsListLocal(const nlohmann::json& json_rpc); // Modifies the response from upstream into JSON-RPC response. diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc index 8c4086f9e2f49..97dc0470a4c45 100644 --- a/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc +++ b/test/extensions/filters/http/mcp_json_rest_bridge/mcp_json_rest_bridge_filter_test.cc @@ -1382,12 +1382,12 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListPerRouteConfig) { std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": "req-1"})"; Buffer::OwnedImpl data(json); - EXPECT_CALL(decoder_callbacks_, encodeHeaders_(testing::_, false)) + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(_, false)) .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { EXPECT_EQ("200", headers.getStatusValue()); EXPECT_EQ("application/json", headers.getContentTypeValue()); })); - EXPECT_CALL(decoder_callbacks_, encodeData(testing::_, true)) + EXPECT_CALL(decoder_callbacks_, encodeData(_, true)) .WillOnce(testing::Invoke([](Buffer::Instance& data, bool) { auto parsed_response = nlohmann::json::parse(data.toString()); @@ -1433,12 +1433,12 @@ TEST_F(McpJsonRestBridgeFilterTest, ToolsListLocalEmpty) { std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": "req-1"})"; Buffer::OwnedImpl data(json); - EXPECT_CALL(decoder_callbacks_, encodeHeaders_(testing::_, false)) + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(_, false)) .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { EXPECT_EQ("200", headers.getStatusValue()); EXPECT_EQ("application/json", headers.getContentTypeValue()); })); - EXPECT_CALL(decoder_callbacks_, encodeData(testing::_, true)) + EXPECT_CALL(decoder_callbacks_, encodeData(_, true)) .WillOnce(testing::Invoke([](Buffer::Instance& data, bool) { auto parsed_response = nlohmann::json::parse(data.toString()); From 5db54c0cf788bb72a0ff08f1bada397f1ceb833b Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Wed, 20 May 2026 16:18:23 +0000 Subject: [PATCH 15/16] fix format Signed-off-by: Michael Behr --- .../current/new_features/mcp_transcoder__tools_list_local.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst index 4e3f4dcb77010..dcc0e9f193742 100644 --- a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst +++ b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst @@ -1,4 +1,4 @@ -Added local response handling for the `tools/list` JSON-RPC method in the MCP JSON REST bridge +Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge filter. Configuring `tools_list_local` will cause the filter to directly generate and serve the available tools list response without sending a request upstream. This may be configured on a per-route basis. From 3ed4b1d735e8f2782cad670232d1b6efd3ade31d Mon Sep 17 00:00:00 2001 From: Michael Behr Date: Wed, 20 May 2026 18:33:19 +0000 Subject: [PATCH 16/16] fix format Signed-off-by: Michael Behr --- .../current/new_features/mcp_transcoder__tools_list_local.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst index dcc0e9f193742..1d7b5d4ab20d0 100644 --- a/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst +++ b/changelogs/current/new_features/mcp_transcoder__tools_list_local.rst @@ -1,4 +1,4 @@ Added local response handling for the ``tools/list`` JSON-RPC method in the MCP JSON REST bridge -filter. Configuring `tools_list_local` will cause the filter to directly generate and serve the +filter. Configuring ``tools_list_local`` will cause the filter to directly generate and serve the available tools list response without sending a request upstream. This may be configured on a per-route basis.