Skip to content

Commit d1625ac

Browse files
authored
mcp_filter: add mcp methods group annotations (#42877)
This allows a group policy enforcement rather than apply on each method. Added method group classification to the MCP filter. Methods are classified into built-in groups (lifecycle, tool, resource, prompt, notification, logging, sampling, completion, unknown) and the group is added to dynamic metadata --------- Signed-off-by: Boteng Yao <boteng@google.com>
1 parent 03697a4 commit d1625ac

File tree

7 files changed

+296
-9
lines changed

7 files changed

+296
-9
lines changed

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,25 @@ message ParserConfig {
7373
// This matches the "method" field in the JSON-RPC request.
7474
string method = 1 [(validate.rules).string = {min_len: 1}];
7575

76+
// The group/category name to assign to this method (e.g., "tool", "lifecycle").
77+
// This will be emitted to dynamic metadata under the key specified by group_metadata_key.
78+
// If empty, the built-in group classification is used.
79+
string group = 2;
80+
7681
// Attributes to extract for this method.
77-
// If empty, no attributes will be extracted for this method beyond the default ones (jsonrpc, method).
78-
repeated AttributeExtractionRule extraction_rules = 2;
82+
// If empty, only default attributes (jsonrpc, method) are extracted.
83+
repeated AttributeExtractionRule extraction_rules = 3;
7984
}
8085

81-
// Method-specific configurations.
82-
// These rules override or supplement the default extraction logic for the specified methods.
86+
// List of rules for classification and extraction.
87+
// Rules are evaluated in order; the first match wins.
88+
// If no rule matches, extraction defaults are used and group falls back to built-in classification.
89+
// Built-in groups: lifecycle, tool, resource, prompt, notification, logging, sampling, completion, unknown.
8390
repeated MethodConfig methods = 1;
91+
92+
// The dynamic metadata key where the group name will be stored.
93+
// If empty, group classification is disabled.
94+
string group_metadata_key = 2;
8495
}
8596

8697
// Per-route override configuration for MCP filter

changelogs/current.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,12 @@ new_features:
652652
The filter extracts the ``method`` and ``id`` fields from incoming requests and stores them in dynamic metadata
653653
for use by downstream filters and access logging. Notifications (methods starting with ``notifications/``) are
654654
correctly handled as they don't have an ``id`` field per the JSON-RPC specification.
655+
- area: mcp
656+
change: |
657+
Added method group classification to the MCP filter. Methods are classified into built-in groups (lifecycle, tool,
658+
resource, prompt, notification, logging, sampling, completion, unknown) and the group is added to dynamic metadata
659+
when :ref:`group_metadata_key <envoy_v3_api_field_extensions.filters.http.mcp.v3.ParserConfig.group_metadata_key>`
660+
is configured. User-defined groups can override built-in classifications via ``MethodConfig``.
655661
- area: mcp
656662
change: |
657663
Added :ref:`mcp_router HTTP filter <config_http_filters_mcp_router>` which routes MCP (Model Context Protocol)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,16 @@ Http::FilterDataStatus McpFilter::completeParsing() {
242242
}
243243

244244
// Set dynamic metadata
245-
const auto& metadata = parser_->metadata();
245+
Protobuf::Struct metadata = parser_->metadata();
246+
247+
// Add method group if configured
248+
const std::string& group_metadata_key = config_->parserConfig().groupMetadataKey();
249+
if (!group_metadata_key.empty()) {
250+
std::string method_group = config_->parserConfig().getMethodGroup(parser_->getMethod());
251+
(*metadata.mutable_fields())[group_metadata_key].set_string_value(method_group);
252+
ENVOY_LOG(debug, "MCP filter set method group: {}={}", group_metadata_key, method_group);
253+
}
254+
246255
if (!metadata.fields().empty()) {
247256
decoder_callbacks_->streamInfo().setDynamicMetadata(std::string(MetadataKeys::FilterName),
248257
metadata);

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,28 @@ McpParserConfig::fromProto(const envoy::extensions::filters::http::mcp::v3::Pars
6363
config.always_extract_.insert("method");
6464
config.initializeDefaults();
6565

66-
// Process method-specific overrides
66+
config.group_metadata_key_ = proto.group_metadata_key();
67+
68+
// Process method-specific configs (for both extraction rules and group overrides)
6769
for (const auto& method_proto : proto.methods()) {
68-
std::vector<AttributeExtractionRule> extraction_rules;
70+
MethodConfigEntry entry;
71+
entry.method_pattern = method_proto.method();
72+
entry.group = method_proto.group();
73+
6974
for (const auto& extraction_rule_proto : method_proto.extraction_rules()) {
70-
extraction_rules.emplace_back(extraction_rule_proto.path());
75+
entry.extraction_rules.emplace_back(extraction_rule_proto.path());
76+
}
77+
78+
config.method_configs_.push_back(std::move(entry));
79+
80+
// Also update method_fields_ for extraction rules (for backward compatibility)
81+
if (!method_proto.extraction_rules().empty()) {
82+
std::vector<AttributeExtractionRule> extraction_rules;
83+
for (const auto& rule_proto : method_proto.extraction_rules()) {
84+
extraction_rules.emplace_back(rule_proto.path());
85+
}
86+
config.addMethodConfig(method_proto.method(), std::move(extraction_rules));
7187
}
72-
config.addMethodConfig(method_proto.method(), std::move(extraction_rules));
7388
}
7489

7590
return config;
@@ -88,6 +103,66 @@ McpParserConfig::getFieldsForMethod(const std::string& method) const {
88103
return (it != method_fields_.end()) ? it->second : empty;
89104
}
90105

106+
std::string McpParserConfig::getMethodGroup(const std::string& method) const {
107+
// Check user-configured rules first (exact match only)
108+
for (const auto& entry : method_configs_) {
109+
if (method == entry.method_pattern && !entry.group.empty()) {
110+
return entry.group;
111+
}
112+
}
113+
114+
// Fall back to built-in groups
115+
return getBuiltInMethodGroup(method);
116+
}
117+
118+
std::string McpParserConfig::getBuiltInMethodGroup(const std::string& method) const {
119+
using namespace McpConstants::Methods;
120+
using namespace McpConstants::MethodGroups;
121+
122+
// Lifecycle methods
123+
if (method == INITIALIZE || method == NOTIFICATION_INITIALIZED || method == PING) {
124+
return std::string(LIFECYCLE);
125+
}
126+
127+
// Tool methods
128+
if (method == TOOLS_CALL || method == TOOLS_LIST) {
129+
return std::string(TOOL);
130+
}
131+
132+
// Resource methods
133+
if (method == RESOURCES_READ || method == RESOURCES_LIST || method == RESOURCES_SUBSCRIBE ||
134+
method == RESOURCES_UNSUBSCRIBE || method == RESOURCES_TEMPLATES_LIST) {
135+
return std::string(RESOURCE);
136+
}
137+
138+
// Prompt methods
139+
if (method == PROMPTS_GET || method == PROMPTS_LIST) {
140+
return std::string(PROMPT);
141+
}
142+
143+
// Logging
144+
if (method == LOGGING_SET_LEVEL) {
145+
return std::string(LOGGING);
146+
}
147+
148+
// Sampling
149+
if (method == SAMPLING_CREATE_MESSAGE) {
150+
return std::string(SAMPLING);
151+
}
152+
153+
// Completion
154+
if (method == COMPLETION_COMPLETE) {
155+
return std::string(COMPLETION);
156+
}
157+
158+
// General notifications (prefix match, excluding those already categorized)
159+
if (absl::StartsWith(method, NOTIFICATION_PREFIX)) {
160+
return std::string(NOTIFICATION);
161+
}
162+
163+
return std::string(UNKNOWN);
164+
}
165+
91166
// McpFieldExtractor implementation
92167
McpFieldExtractor::McpFieldExtractor(Protobuf::Struct& metadata, const McpParserConfig& config)
93168
: root_metadata_(metadata), config_(config) {

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ constexpr absl::string_view NOTIFICATION_MESSAGE = "notifications/message";
7575
constexpr absl::string_view NOTIFICATION_CANCELLED = "notifications/cancelled";
7676
constexpr absl::string_view NOTIFICATION_INITIALIZED = "notifications/initialized";
7777
} // namespace Methods
78+
79+
// Method group names for classification
80+
namespace MethodGroups {
81+
constexpr absl::string_view LIFECYCLE = "lifecycle";
82+
constexpr absl::string_view TOOL = "tool";
83+
constexpr absl::string_view RESOURCE = "resource";
84+
constexpr absl::string_view PROMPT = "prompt";
85+
constexpr absl::string_view NOTIFICATION = "notification";
86+
constexpr absl::string_view LOGGING = "logging";
87+
constexpr absl::string_view SAMPLING = "sampling";
88+
constexpr absl::string_view COMPLETION = "completion";
89+
constexpr absl::string_view UNKNOWN = "unknown";
90+
} // namespace MethodGroups
7891
} // namespace McpConstants
7992

8093
/**
@@ -88,6 +101,13 @@ class McpParserConfig {
88101
AttributeExtractionRule(const std::string& p) : path(p) {}
89102
};
90103

104+
// Method config entry for user-configured rules
105+
struct MethodConfigEntry {
106+
std::string method_pattern; // Method pattern (exact or with trailing "*" for prefix)
107+
std::string group; // Group name, empty means use built-in
108+
std::vector<AttributeExtractionRule> extraction_rules;
109+
};
110+
91111
// Create from proto configuration
92112
static McpParserConfig
93113
fromProto(const envoy::extensions::filters::http::mcp::v3::ParserConfig& proto);
@@ -101,15 +121,29 @@ class McpParserConfig {
101121
// Get all global fields to always extract
102122
const absl::flat_hash_set<std::string>& getAlwaysExtract() const { return always_extract_; }
103123

124+
// Get the group metadata key (empty if disabled)
125+
const std::string& groupMetadataKey() const { return group_metadata_key_; }
126+
127+
// Get the method group for a given method name
128+
// Returns the group name based on user config first, then built-in groups
129+
std::string getMethodGroup(const std::string& method) const;
130+
104131
// Create default config (minimal extraction)
105132
static McpParserConfig createDefault();
106133

107134
private:
108135
void initializeDefaults();
136+
std::string getBuiltInMethodGroup(const std::string& method) const;
109137

110138
// Per-method field policies
111139
absl::flat_hash_map<std::string, std::vector<AttributeExtractionRule>> method_fields_;
112140

141+
// User-configured method configs
142+
std::vector<MethodConfigEntry> method_configs_;
143+
144+
// Method group configuration
145+
std::string group_metadata_key_;
146+
113147
// Global fields to always extract
114148
absl::flat_hash_set<std::string> always_extract_;
115149
};

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,78 @@ TEST_F(McpFilterTest, PerRouteMaxBodySizeFallbackToGlobal) {
768768
EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, filter_->decodeHeaders(headers, false));
769769
}
770770

771+
// Test method group added to dynamic metadata when configured
772+
TEST_F(McpFilterTest, MethodGroupAddedToMetadata) {
773+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
774+
proto_config.mutable_parser_config()->set_group_metadata_key("method_group");
775+
config_ = std::make_shared<McpFilterConfig>(proto_config, "test.", factory_context_.scope());
776+
filter_ = std::make_unique<McpFilter>(config_);
777+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
778+
779+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
780+
{"content-type", "application/json"},
781+
{"accept", "application/json"},
782+
{"accept", "text/event-stream"}};
783+
784+
filter_->decodeHeaders(headers, false);
785+
786+
std::string json =
787+
R"({"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "test"}, "id": 1})";
788+
Buffer::OwnedImpl buffer(json);
789+
790+
EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _))
791+
.WillOnce([&](const std::string&, const Protobuf::Struct& metadata) {
792+
const auto& fields = metadata.fields();
793+
794+
// Check method_group is set to "tool" (built-in group for tools/call)
795+
auto group_it = fields.find("method_group");
796+
ASSERT_NE(group_it, fields.end());
797+
EXPECT_EQ(group_it->second.string_value(), "tool");
798+
799+
// Check method is also set
800+
auto method_it = fields.find("method");
801+
ASSERT_NE(method_it, fields.end());
802+
EXPECT_EQ(method_it->second.string_value(), "tools/call");
803+
});
804+
805+
EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true));
806+
}
807+
808+
// Test method group with custom override
809+
TEST_F(McpFilterTest, MethodGroupWithCustomOverride) {
810+
envoy::extensions::filters::http::mcp::v3::Mcp proto_config;
811+
auto* parser_config = proto_config.mutable_parser_config();
812+
parser_config->set_group_metadata_key("group");
813+
814+
auto* method_config = parser_config->add_methods();
815+
method_config->set_method("tools/list");
816+
method_config->set_group("custom_tools");
817+
818+
config_ = std::make_shared<McpFilterConfig>(proto_config, "test.", factory_context_.scope());
819+
filter_ = std::make_unique<McpFilter>(config_);
820+
filter_->setDecoderFilterCallbacks(decoder_callbacks_);
821+
822+
Http::TestRequestHeaderMapImpl headers{{":method", "POST"},
823+
{"content-type", "application/json"},
824+
{"accept", "application/json"},
825+
{"accept", "text/event-stream"}};
826+
827+
filter_->decodeHeaders(headers, false);
828+
829+
std::string json = R"({"jsonrpc": "2.0", "method": "tools/list", "id": 1})";
830+
Buffer::OwnedImpl buffer(json);
831+
832+
EXPECT_CALL(decoder_callbacks_.stream_info_, setDynamicMetadata("mcp_proxy", _))
833+
.WillOnce([&](const std::string&, const Protobuf::Struct& metadata) {
834+
const auto& fields = metadata.fields();
835+
auto group_it = fields.find("group");
836+
ASSERT_NE(group_it, fields.end());
837+
EXPECT_EQ(group_it->second.string_value(), "custom_tools");
838+
});
839+
840+
EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(buffer, true));
841+
}
842+
771843
} // namespace
772844
} // namespace Mcp
773845
} // namespace HttpFilters

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,86 @@ TEST_F(McpJsonParserTest, CheckIdForRegularRequest) {
11951195
EXPECT_EQ(id->number_value(), 2);
11961196
}
11971197

1198+
// Method Group Tests
1199+
TEST(McpParserConfigTest, BuiltInMethodGroups) {
1200+
McpParserConfig config = McpParserConfig::createDefault();
1201+
1202+
// Lifecycle
1203+
EXPECT_EQ(config.getMethodGroup("initialize"), "lifecycle");
1204+
EXPECT_EQ(config.getMethodGroup("notifications/initialized"), "lifecycle");
1205+
EXPECT_EQ(config.getMethodGroup("ping"), "lifecycle");
1206+
1207+
// Tool
1208+
EXPECT_EQ(config.getMethodGroup("tools/call"), "tool");
1209+
EXPECT_EQ(config.getMethodGroup("tools/list"), "tool");
1210+
1211+
// Resource
1212+
EXPECT_EQ(config.getMethodGroup("resources/read"), "resource");
1213+
EXPECT_EQ(config.getMethodGroup("resources/list"), "resource");
1214+
EXPECT_EQ(config.getMethodGroup("resources/subscribe"), "resource");
1215+
EXPECT_EQ(config.getMethodGroup("resources/unsubscribe"), "resource");
1216+
EXPECT_EQ(config.getMethodGroup("resources/templates/list"), "resource");
1217+
1218+
// Prompt
1219+
EXPECT_EQ(config.getMethodGroup("prompts/get"), "prompt");
1220+
EXPECT_EQ(config.getMethodGroup("prompts/list"), "prompt");
1221+
1222+
// Other built-ins
1223+
EXPECT_EQ(config.getMethodGroup("logging/setLevel"), "logging");
1224+
EXPECT_EQ(config.getMethodGroup("sampling/createMessage"), "sampling");
1225+
EXPECT_EQ(config.getMethodGroup("completion/complete"), "completion");
1226+
1227+
// Notifications (prefix match)
1228+
EXPECT_EQ(config.getMethodGroup("notifications/progress"), "notification");
1229+
EXPECT_EQ(config.getMethodGroup("notifications/cancelled"), "notification");
1230+
EXPECT_EQ(config.getMethodGroup("notifications/custom"), "notification");
1231+
1232+
// Unknown
1233+
EXPECT_EQ(config.getMethodGroup("unknown/method"), "unknown");
1234+
EXPECT_EQ(config.getMethodGroup("custom/extension"), "unknown");
1235+
}
1236+
1237+
TEST(McpParserConfigTest, MethodGroupFromProtoWithOverrides) {
1238+
envoy::extensions::filters::http::mcp::v3::ParserConfig proto_config;
1239+
proto_config.set_group_metadata_key("method_group");
1240+
1241+
// Override initialize to be in "admin" group
1242+
auto* method1 = proto_config.add_methods();
1243+
method1->set_method("initialize");
1244+
method1->set_group("admin");
1245+
1246+
// Override tools/call to be in "operations" group
1247+
auto* method2 = proto_config.add_methods();
1248+
method2->set_method("tools/call");
1249+
method2->set_group("operations");
1250+
1251+
McpParserConfig config = McpParserConfig::fromProto(proto_config);
1252+
1253+
EXPECT_EQ(config.groupMetadataKey(), "method_group");
1254+
EXPECT_EQ(config.getMethodGroup("initialize"), "admin");
1255+
EXPECT_EQ(config.getMethodGroup("tools/call"), "operations");
1256+
1257+
// Non-overridden methods use built-in
1258+
EXPECT_EQ(config.getMethodGroup("tools/list"), "tool");
1259+
EXPECT_EQ(config.getMethodGroup("resources/read"), "resource");
1260+
EXPECT_EQ(config.getMethodGroup("ping"), "lifecycle");
1261+
}
1262+
1263+
TEST(McpParserConfigTest, MethodGroupEmptyGroupFallsBackToBuiltIn) {
1264+
envoy::extensions::filters::http::mcp::v3::ParserConfig proto_config;
1265+
proto_config.set_group_metadata_key("group");
1266+
1267+
// Empty group means use built-in
1268+
auto* method = proto_config.add_methods();
1269+
method->set_method("tools/call");
1270+
method->set_group(""); // Empty group
1271+
1272+
McpParserConfig config = McpParserConfig::fromProto(proto_config);
1273+
1274+
// Should fall back to built-in group
1275+
EXPECT_EQ(config.getMethodGroup("tools/call"), "tool");
1276+
}
1277+
11981278
} // namespace
11991279
} // namespace Mcp
12001280
} // namespace HttpFilters

0 commit comments

Comments
 (0)