fix(mcp): explain revision-scoped policy and rejections (#3850)

* fix(mcp): explain revision-scoped policy and rejections

Explain the selected-revision method set in profile output and policy docs.
Distinguish protocol and policy rejection causes and give a next step while
preserving authorization, response statuses, error codes and YAML keys.

Cover CLI serialization, revision selection, exact extension rules, deny
precedence and rejection before forwarding with focused regressions.

Signed-off-by: Shiju <shiju@nvidia.com>

* docs(mcp): correct HTTP cancellation revision support

Limit notifications/cancelled to the three 2025 revisions in the core
method matrix. State that MCP 2026-07-28 HTTP cancellation closes the
response stream, matching the runtime rejection and sessionless docs.

Signed-off-by: Shiju <shiju@nvidia.com>

---------

Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
Shiju
2026-09-29 21:50:35 +00:00
committed by GitHub
parent 0ea0d31020
commit ba16b9f2c7
8 changed files with 448 additions and 45 deletions
+57 -5
View File
@@ -2566,7 +2566,8 @@ fn format_provider_profile_details(profile: &ProviderProfile) -> String {
let access = match network_access_preset_to_str(endpoint.access) {
Some("") if !endpoint.rules.is_empty() => "custom rules".to_string(),
Some("") if is_mcp && allow_all_known_mcp_methods == Some(true) => {
"all known MCP methods (subject to tool and deny rules)".to_string()
"core MCP methods for the selected revision (subject to tool and deny rules)"
.to_string()
}
Some("") => "not specified".to_string(),
Some(access) => access.to_string(),
@@ -2601,9 +2602,26 @@ fn format_provider_profile_details(profile: &ProviderProfile) -> String {
|| "not specified".to_string(),
|options| options.versions.join(", "),
);
let _ = writeln!(rendered, " Allow all known MCP methods: {methods}");
let _ = writeln!(
rendered,
" Allow core MCP methods for the selected revision: {methods}"
);
if allow_all_known_mcp_methods == Some(true) {
rendered
.push_str(" Tool restrictions still apply; deny rules take precedence.\n");
rendered.push_str(
" Without tool-specific allow rules, all tool names are allowed.\n",
);
}
rendered.push_str(" Extension methods: require an exact allow rule\n");
let _ = writeln!(rendered, " Strict MCP tool names: {strict_names}");
let _ = writeln!(rendered, " MCP versions (declared): {versions}");
rendered.push_str(
" Revision selection: MCP-Protocol-Version header; 2025-03-26 when absent\n",
);
rendered.push_str(
" Legacy initialize requests negotiate their revision in the body.\n",
);
}
if !endpoint.allowed_ips.is_empty() {
let _ = writeln!(
@@ -3143,11 +3161,39 @@ binaries: [/usr/bin/curl]
let rendered = format_provider_profile_details(&proto);
assert!(
rendered
.contains("Access: all known MCP methods (subject to tool and deny rules)\n")
.contains("Access: core MCP methods for the selected revision (subject to tool and deny rules)\n")
);
assert!(rendered.contains("Allow all known MCP methods: true\n"));
assert!(rendered.contains("Allow core MCP methods for the selected revision: true\n"));
assert!(
rendered.contains("Tool restrictions still apply; deny rules take precedence.")
);
assert!(
rendered.contains("Without tool-specific allow rules, all tool names are allowed.")
);
assert!(rendered.contains("Extension methods: require an exact allow rule\n"));
assert!(rendered.contains("Strict MCP tool names: true (default)\n"));
assert!(rendered.contains("MCP versions (declared): 2025-11-25\n"));
assert!(rendered.contains(
"Revision selection: MCP-Protocol-Version header; 2025-03-26 when absent\n"
));
assert!(
rendered
.contains("Legacy initialize requests negotiate their revision in the body.\n")
);
for output in ["json", "yaml"] {
let structured = format_provider_profile_description(&proto, output)
.expect("structured description renders");
let roundtrip = if output == "json" {
parse_profile_json(&structured)
} else {
parse_profile_yaml(&structured)
}
.expect("structured description is a full profile document");
assert_eq!(roundtrip, ProviderTypeProfile::from_proto(&proto));
assert!(structured.contains("allow_all_known_mcp_methods"));
assert!(!structured.contains("Allow core MCP methods"));
}
// Explicit rules remain relevant when the method default is enabled,
// and independent tool-name validation must not disappear from view.
@@ -3163,7 +3209,13 @@ binaries: [/usr/bin/curl]
options.strict_tool_names = Some(false);
let rendered = format_provider_profile_details(&proto);
assert!(rendered.contains("Access: custom rules\n Rules: 1 allow, 0 deny\n"));
assert!(rendered.contains(&format!("Allow all known MCP methods: {allow_all}\n")));
assert!(rendered.contains(&format!(
"Allow core MCP methods for the selected revision: {allow_all}\n"
)));
assert_eq!(
rendered.contains("Tool restrictions still apply"),
allow_all
);
assert!(rendered.contains("Strict MCP tool names: false\n"));
}
}
@@ -424,11 +424,66 @@ request_deny_reason := reason if {
reason := "JSON-RPC response frames are not permitted from client to server"
}
# Explain only parsed MCP calls on an endpoint that matches this request path.
# The relay evaluates batch members separately. Response frames and protocol
# errors keep their own diagnostics, and a sibling endpoint must not select
# the explanation merely because it shares the connection's host and port.
mcp_policy_request if {
input.request.method == "POST"
not is_object(object.get(input.request, "graphql", null))
not jsonrpc_response_frame_present(input.request)
jsonrpc := object.get(input.request, "jsonrpc", null)
is_object(jsonrpc)
jsonrpc_no_parse_error(jsonrpc)
method := object.get(jsonrpc, "method", "")
is_string(method)
method != ""
object.get(jsonrpc, "mcp_method_classification", "") in {"available", "extension"}
endpoint := _matching_endpoint_configs[_]
endpoint.protocol == "mcp"
endpoint_path_matches_request(endpoint, input.request)
}
# These reasons use fixed text because method names and tool parameters can
# contain caller data. Deny rules take precedence over missing allow rules.
request_deny_reason := reason if {
mcp_policy_request
deny_request
reason := "MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors"
}
request_deny_reason := reason if {
mcp_policy_request
not deny_request
not allow_request
input.request.jsonrpc.mcp_method_classification == "extension"
reason := "MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions"
}
request_deny_reason := reason if {
mcp_policy_request
not deny_request
not allow_request
input.request.jsonrpc.mcp_method_classification == "available"
input.request.jsonrpc.method == "tools/call"
reason := "MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors"
}
request_deny_reason := reason if {
mcp_policy_request
not deny_request
not allow_request
input.request.jsonrpc.mcp_method_classification == "available"
input.request.jsonrpc.method != "tools/call"
reason := "MCP core method is not permitted by policy; ask the policy owner to review rules for this method in the selected MCP revision"
}
request_deny_reason := reason if {
input.request
deny_request
not graphql_request_has_operations(input.request)
not jsonrpc_response_frame_present(input.request)
not mcp_policy_request
reason := sprintf("%s %s blocked by deny rule", [input.request.method, input.request.path])
}
@@ -438,6 +493,7 @@ request_deny_reason := reason if {
not allow_request
not graphql_request_has_operations(input.request)
not jsonrpc_response_frame_present(input.request)
not mcp_policy_request
reason := sprintf("%s %s not permitted by policy", [input.request.method, input.request.path])
}
@@ -814,10 +814,7 @@ fn parse_mcp_payload(
TowerMcpMethodClassification::Unavailable => {
return JsonRpcRequestInfo::rejected(
inspection.payload().is_batch(),
JsonRpcInspectionError::mcp_profile_violation(format!(
"MCP {revision} does not make `{}` available",
method.method()
)),
unavailable_mcp_method_error(revision, method.method()),
);
}
_ => {
@@ -944,10 +941,7 @@ pub(crate) fn inspect_mcp_payload_for_revision(
for method in inspection.methods() {
match method.classification() {
TowerMcpMethodClassification::Unavailable => {
return Err(JsonRpcInspectionError::mcp_profile_violation(format!(
"MCP {revision} does not make `{}` available",
method.method()
)));
return Err(unavailable_mcp_method_error(revision, method.method()));
}
TowerMcpMethodClassification::Available | TowerMcpMethodClassification::Extension => {}
_ => {
@@ -962,6 +956,17 @@ pub(crate) fn inspect_mcp_payload_for_revision(
Ok(inspection)
}
// Only a method recognized by Tower's closed core-method registry reaches
// this diagnostic. Policy permission cannot make it valid in another revision.
fn unavailable_mcp_method_error(
revision: McpProtocolVersion,
method: &str,
) -> JsonRpcInspectionError {
JsonRpcInspectionError::mcp_profile_violation(format!(
"MCP method `{method}` is unavailable in revision {revision}; use a method defined by this core revision, or check client/server support and mcp.versions before selecting another revision; allow rules and allow_all_known_mcp_methods cannot enable an unavailable method"
))
}
fn parse_mcp_initialize(payload: JsonRpcPayload) -> JsonRpcRequestInfo {
if payload.is_batch() {
return JsonRpcRequestInfo::rejected(
@@ -1080,10 +1085,18 @@ fn mcp_named_request_for_inspected_method(
fn map_mcp_inspection_error(error: McpInspectionError) -> JsonRpcInspectionError {
match error.kind() {
McpInspectionErrorKind::BatchUnavailable
| McpInspectionErrorKind::DirectionMismatch
| McpInspectionErrorKind::UnsupportedProfile => {
JsonRpcInspectionError::mcp_profile_violation(error.to_string())
McpInspectionErrorKind::BatchUnavailable => JsonRpcInspectionError::mcp_profile_violation(
format!("{error}; send each JSON-RPC message in a separate request for this revision"),
),
McpInspectionErrorKind::DirectionMismatch => {
JsonRpcInspectionError::mcp_profile_violation(format!(
"{error}; send the method from the peer role defined by this revision; an allow rule cannot change its direction"
))
}
McpInspectionErrorKind::UnsupportedProfile => {
JsonRpcInspectionError::mcp_profile_violation(format!(
"{error}; use an MCP revision supported by this OpenShell build"
))
}
McpInspectionErrorKind::InitializeInBatch => {
JsonRpcInspectionError::mcp_lifecycle_violation(error.to_string())
@@ -1889,12 +1902,18 @@ mod tests {
info.error.as_ref().map(JsonRpcInspectionError::kind),
Some(JsonRpcInspectionErrorKind::McpProfileViolation)
);
let detail = info.error.as_ref().expect("unavailable method").detail();
assert!(detail.contains("`tasks/get` is unavailable in revision 2025-06-18"));
assert!(detail.contains("check client/server support and mcp.versions"));
assert!(detail.contains("allow_all_known_mcp_methods cannot enable"));
assert!(
info.error
.as_ref()
.is_some_and(|error| error.detail().contains("tasks/get")),
"expected unavailable-method evidence, got {info:?}"
!detail.contains("task-1"),
"task params must not be reflected"
);
let november =
parse_jsonrpc_body_with_options(body, mcp_options(McpProtocolVersion::V2025_11_25));
assert!(november.error.is_none(), "November task remains valid");
}
#[test]
@@ -1919,6 +1938,11 @@ mod tests {
.is_some_and(|error| error.detail().contains("client-to-server")),
"expected direction mismatch evidence, got {info:?}"
);
assert!(info.error.as_ref().is_some_and(|error| {
error
.detail()
.contains("an allow rule cannot change its direction")
}));
}
#[test]
@@ -39,6 +39,20 @@ pub(super) enum McpProtocolVersionError {
}
impl McpProtocolVersionError {
/// Explain a rejected revision using validated header metadata.
///
/// Missing headers select the 2025-03-26 fallback. Only the validated parser
/// may identify that case; duplicate or hop-by-hop headers must keep their
/// own rejection reason without being described as absent.
pub(super) fn rejection_detail(self, request: &L7Request) -> String {
match self {
Self::NotAllowed(version) => {
format!("{self}; {}", selected_revision_context(request, version))
}
_ => self.to_string(),
}
}
/// Return the HTTP status for this transport or policy rejection.
#[must_use]
pub(super) const fn http_status(self) -> &'static str {
@@ -68,20 +82,20 @@ impl std::fmt::Display for McpProtocolVersionError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidHeader => formatter.write_str(
"MCP-Protocol-Version must contain one non-empty end-to-end header value",
"MCP-Protocol-Version must contain one non-empty end-to-end header value; send exactly one revision, without duplicates, comma-separated values, or Connection nomination",
),
Self::UnsupportedHeaderValue => {
formatter.write_str("MCP-Protocol-Version names an unsupported protocol version")
formatter.write_str("MCP-Protocol-Version names a revision unsupported by this OpenShell build; use a supported client/server revision permitted by mcp.versions")
}
Self::InvalidRequestMetadata => {
formatter.write_str("MCP request headers must match the inspected request metadata")
formatter.write_str("MCP request headers must match the inspected request metadata; send MCP-Protocol-Version, Mcp-Method, and any required Mcp-Name consistently with the 2026-07-28 message")
}
Self::MethodNotAllowed => {
formatter.write_str("MCP protocol version 2026-07-28 requires HTTP POST")
formatter.write_str("MCP protocol version 2026-07-28 requires HTTP POST; send a POST message instead of a legacy GET stream or DELETE session request")
}
Self::NotAllowed(version) => write!(
formatter,
"MCP protocol version {version} is not allowed by endpoint policy"
"MCP protocol version {version} is not allowed by endpoint policy; use a client/server revision permitted by mcp.versions"
),
}
}
@@ -89,6 +103,23 @@ impl std::fmt::Display for McpProtocolVersionError {
impl std::error::Error for McpProtocolVersionError {}
/// Describe how the request selected its MCP revision.
///
/// Initial and post-middleware inspection supply their current request so the
/// explanation describes the headers used for that selection.
pub(super) fn selected_revision_context(
request: &L7Request,
version: McpProtocolVersion,
) -> String {
match request_protocol_version_header(&request.raw_header) {
Ok(None) => format!(
"selected MCP revision {version} from the missing MCP-Protocol-Version header fallback; send the client/server revision explicitly in that header"
),
Ok(Some(_)) => format!("selected MCP revision {version} from MCP-Protocol-Version"),
Err(_) => format!("selected MCP revision {version}"),
}
}
/// Select and authorize the protocol revision for one MCP HTTP request.
///
/// Legacy initialization negotiates its revision in the body. It cannot exempt
@@ -224,7 +224,10 @@ where
// even when the caller disconnects before receiving it.
observer.observe(EndpointResult::PolicyDenied);
}
let reason = error.to_string();
let reason = format!(
"{error}; {}",
crate::l7::mcp::selected_revision_context(request, version)
);
let summary = l7_protocol_log_summary(None, Some(&info));
ocsf_emit!(build_l7_request_event(
ctx,
@@ -305,7 +308,7 @@ where
// Record the denial before client delivery can fail.
observer.observe(EndpointResult::PolicyDenied);
}
let reason = error.to_string();
let reason = error.rejection_detail(request);
let summary = l7_protocol_log_summary(None, Some(info));
ocsf_emit!(build_l7_request_event(
ctx,
@@ -10992,23 +10995,31 @@ network_policies:
#[tokio::test]
async fn mcp_relay_rejects_invalid_disallowed_and_missing_versions_without_forwarding() {
for (headers, status, code) in [
for (headers, status, code, remedy) in [
(
"MCP-Protocol-Version: 2026-07-29\r\n",
"400 Bad Request",
"unsupported_mcp_protocol_version",
"use a supported client/server revision permitted by mcp.versions",
),
(
"MCP-Protocol-Version: 2025-11-25\r\nMCP-Protocol-Version: 2025-11-25\r\n",
"400 Bad Request",
"invalid_mcp_protocol_version_header",
"send exactly one revision",
),
(
"MCP-Protocol-Version: 2025-06-18\r\n",
"403 Forbidden",
"mcp_protocol_version_not_allowed",
"selected MCP revision 2025-06-18 from MCP-Protocol-Version",
),
(
"",
"403 Forbidden",
"mcp_protocol_version_not_allowed",
"missing MCP-Protocol-Version header fallback; send the client/server revision explicitly",
),
("", "403 Forbidden", "mcp_protocol_version_not_allowed"),
] {
let (response, forwarded) = run_rejected_mcp_request(
false,
@@ -11021,6 +11032,7 @@ network_policies:
"{response}"
);
assert!(response.contains(code), "{response}");
assert!(response.contains(remedy), "{response}");
assert!(forwarded.is_empty(), "rejected request reached upstream");
}
}
@@ -11506,12 +11518,40 @@ network_policies:
response.contains("does not permit top-level JSON-RPC batches"),
"{response}"
);
assert!(response.contains("send each JSON-RPC message in a separate request"));
assert!(response.contains("selected MCP revision 2025-11-25 from MCP-Protocol-Version"));
assert!(
forwarded.is_empty(),
"profile-invalid request reached upstream"
);
}
#[tokio::test]
async fn mcp_relay_explains_unavailable_method_without_reflecting_params() {
// tasks/update is known to the parser but absent from the selected
// core revision. The diagnostic must not suggest adding an allow rule.
let body = br#"{"jsonrpc":"2.0","id":1,"method":"tasks/update","params":{"taskId":"private-task-marker","inputResponses":{"secret":"private-argument-marker"}}}"#;
for route_selected in [false, true] {
let (response, forwarded) = run_rejected_mcp_request(
route_selected,
"MCP-Protocol-Version: 2025-11-25\r\n",
body,
)
.await;
assert!(
response.starts_with("HTTP/1.1 400 Bad Request"),
"{response}"
);
assert!(response.contains("invalid_mcp_request"), "{response}");
assert!(response.contains("`tasks/update` is unavailable in revision 2025-11-25"));
assert!(response.contains("allow_all_known_mcp_methods cannot enable"));
assert!(response.contains("from MCP-Protocol-Version"));
assert!(!response.contains("private-task-marker"));
assert!(!response.contains("private-argument-marker"));
assert!(forwarded.is_empty(), "unavailable method reached upstream");
}
}
#[tokio::test]
async fn final_mcp_version_check_reclassifies_a_rewritten_initialize_body() {
let (config, _, ctx) = mcp_test_relay_context();
+156 -12
View File
@@ -4925,6 +4925,31 @@ process:
val == regorus::Value::from(true)
}
fn assert_l7_denial(
engine: &OpaEngine,
input: &serde_json::Value,
deny_rule_matches: bool,
expected_reason: &str,
) {
let mut eng = engine.engine.lock().unwrap();
set_regorus_input(&mut eng, input.clone()).unwrap();
assert_eq!(
eng.eval_rule("data.openshell.sandbox.allow_request".into())
.expect("evaluate allow_request"),
regorus::Value::from(false),
);
assert_eq!(
eng.eval_rule("data.openshell.sandbox.deny_request".into())
.expect("evaluate deny_request"),
regorus::Value::from(deny_rule_matches),
);
assert_eq!(
eng.eval_rule("data.openshell.sandbox.request_deny_reason".into())
.expect("evaluate one unambiguous denial reason"),
regorus::Value::from(expected_reason),
);
}
fn eval_l7_raw_data(data: serde_json::Value, input: serde_json::Value) -> bool {
let mut engine = regorus::Engine::new();
engine
@@ -6271,10 +6296,34 @@ network_policies:
"tools/call",
serde_json::json!({"name": "blocked_action"}),
);
assert!(!eval_l7(&engine, &blocked));
assert_l7_denial(
&engine,
&blocked,
true,
"MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors",
);
let unmatched_tool = l7_jsonrpc_input_with_params(
"mcp.params.test",
8000,
"/mcp",
"tools/call",
serde_json::json!({"name": "private_tool_name", "arguments.secret": "private-value"}),
);
assert_l7_denial(
&engine,
&unmatched_tool,
false,
"MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors",
);
let list_tools = l7_jsonrpc_input("mcp.params.test", 8000, "/mcp", "tools/list");
assert!(!eval_l7(&engine, &list_tools));
assert_l7_denial(
&engine,
&list_tools,
false,
"MCP core method is not permitted by policy; ask the policy owner to review rules for this method in the selected MCP revision",
);
}
#[test]
@@ -6311,12 +6360,19 @@ network_policies:
let list_tools = l7_jsonrpc_input("mcp.default.test", 8000, "/mcp", "tools/list");
assert!(eval_l7(&engine, &list_tools));
let mut extension = l7_jsonrpc_input("mcp.default.test", 8000, "/mcp", "vendor/extension");
let mut extension = l7_jsonrpc_input(
"mcp.default.test",
8000,
"/mcp",
"vendor/private_method_name",
);
extension["request"]["jsonrpc"]["mcp_method_classification"] =
serde_json::json!("extension");
assert!(
!eval_l7(&engine, &extension),
"allow_all_known_mcp_methods must cover only selected-profile methods"
assert_l7_denial(
&engine,
&extension,
false,
"MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions",
);
}
@@ -6382,20 +6438,108 @@ network_policies:
l7_jsonrpc_input("mcp.extension-wildcard.test", 8000, "/mcp", "tools/vendor");
wildcard["request"]["jsonrpc"]["mcp_method_classification"] =
serde_json::json!("extension");
assert!(
!eval_l7(&engine, &wildcard),
"wildcards must not authorize unknown extension methods"
assert_l7_denial(
&engine,
&wildcard,
false,
"MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions",
);
let mut denied =
l7_jsonrpc_input("mcp.extension-denied.test", 8000, "/mcp", "tools/vendor");
denied["request"]["jsonrpc"]["mcp_method_classification"] = serde_json::json!("extension");
assert!(
!eval_l7(&engine, &denied),
"deny-rule wildcards must still block explicitly allowed extensions"
assert_l7_denial(
&engine,
&denied,
true,
"MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors",
);
}
#[test]
fn l7_mcp_denial_reason_matches_request_path_and_protocol() {
let data = r#"
network_policies:
mixed:
name: mixed
endpoints:
- host: mcp.reasons.test
port: 8000
path: /rpc
protocol: json-rpc
enforcement: enforce
rules: [{allow: {method: reports.list}}]
- host: mcp.reasons.test
port: 8000
path: /rest
protocol: rest
enforcement: enforce
rules: [{allow: {method: GET, path: /rest}}]
- host: mcp.reasons.test
port: 8000
path: /mcp
protocol: mcp
enforcement: enforce
mcp:
allow_all_known_mcp_methods: true
rules: [{allow: {tool: read_status}}]
binaries:
- {path: /usr/bin/curl}
"#;
let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml");
// Select the explanation by the current request path, not the first
// configured endpoint for this host and port.
let unmatched_tool = l7_jsonrpc_input_with_params(
"mcp.reasons.test",
8000,
"/mcp",
"tools/call",
serde_json::json!({"name": "other_tool"}),
);
assert_l7_denial(
&engine,
&unmatched_tool,
false,
"MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors",
);
for path in ["/rest", "/rpc", "/other"] {
assert_l7_denial(
&engine,
&l7_jsonrpc_input("mcp.reasons.test", 8000, path, "tools/call"),
false,
&format!("POST {path} not permitted by policy"),
);
}
// Batch envelopes have no selected call until the relay evaluates
// each member. Protocol failures also remain outside policy hints.
for field in ["method", "error", "mcp_method_classification"] {
let mut input = unmatched_tool.clone();
input["request"]["jsonrpc"][field] = match field {
"method" => serde_json::Value::Null,
"error" => serde_json::json!("invalid MCP request"),
_ => serde_json::json!("unavailable"),
};
assert_l7_denial(&engine, &input, false, "POST /mcp not permitted by policy");
}
assert_l7_denial(
&engine,
&l7_jsonrpc_response_input("mcp.reasons.test", 8000, "/rpc"),
true,
"JSON-RPC response frames are not permitted from client to server",
);
assert!(eval_l7(
&engine,
&l7_jsonrpc_response_input("mcp.reasons.test", 8000, "/mcp")
));
assert!(eval_l7(
&engine,
&l7_jsonrpc_input("mcp.reasons.test", 8000, "/mcp", "tools/list")
));
}
#[test]
fn l7_jsonrpc_null_metadata_non_matches_without_opa_error() {
let data = r#"