Skip to content

mcp_transcoder: Serve route-specific MCP tools/list responses from McpJsonRestBridgeFilter.#45015

Open
mkbehr wants to merge 18 commits into
envoyproxy:mainfrom
mkbehr:mcp-list
Open

mcp_transcoder: Serve route-specific MCP tools/list responses from McpJsonRestBridgeFilter.#45015
mkbehr wants to merge 18 commits into
envoyproxy:mainfrom
mkbehr:mcp-list

Conversation

@mkbehr
Copy link
Copy Markdown
Contributor

@mkbehr mkbehr commented May 11, 2026

Commit Message: mcp_transcoder: Serve route-specific MCP tools/list responses from McpJsonRestBridgeFilter.

Additional Description:

Add config for serving tools/list calls via local reply from McpJsonRestBridgeFilter. Tools can specify title, description, and input/output schema using the new ToolsListSpecificConfig message. Input and output schemas are provided via serialized JSON strings instead of structured objects for efficiency of encoding, because this config is only used for tools/list.

Also add a new McpJsonRestBridgePerRouteConfig, to allow specifying tools per-route.

Risk Level: Low. Config-guarded, default off.
Testing: Unit test, integration test.
Docs Changes: API docs.
Release Notes:
Platform Specific Features: N/A
API Considerations: Feature is gated by xDS-delivered config; off by default.

Generative AI was used to write this change. I have manually reviewed and fully understand the change.

@repokitteh-read-only
Copy link
Copy Markdown

As a reminder, PRs marked as draft will not be automatically assigned reviewers,
or be handled by maintainer-oncall triage.

Please mark your PR as ready when you want it to be reviewed!

🐱

Caused by: #45015 was opened by mkbehr.

see: more, trace.

mkbehr added 3 commits May 11, 2026 21:32
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 <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
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 <mkbehr@google.com>
mkbehr added 2 commits May 12, 2026 18:06
- 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 <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
@mkbehr mkbehr marked this pull request as ready for review May 12, 2026 18:47
@repokitteh-read-only
Copy link
Copy Markdown

CC @envoyproxy/api-shepherds: Your approval is needed for changes made to (api/envoy/|docs/root/api-docs/).
envoyproxy/api-shepherds assignee is @adisuissa
CC @envoyproxy/api-watchers: FYI only for changes made to (api/envoy/|docs/root/api-docs/).

🐱

Caused by: #45015 was ready_for_review by mkbehr.

see: more, trace.

@mkbehr
Copy link
Copy Markdown
Contributor Author

mkbehr commented May 12, 2026

/assign @tyxia

mkbehr added 2 commits May 12, 2026 20:48
Signed-off-by: Michael Behr <mkbehr@google.com>
…nd 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 <mkbehr@google.com>
Copy link
Copy Markdown
Member

@tyxia tyxia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Thank you!

First pass on API.

Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Copy link
Copy Markdown
Member

@tyxia tyxia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API mostly LGTM, let's bring the API Reviewer @adisuissa , while reviewing the implementation. Thanks!

@mkbehr You also need to fix DCO failure

@tyxia
Copy link
Copy Markdown
Member

tyxia commented May 15, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds local response handling for the tools/list JSON-RPC method in the MCP JSON REST bridge filter, allowing it to serve tool metadata directly. It introduces per-route configuration overrides and updates the filter logic to merge global and route-specific tool definitions. Review feedback points out a potential violation of the JSON-RPC 2.0 specification regarding notifications and suggests refactoring the configuration resolution logic for better maintainability.

Comment on lines +378 to +382
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();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the JSON-RPC 2.0 specification, a Notification is a Request object without an id member, and the server MUST NOT reply to a Notification. The current implementation will send a local response with "id": null if the id field is missing from the request, which violates the spec. You should check for the presence of the id field before proceeding with the local response.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. We now send 204 No Content for these.

}
}

for (const auto& tool : config_->toolConfig().tools()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't reviewed the whole PR , but one thing from quick scan

per route config generally overrides the main filter configuration, so we should not combine them here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Copy Markdown
Member

@tyxia tyxia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass on implementation. Thanks!


response_data.add(",\"inputSchema\":");
if (!tool->tool_list_config().input_schema().empty()) {
response_data.add(tool->tool_list_config().input_schema());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a big warning comment here that input_schema from config for now is from trusted source. i.e., valid json, otherwise if it is from untrusted source JSON Validation for Schema is needed.

Validation actually is not hard, it can be validated at config load time with nlohmann::json::accept. We don't need to add it in this PR though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +384 to +387
Buffer::OwnedImpl response_data;
response_data.add("{\"jsonrpc\":\"2.0\",\"id\":");
response_data.add(request_id_json);
response_data.add(",\"result\":{\"tools\":[");
Copy link
Copy Markdown
Member

@tyxia tyxia May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of Buffer::OwnedImpl, we could optimize this with string/ absl::StrAppend + reserve() , which will be faster and more memory efficient.

envoy bufferImpl is a chain of separate memory slices while we can have single contiguous string with preallocation. string representation is also aligned with sendLocalReply

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we used a string, we'd have to copy the data out into a buffer when we were done anyway. (sendLocalReply does a copy under the hood.)

What we could do is reserve a single slice, and write all the data to there. But then we'd have to precompute the length, which would be hard to maintain since we're building the response ourselves. The efficiency gains would be minimal since Envoy will combine these into 4KiB slices as is. I don't think the maintainability tradeoff is worth it if we do this by hand. Do you know of a library we could use?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, buffer save one copy inside of sendLoalReply but it sacrifices the cache locality with multiple scattered slices. Thus i would say it is really microscopic optimization, if there is a gain. However, the stability and reliability issues mentioned here #45015 (comment) is more important factor here

first_tool = false;

response_data.add("{");
nlohmann::json name_json = tool->name();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: with StrAppend above, we can avoid heavy temporary nlohmann::json object

absl::StrAppend(&response_str, ""name":", nlohmann::json(tool->name()).dump());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There aren't any heavy nlohmann::json objects here, just light ones to escape strings. That example doesn't use any fewer temporary objects than this implementation does.

Comment on lines +428 to +436
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing manual encodeHeaders and encodeData, we should just use sendLocalReply here. That is standard way in Envoy to handle this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendLocalReply does a string copy for the body, so it's inefficient for large responses. encodeData lets us pass the buffer in directly.

Copy link
Copy Markdown
Member

@tyxia tyxia May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendLocalyReply is a better option here. It has many advantages, to name a few:
It cleanly handle Envoy’s HTTP stream state machine. And it integrates with HCM to correctly terminate the request and close the connection. Also other filters are properly notified through their onLocalReply callbacks if they need to inspect or intercept local responses.

String copy is much smaller gain compared with correctness, stability and Envoy core safely here. Also, tools/list is infrequent only called at tool discovery init phase, which makes string copy gain even smaller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encodeHeaders/Data from the decoder callbacks is stable and correct; those methods are exposed from the callbacks for this purpose. See documentation in filter.h: "A local reply can be triggered via sendLocalReply() or encodeHeaders()." Standard usage is sendLocalReply for error responses and encodeHeaders for 200s with longer bodies. encodeHeaders is already used for local replies in several places across the codebase, including CacheFilter, CorsFilter, ext_proc filter, and AdminFilter. The state machine and connection management are handled correctly; if they weren't, the integration test wouldn't pass. onLocalReply won't be called, but that's generally used for error management.

Copy link
Copy Markdown
Member

@tyxia tyxia May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is not about individual encodeHeaders/Data call. Also your points only refer to encodeHeaders alone while here we are encode both header and body. There is no standard usage saying sendLocalReply is only for error response, it is local reply by its name. Besides, Integration test also can not demonstrate it is working e2e as it just have one filter in the chain, however, in production there will be lots of filters and each filter can have their own logics such as local reply or processing error or header body mutation or filter chain stall etc.

If you take a look at how sendLocalReply really works, you can see it is much more complicated that just encodeHeader and body. It needs to handle FM integration, filter call state, data reset_imminent, reply execution model and so on ...... Manually using separate encodeHeader/body here is error prone and not stable/reliable.

Furthermore, back to your initial motivation of this change. I don't think optimization of saving string copy on a infrequent path (which is somewhat microscopic) could justify the sacrifice of simplicity, reliability and even correctness that are brought by well-established and well-tested sendLocalReply API.

If you still have questions, i am happy/available to have a quick sync.

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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove"per-route " ? per-route config is used when it is configured but main filter config can also be used when per route config is absent

absl::StatusOr<envoy::extensions::filters::http::mcp_json_rest_bridge::v3::HttpRule> http_rule =
config_->getToolsListHttpRule();
if (http_rule.ok() && !http_rule->get().empty()) {
const auto* per_route_config =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have invoked resolveMostSpecificPerFilterConfig multiple times in this filter, i think it will be better we just latch a per route config pointer in this filter as class member

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@tyxia
Copy link
Copy Markdown
Member

tyxia commented May 19, 2026

/assign @adisuissa @wbpcode

for API review as they have reviewed multiple API PRs for this filter in the past (i.e., has most context). Please take a look either of you have time Thanks!

Comment thread changelogs/current.yaml Outdated
# *Normally occurs at the end of the* :ref:`deprecation period <deprecated>`

new_features:
- area: mcp_json_rest_bridge
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to use new changelog layout - see #45095

/wait

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

mkbehr added 3 commits May 19, 2026 20:01
- 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 <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
mkbehr added 3 commits May 19, 2026 20:47
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
Signed-off-by: Michael Behr <mkbehr@google.com>
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should change the error response to NoContent here if the request id is missing for a non-notification request like a tools/call or tools/list request.

id
This member is REQUIRED.
It MUST be the same as the value of the id member in the Request Object.
If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.

https://www.jsonrpc.org/specification#:~:text=in%20section%205.1.-,id,object%20(e.g.%20Parse%20error/Invalid%20Request)%2C%20it%20MUST%20be%20Null.,-Either%20the%20result

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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A tools/list request without id should result in an error response.

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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants