diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 5f3ec4e452..b2c15e4595 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -316,9 +316,9 @@ impl L7ConfigStanza { /// /// The stanza schema stays tied to this crate's canonical serde definitions, so /// adding a new supported field requires updating this conversion next to the -/// type that parses it. MCP revision fields are validated before the alias is -/// flattened so invalid authoring cannot disappear when version metadata is -/// omitted from the returned runtime-only fields. +/// type that parses it. MCP revision fields are validated and materialized +/// before the alias is flattened so both runtime ingress paths receive the +/// same canonical allowlist. pub fn l7_config_alias_runtime_fields( stanza: L7ConfigStanza, value: serde_json::Value, @@ -338,12 +338,15 @@ pub fn l7_config_alias_runtime_fields( .map_err(|error| miette::miette!("invalid mcp config: {error}"))?; validate_authored_mcp_versions(config.versions.as_deref(), "invalid mcp config")?; let McpConfigDef { - versions: _, + versions, max_body_bytes, strict_tool_names, allow_all_known_mcp_methods, } = config; + let mut versions = versions.unwrap_or_else(default_mcp_versions); + canonicalize_mcp_versions(&mut versions); let mut fields = Vec::new(); + fields.push(("mcp_versions", serde_json::json!(versions))); if max_body_bytes > 0 { fields.push(("json_rpc_max_body_bytes", serde_json::json!(max_body_bytes))); } @@ -2560,6 +2563,10 @@ network_policies: assert_eq!( fields, vec![ + ( + "mcp_versions", + serde_json::json!(["2025-03-26", "2025-06-18", "2025-11-25"]) + ), ("json_rpc_max_body_bytes", serde_json::json!(131_072)), ("mcp_strict_tool_names", serde_json::json!(false)), ("mcp_allow_all_known_mcp_methods", serde_json::json!(true)), @@ -2570,10 +2577,16 @@ network_policies: L7ConfigStanza::Mcp, serde_json::json!({"strict_tool_names": false}), ) - .expect("runtime alias parsing does not select a wire profile yet"); + .expect("runtime alias parsing supplies the pinned wire profile"); assert_eq!( runtime_only_fields, - vec![("mcp_strict_tool_names", serde_json::json!(false))] + vec![ + ( + "mcp_versions", + serde_json::json!([DEFAULT_MCP_PROTOCOL_VERSION.as_str()]) + ), + ("mcp_strict_tool_names", serde_json::json!(false)) + ] ); let err = l7_config_alias_runtime_fields( diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 70a980ba2d..5e28a67a10 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -21,6 +21,7 @@ pub mod tls; pub(crate) mod token_grant_injection; pub(crate) mod websocket; +use openshell_core::mcp::McpProtocolVersion; pub use openshell_policy::L7Protocol; use openshell_policy::{ L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, @@ -119,6 +120,10 @@ pub struct L7EndpointConfig { /// MCP-only strict validation for tools/call params.name. Defaults to true /// for MCP endpoints and is ignored by other JSON-RPC-family protocols. pub mcp_strict_tool_names: bool, + /// Canonical MCP protocol revisions allowed by this endpoint. + /// + /// Non-MCP endpoints always carry an empty list. + pub mcp_versions: Vec, /// When true, percent-encoded `/` (`%2F`) is preserved in path segments /// rather than rejected at the parser. Needed by upstreams like GitLab /// that embed `%2F` in namespaced project paths. Defaults to false. @@ -172,7 +177,8 @@ pub struct L7RequestInfo { /// Parse an L7 endpoint config from a regorus Value (returned by Rego query). /// /// The value is expected to be the raw endpoint object from the Rego data, -/// containing fields: `protocol`, optionally `tls`, `enforcement`. +/// containing fields: `protocol`, optionally `tls`, `enforcement`, and the +/// canonical `mcp_versions` array for MCP endpoints. pub fn parse_l7_config(val: ®orus::Value) -> Option { let protocol_val = get_object_str(val, "protocol")?; let protocol = L7Protocol::parse(&protocol_val)?; @@ -231,6 +237,11 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { .unwrap_or(jsonrpc::DEFAULT_MAX_BODY_BYTES); let mcp_strict_tool_names = protocol == L7Protocol::Mcp && get_object_bool(val, "mcp_strict_tool_names").unwrap_or(true); + let mcp_versions = if protocol == L7Protocol::Mcp { + parse_canonical_mcp_versions(val)? + } else { + Vec::new() + }; let credential_signing = match get_object_str(val, "credential_signing").as_deref() { Some("sigv4") => CredentialSigning::SigV4, @@ -271,6 +282,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { graphql_max_body_bytes, json_rpc_max_body_bytes, mcp_strict_tool_names, + mcp_versions, allow_encoded_slash, websocket_credential_rewrite, request_body_credential_rewrite, @@ -402,6 +414,27 @@ fn get_object_str(val: ®orus::Value, key: &str) -> Option { } } +fn parse_canonical_mcp_versions(val: ®orus::Value) -> Option> { + let regorus::Value::Array(values) = get_object_value(val, "mcp_versions")? else { + return None; + }; + let versions = values + .iter() + .map(|value| match value { + regorus::Value::String(value) => value.parse::().ok(), + _ => None, + }) + .collect::>>()?; + + // Both policy ingress paths promise a non-empty, strictly ordered list. + // Rechecking that runtime contract prevents a dropped or corrupted + // projection from silently selecting a different wire profile. + if versions.is_empty() || !versions.windows(2).all(|pair| pair[0] < pair[1]) { + return None; + } + Some(versions) +} + fn endpoint_has_graphql_policy(val: ®orus::Value) -> bool { has_non_empty_object_field(val, "graphql_persisted_queries") || has_graphql_persisted_query_mode(val) @@ -1252,6 +1285,7 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< "{loc}: JSON-RPC-specific endpoint fields are ignored unless protocol is json-rpc or mcp" )); } + validate_mcp_versions_field(&mut errors, &loc, ep, l7_protocol); let has_mcp_strict_tool_names = ep.get("mcp_strict_tool_names").is_some(); let has_mcp_allow_all_known_mcp_methods = ep.get("mcp_allow_all_known_mcp_methods").is_some(); @@ -1571,6 +1605,55 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< (errors, warnings) } +fn validate_mcp_versions_field( + errors: &mut Vec, + loc: &str, + endpoint: &serde_json::Value, + protocol: Option, +) { + let Some(value) = endpoint.get("mcp_versions") else { + return; + }; + if protocol != Some(L7Protocol::Mcp) { + errors.push(format!( + "{loc}: mcp.versions is only valid for protocol mcp" + )); + return; + } + let Some(values) = value.as_array() else { + errors.push(format!("{loc}: mcp.versions must be an array")); + return; + }; + if values.is_empty() { + errors.push(format!("{loc}: mcp.versions must not be empty")); + return; + } + + let mut versions = Vec::with_capacity(values.len()); + for value in values { + let Some(value) = value.as_str() else { + errors.push(format!("{loc}: mcp.versions entries must be strings")); + return; + }; + let Ok(version) = value.parse::() else { + errors.push(format!( + "{loc}: mcp.versions contains an unsupported protocol version" + )); + return; + }; + versions.push(version); + } + + // Runtime ingress has already normalized the allowlist. Requiring strict + // order here catches duplicates and bypasses that could otherwise fail + // later by removing L7 inspection from the selected route. + if !versions.windows(2).all(|pair| pair[0] < pair[1]) { + errors.push(format!( + "{loc}: mcp.versions must be unique and in canonical order" + )); + } +} + /// Map a supported L7 `access` preset to explicit rules for `protocol`. /// /// Returns `None` when `access` is not `read-only`, `read-write`, or `full`. @@ -1884,7 +1967,7 @@ mod tests { #[test] fn parse_l7_config_mcp_strict_tool_names_defaults_true() { let val = regorus::Value::from_json_str( - r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443}"#, + r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443, "mcp_versions": ["2025-11-25"]}"#, ) .unwrap(); let config = parse_l7_config(&val).unwrap(); @@ -1894,13 +1977,53 @@ mod tests { #[test] fn parse_l7_config_mcp_strict_tool_names_can_disable() { let val = regorus::Value::from_json_str( - r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443, "mcp_strict_tool_names": false}"#, + r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443, "mcp_strict_tool_names": false, "mcp_versions": ["2025-11-25"]}"#, ) .unwrap(); let config = parse_l7_config(&val).unwrap(); assert!(!config.mcp_strict_tool_names); } + #[test] + fn parse_l7_config_requires_canonical_mcp_versions() { + let explicit = regorus::Value::from_json_str( + r#"{"protocol": "mcp", "mcp_versions": ["2025-03-26", "2025-11-25"]}"#, + ) + .unwrap(); + assert_eq!( + parse_l7_config(&explicit).unwrap().mcp_versions, + vec![ + McpProtocolVersion::V2025_03_26, + McpProtocolVersion::V2025_11_25 + ] + ); + + for invalid in [ + r#"{"protocol": "mcp"}"#, + r#"{"protocol": "mcp", "mcp_versions": []}"#, + r#"{"protocol": "mcp", "mcp_versions": ["2026-01-01"]}"#, + r#"{"protocol": "mcp", "mcp_versions": [1]}"#, + r#"{"protocol": "mcp", "mcp_versions": ["2025-11-25", "2025-11-25"]}"#, + r#"{"protocol": "mcp", "mcp_versions": ["2025-11-25", "2025-03-26"]}"#, + ] { + let value = regorus::Value::from_json_str(invalid).unwrap(); + assert!( + parse_l7_config(&value).is_none(), + "non-canonical MCP runtime config must be rejected: {invalid}" + ); + } + } + + #[test] + fn parse_l7_config_keeps_mcp_versions_off_other_protocols() { + let value = regorus::Value::from_json_str( + r#"{"protocol": "json-rpc", "mcp_versions": ["2025-11-25"]}"#, + ) + .unwrap(); + + assert!(parse_l7_config(&value).unwrap().mcp_versions.is_empty()); + } + #[test] fn parse_l7_config_websocket_credential_rewrite_defaults_false() { let val = regorus::Value::from_json_str( diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9e4949b191..1a5e82f645 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -7357,6 +7357,7 @@ network_policies: graphql_max_body_bytes: 0, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash, websocket_credential_rewrite: false, request_body_credential_rewrite: false, @@ -7801,6 +7802,7 @@ network_policies: graphql_max_body_bytes: 0, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, @@ -8003,6 +8005,7 @@ network_policies: graphql_max_body_bytes: 0, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, @@ -8129,6 +8132,7 @@ network_policies: graphql_max_body_bytes: 0, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 8d77da76e9..14170ff9ee 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1507,6 +1507,28 @@ fn normalize_l7_config_aliases(data: &mut serde_json::Value) -> Vec { for stanza in L7ConfigStanza::ALL { normalize_l7_config_alias(&mut errors, ep_obj, &loc, stanza); } + + // The nested MCP stanza is optional, but the runtime projection is + // not. Materialize the pinned default at this YAML boundary so a + // missing alias cannot later look like corrupted runtime state. + if ep_obj + .get("protocol") + .and_then(serde_json::Value::as_str) + .is_some_and(|protocol| protocol.eq_ignore_ascii_case("mcp")) + && !ep_obj.contains_key("mcp_versions") + { + match openshell_policy::l7_config_alias_runtime_fields( + L7ConfigStanza::Mcp, + serde_json::json!({}), + ) { + Ok(fields) => { + for (field, value) in fields { + ep_obj.insert(field.to_string(), value); + } + } + Err(error) => errors.push(format!("{loc}.mcp: {error}")), + } + } } } @@ -1531,6 +1553,15 @@ fn normalize_l7_config_alias( Ok(fields) => { ep.remove(key); for (field, value) in fields { + if stanza == L7ConfigStanza::Mcp + && field == "mcp_versions" + && ep.contains_key(field) + { + errors.push(format!( + "{loc}: mcp.versions and mcp_versions cannot both be set" + )); + continue; + } ep.entry(field.to_string()).or_insert(value); } } @@ -2064,6 +2095,9 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St ep["json_rpc_max_body_bytes"] = e.json_rpc_max_body_bytes.into(); } if let Some(mcp) = &e.mcp { + if e.protocol.eq_ignore_ascii_case("mcp") { + ep["mcp_versions"] = mcp.versions.clone().into(); + } if let Some(strict_tool_names) = mcp.strict_tool_names { ep["mcp_strict_tool_names"] = strict_tool_names.into(); } @@ -2288,7 +2322,10 @@ mod tests { }], ..Default::default() }], - ..Default::default() + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], }, ); policy @@ -4859,6 +4896,7 @@ network_policies: protocol: mcp enforcement: enforce mcp: + versions: ["2025-11-25", "2025-03-26"] strict_tool_names: false rules: - allow: @@ -4882,6 +4920,151 @@ network_policies: let l7 = crate::l7::parse_l7_config(&config).expect("parse l7 config"); assert_eq!(l7.protocol, crate::l7::L7Protocol::Mcp); assert!(!l7.mcp_strict_tool_names); + assert_eq!( + l7.mcp_versions, + vec![ + openshell_core::mcp::McpProtocolVersion::V2025_03_26, + openshell_core::mcp::McpProtocolVersion::V2025_11_25, + ] + ); + } + + #[test] + fn yaml_load_accepts_mixed_case_mcp_protocol_with_default_versions() { + let data = r#" +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + protocol: MCP + rules: + - allow: + method: tools/list + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + let input = NetworkInput { + host: "mcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let config = engine + .query_endpoint_config(&input) + .expect("query endpoint config") + .expect("expected MCP endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).expect("parse L7 endpoint config"); + + assert_eq!(l7.mcp_versions, vec![DEFAULT_MCP_PROTOCOL_VERSION]); + } + + #[test] + fn yaml_load_rejects_invalid_flat_mcp_versions_before_activation() { + for (case, versions) in [ + ("empty", "[]"), + ("non-string", "[1]"), + ("unsupported", "[\"2026-01-01\"]"), + ("duplicate", "[\"2025-11-25\", \"2025-11-25\"]"), + ("non-canonical", "[\"2025-11-25\", \"2025-03-26\"]"), + ] { + let data = format!( + r#" +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + protocol: mcp + mcp_versions: {versions} + rules: + - allow: + method: tools/list + binaries: + - {{ path: /usr/bin/curl }} +"# + ); + + let Err(error) = OpaEngine::from_strings(TEST_POLICY, &data) else { + panic!("invalid MCP runtime metadata must reject activation: {case}"); + }; + assert!( + error.to_string().contains("mcp.versions"), + "{case}: {error}" + ); + } + } + + #[test] + fn yaml_load_rejects_nested_and_flat_mcp_version_collision() { + let data = r#" +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + protocol: mcp + mcp_versions: ["2025-03-26"] + mcp: + versions: ["2025-11-25"] + rules: + - allow: + method: tools/list + binaries: + - { path: /usr/bin/curl } +"#; + + let Err(error) = OpaEngine::from_strings(TEST_POLICY, data) else { + panic!("ambiguous MCP revision sources must reject activation"); + }; + assert!( + error + .to_string() + .contains("mcp.versions and mcp_versions cannot both be set"), + "{error}" + ); + } + + #[test] + fn yaml_load_rejects_mcp_versions_on_non_mcp_protocols() { + for mcp_fields in [ + "mcp:\n versions: [\"2025-11-25\"]", + "mcp_versions: [\"2025-11-25\"]", + ] { + let data = format!( + r#" +network_policies: + json_rpc: + name: json_rpc + endpoints: + - host: rpc.example.com + port: 443 + protocol: json-rpc + {mcp_fields} + rules: + - allow: + method: ping + binaries: + - {{ path: /usr/bin/curl }} +"# + ); + + let Err(error) = OpaEngine::from_strings(TEST_POLICY, &data) else { + panic!("MCP revision policy must not apply to generic JSON-RPC"); + }; + assert!( + error + .to_string() + .contains("mcp.versions is only valid for protocol mcp"), + "{error}" + ); + } } #[test] @@ -5302,16 +5485,71 @@ network_policies: } #[test] - fn proto_load_accepts_defaultable_mcp_versions() { - for policy in [ - defaultable_mcp_proto(None), - defaultable_mcp_proto(Some(McpOptions::default())), - ] { - OpaEngine::from_proto(&policy) + fn proto_load_accepts_defaultable_mcp_versions_with_mixed_case_protocol() { + let mut implicit_defaults = defaultable_mcp_proto(None); + implicit_defaults + .network_policies + .get_mut("mcp") + .expect("defaultable MCP fixture contains the MCP policy") + .endpoints[0] + .protocol = "Mcp".to_string(); + let explicit_defaults = defaultable_mcp_proto(Some(McpOptions::default())); + + for policy in [implicit_defaults, explicit_defaults] { + let engine = OpaEngine::from_proto(&policy) .expect("supervisor ingress must materialize the pinned MCP revision"); + let input = NetworkInput { + host: "mcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let config = engine + .query_endpoint_config(&input) + .expect("query endpoint config") + .expect("expected MCP endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).expect("parse L7 endpoint config"); + assert_eq!( + l7.mcp_versions, + vec![DEFAULT_MCP_PROTOCOL_VERSION], + "protobuf ingress must preserve the materialized default through OPA" + ); } } + #[test] + fn proto_load_projects_canonical_mcp_versions_to_l7_config() { + let policy = defaultable_mcp_proto(Some(McpOptions { + versions: vec!["2025-11-25".to_string(), "2025-03-26".to_string()], + ..Default::default() + })); + let engine = OpaEngine::from_proto(&policy).expect("valid MCP policy"); + let input = NetworkInput { + host: "mcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let config = engine + .query_endpoint_config(&input) + .expect("query endpoint config") + .expect("expected MCP endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).expect("parse L7 endpoint config"); + + assert_eq!( + l7.mcp_versions, + vec![ + openshell_core::mcp::McpProtocolVersion::V2025_03_26, + openshell_core::mcp::McpProtocolVersion::V2025_11_25, + ], + "protobuf ingress must preserve the canonical allowlist through OPA" + ); + } + #[test] fn proto_load_rejects_unsupported_mcp_versions() { let policy = defaultable_mcp_proto(Some(McpOptions { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 177d640fd8..84df3801ce 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7306,6 +7306,7 @@ network_policies: graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite, request_body_credential_rewrite: false, @@ -8545,6 +8546,7 @@ network_policies: graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, @@ -8565,6 +8567,7 @@ network_policies: graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 9e00cfa254..d5d1828622 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -409,6 +409,7 @@ mod tests { graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, + mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false,