Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)));
}
Expand Down Expand Up @@ -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)),
Expand All @@ -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(
Expand Down
129 changes: 126 additions & 3 deletions crates/openshell-supervisor-network/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<McpProtocolVersion>,
/// 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.
Expand Down Expand Up @@ -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: &regorus::Value) -> Option<L7EndpointConfig> {
let protocol_val = get_object_str(val, "protocol")?;
let protocol = L7Protocol::parse(&protocol_val)?;
Expand Down Expand Up @@ -231,6 +237,11 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
.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,
Expand Down Expand Up @@ -271,6 +282,7 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
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,
Expand Down Expand Up @@ -402,6 +414,27 @@ fn get_object_str(val: &regorus::Value, key: &str) -> Option<String> {
}
}

fn parse_canonical_mcp_versions(val: &regorus::Value) -> Option<Vec<McpProtocolVersion>> {
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::<McpProtocolVersion>().ok(),
_ => None,
})
.collect::<Option<Vec<_>>>()?;

// 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: &regorus::Value) -> bool {
has_non_empty_object_field(val, "graphql_persisted_queries")
|| has_graphql_persisted_query_mode(val)
Expand Down Expand Up @@ -1252,6 +1285,7 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, 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();
Expand Down Expand Up @@ -1571,6 +1605,55 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
(errors, warnings)
}

fn validate_mcp_versions_field(
errors: &mut Vec<String>,
loc: &str,
endpoint: &serde_json::Value,
protocol: Option<L7Protocol>,
) {
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::<McpProtocolVersion>() 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`.
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading