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 a1b6f2a8bba33..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 @@ -167,6 +167,10 @@ 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. message ServerToolConfig { // List of MCP tools configurations. @@ -177,26 +181,48 @@ 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. - // - // 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; + // 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. + 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, according to each tool's + // ToolsListSpecificConfig. + ToolsListLocal tool_list_local = 4; + } +} + +// Configuration for a tool's entry in tools/list responses. +message ToolsListSpecificConfig { + // Optional, human-readable name of the tool for display purposes. + string title = 1; + + // Human-readable description of functionality. + string description = 2 [(validate.rules).string = {min_len: 1}]; + + // 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; } -// Configuration for a specific MCP tool. 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; + + // 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; } // Defines the schema of the JSON-RPC to REST mapping. It specifies how the "arguments" @@ -247,3 +273,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/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/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index 88f88a8aa308f..3f7c09b9bc7d9 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/BUILD b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD index b7df6e51323bc..95fcdcf55782e 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/BUILD +++ b/source/extensions/filters/http/mcp_json_rest_bridge/BUILD @@ -17,6 +17,8 @@ envoy_cc_library( ":http_request_builder_lib", "//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/config.cc b/source/extensions/filters/http/mcp_json_rest_bridge/config.cc index e3121b8f214c0..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,6 +32,14 @@ McpJsonRestBridgeFilterConfigFactory::createFilterFactoryFromProtoTyped( }; } +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); +} + /** * Static registration for the MCP JSON REST bridge filter. @see RegisterFactory. */ 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 19da64325b6e2..885a77845df72 100644 --- a/source/extensions/filters/http/mcp_json_rest_bridge/config.h +++ b/source/extensions/filters/http/mcp_json_rest_bridge/config.h @@ -16,7 +16,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(FilterName) {} @@ -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 7aeb357a7fd84..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 @@ -6,10 +6,14 @@ #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" #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" @@ -157,12 +161,23 @@ McpJsonRestBridgeFilterConfig::getHttpRule(absl::string_view tool_name) const { return it->second; } +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(); + } +} + 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."); +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 proto_config_.tool_config().tool_list_http_rule(); + return it->second; } Http::FilterHeadersStatus @@ -204,6 +219,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) { @@ -240,8 +263,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 +281,8 @@ 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: break; @@ -273,10 +299,12 @@ 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) { + mcp_operation_ == McpOperation::InitializationAck || + mcp_operation_ == McpOperation::ToolsListLocal) { return Http::FilterDataStatus::Continue; } @@ -321,6 +349,78 @@ 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) { + 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; + response_data.add("{\"jsonrpc\":\"2.0\",\"id\":"); + 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_proto : tools) { + const auto* tool = &tool_proto; + 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()); + + // 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()); + } else { + response_data.add("{\"type\":\"object\"}"); + } + + 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()); + + // 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"); + + 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()); @@ -335,17 +435,30 @@ void McpJsonRestBridgeFilter::handleMcpMethod(const nlohmann::json& json_rpc, generateErrorJsonResponse(-32602, "Unsupported protocol version").dump()); return; } - if (config_->requestStorageMode() == envoy::extensions::filters::http::mcp_json_rest_bridge::v3:: McpJsonRestBridge::DYNAMIC_METADATA) { setDynamicMetadata(method, json_rpc); } - // TODO(guoyilin42): Consider supporting local response for tools/list in addition to the GET. if (method == McpConstants::Methods::TOOLS_LIST) { - absl::StatusOr http_rule = - config_->getToolsListHttpRule(); - if (http_rule.ok() && !http_rule->get().empty()) { + 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; + } + } + + 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()) { @@ -362,12 +475,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) { + 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) && @@ -516,8 +634,17 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ } const auto& tool_name = name_it->get(); - absl::StatusOr http_rule = - config_->getHttpRule(tool_name); + 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); @@ -629,9 +756,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 12d78d18068fc..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 @@ -37,14 +37,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(); + } + envoy::extensions::filters::http::mcp_json_rest_bridge::v3::McpJsonRestBridge::RequestStorageMode requestStorageMode() const { return proto_config_.request_storage_mode(); @@ -60,6 +63,27 @@ class McpJsonRestBridgeFilterConfig : public Logger::Loggable + getHttpRule(absl::string_view tool_name) const; + + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ServerToolConfig& + toolConfig() const { + return tool_config_; + } + +private: + absl::flat_hash_map + tool_to_http_rule_; + const envoy::extensions::filters::http::mcp_json_rest_bridge::v3::ServerToolConfig tool_config_; +}; + using McpJsonRestBridgeFilterConfigSharedPtr = std::shared_ptr; /** @@ -79,12 +103,16 @@ 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. void handleMcpMethod(const nlohmann::json& json_rpc, Http::RequestHeaderMapOptRef request_headers); + // 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. void encodeJsonRpcData(Http::ResponseHeaderMapOptRef response_headers); @@ -113,10 +141,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. + 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_; @@ -126,7 +156,10 @@ 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}; }; } // namespace McpJsonRestBridge diff --git a/test/extensions/filters/http/mcp_json_rest_bridge/BUILD b/test/extensions/filters/http/mcp_json_rest_bridge/BUILD index c24da6f9e270c..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", @@ -50,6 +51,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 af52a8894f726..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 @@ -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) { @@ -1358,6 +1334,206 @@ TEST_P(McpHttpMethodFilterTest, NonPostMethodsReturnMethodNotAllowed) { StrEq("POST")); } +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_); + 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("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(""); + + 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"}}})"); + + override_config.mutable_tool_config()->mutable_tool_list_local(); + + McpJsonRestBridgePerRouteConfig override(override_config); + + ON_CALL(*decoder_callbacks_.route_, mostSpecificPerFilterConfig) + .WillByDefault(testing::Return(&override)); + + 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_(_, false)) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { + EXPECT_EQ("200", headers.getStatusValue()); + EXPECT_EQ("application/json", headers.getContentTypeValue()); + })); + EXPECT_CALL(decoder_callbacks_, encodeData(_, 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"], "simple_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)); +} + +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_(_, false)) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap& headers, bool) { + EXPECT_EQ("200", headers.getStatusValue()); + EXPECT_EQ("application/json", headers.getContentTypeValue()); + })); + EXPECT_CALL(decoder_callbacks_, encodeData(_, 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)); +} +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_.route_, 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_.route_, 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 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..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 @@ -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,70 @@ 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; + 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"); + 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