-
Notifications
You must be signed in to change notification settings - Fork 5.5k
mcp_transcoder: add response streaming option. #45076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
396b26d
861b6a0
2d04430
bd8455c
d57c623
a2330fb
4fc06bf
4038b23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -227,6 +227,21 @@ message ToolConfig { | |
| // 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; | ||
|
|
||
| // Enables streaming transcoding for unstructured text responses (``content`` field of a result). | ||
| // | ||
| // When enabled, the response body is streamed directly to the client without buffering. Each | ||
| // chunk is JSON escaped as it arrives and wrapped with a pre-built JSON-RPC prefix and suffix. | ||
| // | ||
| // Streaming flow: | ||
| // | ||
| // .. code-block:: text | ||
| // | ||
| // input: [chunk1] → [chunk2] → [chunk3] | ||
| // output: [prefix+escaped_chunk1] → [escaped_chunk2] → [escaped_chunk3+suffix] | ||
| // | ||
| // Disabled by default. | ||
| bool text_content_streaming_enabled = 4; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We had a discussion before with @tyxia and thought we'd like to have this disabled by default for future structured content support which needs buffering anyway. Also could you elaborate more about how would
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mark meant that: BoolValue is wrapper message for bool can be set to True by default in the future, while bool is only false by default. For this case, we won't need to set it to True by default even in the future, so bool is fine here. |
||
| } | ||
|
|
||
| // Defines the schema of the JSON-RPC to REST mapping. It specifies how the "arguments" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,8 @@ | |
| #include "envoy/http/filter.h" | ||
| #include "envoy/http/header_map.h" | ||
|
|
||
| #include "source/common/buffer/buffer_impl.h" | ||
| #include "source/common/common/json_escape_string.h" | ||
| #include "source/common/http/headers.h" | ||
| #include "source/common/protobuf/utility.h" | ||
| #include "source/extensions/filters/common/mcp/constants.h" | ||
|
|
@@ -61,7 +63,7 @@ json translateJsonRestResponseToJsonRpc(absl::string_view tool_call_response, | |
| {McpConstants::RESULT_FIELD, | ||
| { | ||
| {McpConstants::CONTENT_FIELD, | ||
| json::array({{{McpConstants::TYPE_FIELD, "text"}, | ||
| json::array({{{McpConstants::TYPE_FIELD, McpConstants::TEXT_FIELD}, | ||
| {McpConstants::TEXT_FIELD, tool_call_response}}})}, | ||
| {McpConstants::IS_ERROR_FIELD, is_error}, | ||
| }}, | ||
|
|
@@ -142,19 +144,27 @@ McpJsonRestBridgeFilterConfig::McpJsonRestBridgeFilterConfig( | |
| max_response_body_size_(PROTOBUF_GET_WRAPPED_OR_DEFAULT(proto_config_, max_response_body_size, | ||
| DEFAULT_MAX_RESPONSE_BODY_SIZE)) { | ||
| for (const auto& tool : proto_config.tool_config().tools()) { | ||
| tool_to_http_rule_[tool.name()] = tool.http_rule(); | ||
| tool_entries_[tool.name()] = {tool.http_rule(), tool.text_content_streaming_enabled()}; | ||
| } | ||
| ENVOY_LOG(debug, "Received MCP JSON REST Bridge config: {}", proto_config_.DebugString()); | ||
| } | ||
|
|
||
| absl::StatusOr<envoy::extensions::filters::http::mcp_json_rest_bridge::v3::HttpRule> | ||
| McpJsonRestBridgeFilterConfig::getHttpRule(absl::string_view tool_name) const { | ||
| auto it = tool_to_http_rule_.find(tool_name); | ||
| if (it == tool_to_http_rule_.end()) { | ||
| auto it = tool_entries_.find(tool_name); | ||
| if (it == tool_entries_.end()) { | ||
| return absl::InvalidArgumentError( | ||
| fmt::format("Failed to find http rule for tool_name: {}", tool_name)); | ||
| } | ||
| return it->second; | ||
| return it->second.http_rule; | ||
| } | ||
|
|
||
| bool McpJsonRestBridgeFilterConfig::textContentStreamingEnabled(absl::string_view tool_name) const { | ||
| auto it = tool_entries_.find(tool_name); | ||
| if (it == tool_entries_.end()) { | ||
| return false; | ||
| } | ||
| return it->second.text_content_streaming_enabled; | ||
| } | ||
|
|
||
| absl::StatusOr<envoy::extensions::filters::http::mcp_json_rest_bridge::v3::HttpRule> | ||
|
|
@@ -248,8 +258,8 @@ Http::FilterDataStatus McpJsonRestBridgeFilter::decodeData(Buffer::Instance& dat | |
| return Http::FilterDataStatus::Continue; | ||
| } | ||
|
|
||
| Http::FilterHeadersStatus McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseHeaderMap&, | ||
| bool end_stream) { | ||
| Http::FilterHeadersStatus | ||
| McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseHeaderMap& response_headers, bool end_stream) { | ||
| switch (mcp_operation_) { | ||
| case McpOperation::Unspecified: | ||
| case McpOperation::Undecided: | ||
|
|
@@ -262,6 +272,17 @@ Http::FilterHeadersStatus McpJsonRestBridgeFilter::encodeHeaders(Http::ResponseH | |
| break; | ||
| } | ||
|
|
||
| // Streaming mode: pre-build the JSON-RPC prefix/suffix, strip Content-Length | ||
| // (final size is unknown), and let the headers flow through immediately so | ||
| // the client can start receiving data without waiting for the full body. | ||
| if (mcp_operation_ == McpOperation::ToolsCall && text_content_streaming_enabled_) { | ||
| buildStreamingPrefixAndSuffix(getResponseCode(response_headers) >= | ||
| static_cast<int>(Http::Code::BadRequest)); | ||
| response_headers.removeContentLength(); | ||
| response_headers.setContentType(Http::Headers::get().ContentTypeValues.Json); | ||
| return Http::FilterHeadersStatus::Continue; | ||
| } | ||
|
|
||
| // TODO(guoyilin42): Handle headers-only upstream responses (e.g., 204 No Content). | ||
| // Currently, these cases bypass transcoding, which can cause MCP SDKs to timeout | ||
| // or throw exceptions because they expect a valid JSON-RPC response with a | ||
|
|
@@ -280,6 +301,45 @@ Http::FilterDataStatus McpJsonRestBridgeFilter::encodeData(Buffer::Instance& dat | |
| return Http::FilterDataStatus::Continue; | ||
| } | ||
|
|
||
| // Streaming fast-path for tools/call: JSON-escape each chunk on-the-fly without | ||
| // buffering the full response body. | ||
| if (!streaming_json_prefix_.empty()) { | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| uint64_t len = data.length(); | ||
| // Note: An empty chunk can arrive when the upstream uses the body + trailer pattern (end_stream | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You probably meant a TODO, as encodeTrailer is not handled yet. |
||
| // is false on the last data frame). It is a no-op here; the suffix will be appended in | ||
| // encodeTrailers. | ||
| absl::string_view chunk(static_cast<const char*>(data.linearize(len)), len); | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| // TODO(guoyilin42): Consider adding text/event-stream backend response support and explore if | ||
| // it needs buffering. | ||
| std::string escaped_chunk = JsonEscaper::escapeString(chunk, JsonEscaper::extraSpace(chunk)); | ||
|
guoyilin42 marked this conversation as resolved.
|
||
|
|
||
| data.drain(len); | ||
| // Note: UTF-8 structural validation (i.e., utf8_range::IsStructurallyValid) is omitted | ||
| // in the streaming fast-path due to the stateless nature of chunk processing (which lacks | ||
| // a stateful UTF-8 validator to track multi-byte character boundaries across chunk limits). | ||
| // If the upstream backend returns invalid UTF-8, it will be streamed to the client as-is, | ||
| // which may cause the client to fail parsing the final JSON. | ||
| if (is_first_streaming_chunk_) { | ||
| ENVOY_STREAM_LOG(debug, | ||
| "Streaming: emitting prefix + first chunk ({} raw bytes, {} escaped bytes).", | ||
| *encoder_callbacks_, len, escaped_chunk.size()); | ||
| data.add(streaming_json_prefix_); | ||
| is_first_streaming_chunk_ = false; | ||
| } else { | ||
| ENVOY_STREAM_LOG(debug, "Streaming: forwarding chunk ({} raw bytes, {} escaped bytes).", | ||
| *encoder_callbacks_, len, escaped_chunk.size()); | ||
| } | ||
| data.add(escaped_chunk); | ||
| // TODO(guoyilin42): There will be a case that end_stream is not set in the encodeData call. | ||
| // This is body + trailer case where encodeTrailer call represents the end of response body. | ||
| // In that case, we should add the streaming_json_suffix at encodeTrailer call. | ||
| if (end_stream) { | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| ENVOY_STREAM_LOG(debug, "Streaming: appending suffix, stream complete.", *encoder_callbacks_); | ||
| data.add(streaming_json_suffix_); | ||
| } | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| return Http::FilterDataStatus::Continue; | ||
| } | ||
|
guoyilin42 marked this conversation as resolved.
|
||
|
|
||
| const uint32_t max_response_body_size = config_->maxResponseBodySize(); | ||
| if (max_response_body_size > 0 && | ||
| (response_body_.length() + data.length()) > max_response_body_size) { | ||
|
|
@@ -321,6 +381,32 @@ Http::FilterTrailersStatus McpJsonRestBridgeFilter::encodeTrailers(Http::Respons | |
| return Http::FilterTrailersStatus::Continue; | ||
| } | ||
|
|
||
| void McpJsonRestBridgeFilter::buildStreamingPrefixAndSuffix(bool is_error) { | ||
| // Build a reference JSON-RPC envelope with an empty text placeholder. | ||
| json ref = { | ||
| {McpConstants::JSONRPC_FIELD, McpConstants::JSONRPC_VERSION}, | ||
| {McpConstants::ID_FIELD, *session_id_}, | ||
| {McpConstants::RESULT_FIELD, | ||
| { | ||
| {McpConstants::CONTENT_FIELD, | ||
| json::array({{{McpConstants::TYPE_FIELD, McpConstants::TEXT_FIELD}, | ||
| {McpConstants::TEXT_FIELD, ""}}})}, | ||
| {McpConstants::IS_ERROR_FIELD, is_error}, | ||
| }}, | ||
| }; | ||
| std::string ref_json = ref.dump(); | ||
|
|
||
| // Locate the empty-string placeholder for the text value: `"text":""`. | ||
| std::string marker = absl::StrCat("\"", McpConstants::TEXT_FIELD, "\":\"\""); | ||
| size_t pos = ref_json.find(marker); | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| if (pos == std::string::npos) { | ||
| IS_ENVOY_BUG("JSON-RPC streaming marker not found in serialized envelope"); | ||
| return; | ||
| } | ||
| streaming_json_prefix_ = ref_json.substr(0, pos + marker.size() - 1); | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| streaming_json_suffix_ = ref_json.substr(pos + marker.size() - 1); | ||
|
guoyilin42 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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()); | ||
|
|
@@ -526,6 +612,9 @@ void McpJsonRestBridgeFilter::mapMcpToolToApiBackend(const nlohmann::json& json_ | |
| return; | ||
| } | ||
|
|
||
| // Set the per-request streaming flag based on the tool's config. | ||
| text_content_streaming_enabled_ = config_->textContentStreamingEnabled(tool_name); | ||
|
|
||
| const auto arguments_it = params.find(McpConstants::ARGUMENTS_FIELD); | ||
| if (arguments_it != params.end() && !arguments_it->is_object()) { | ||
| ENVOY_STREAM_LOG(error, "The arguments of the tool call request must be an object.", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.