feat(mcp): inspect requests with Tower-selected protocol profiles (#3335)

* fix(sandbox-backend): sort boundary request objects before hashing

Sort boundary request objects recursively before hashing so serde_json's
preserve_order feature cannot change digest identity. Cover canonical
bytes, envelope round trips, and rejection of modified provider values
and operations.

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

* feat(mcp): upgrade tower-mcp-types to 0.22.2

Upgrade tower-mcp-types from 0.12.0 to an exact-pinned 0.22.2 and use its
inspection APIs to validate MCP requests against the selected revision.
Carry inspection metadata into policy evaluation and validate requests
after header rewriting, before forwarding.

Add explicit support for the sessionless 2026-07-28 revision while keeping
2025-11-25 as the default. Validate per-request metadata and standard HTTP
header mirrors, and support discovery, tools, and subscription requests.

Delegate batch availability and parameter schemas to Tower. Share typed
request names between policy and HTTP checks, retain the local batch
resource cap, and centralize MCP policy version parsing and ordering.

Keep supported MCP revisions and shared allowlist parsing in the canonical
policy schema; core re-exports those types. Tower owns wire-profile
semantics, and every supported policy revision must map to the matching
inspector profile.

Reject duplicate JSON keys, invalid known-method parameters, unavailable
methods, and unsupported batches. Keep exact extension allow rules and
deny precedence. Document request inspection boundaries and add unit,
forwarding, and sandbox coverage.

Refs #2174.

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

* test(mcp): prove authorization at the forwarding boundary

Cover March batch denial in both member orders, valid and malformed
controls, and audit behavior across both relay entry paths. Exercise real
middleware tool rewrites with matching metadata and assert the exact
upstream representation or zero forwarded bytes.

Verify legacy bodyless SSE GET remains usable while GET tool bodies and
unsupported DELETE cleanup are rejected. Clarify request-selected profile
and middleware mutation comments without changing production behavior.

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

* test(mcp): exercise permitted profiles through the sandbox proxy

Cover March and June singleton policies and select November and July
separately under one endpoint allowlist. Capture upstream tool receipts
to distinguish proxy policy denial from an upstream rejection.

Extend middleware rewrite coverage to June and multi-version policies,
and preserve the sessionless discovery and subscription checks through
the shared fixture helpers.

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

* test(kubernetes): box the admission check future

Keep the admission test future below Clippy's size limit when the
workspace dependency features are unified.

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

* test(mcp): reuse the forwarding fixture identity cache

Share the binary identity cache across protocol-profile cases, matching
the proxy lifecycle and avoiding repeated hashes of the test executable.
Keep procfs authorization and all forwarding assertions intact.

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

---------

Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
Shiju
2026-09-28 20:48:50 +00:00
committed by GitHub
parent b77f5ddfc1
commit 1358941b81
29 changed files with 3822 additions and 514 deletions
Generated
+40 -31
View File
@@ -636,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -737,6 +737,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64-simd"
version = "0.8.0"
@@ -832,7 +838,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4"
dependencies = [
"base64",
"base64 0.22.1",
"bollard-stubs",
"bytes",
"futures-core",
@@ -2387,7 +2393,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"headers-core",
"http 1.4.0",
@@ -2685,7 +2691,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -3160,7 +3166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [
"aws-lc-rs",
"base64",
"base64 0.22.1",
"ed25519-dalek 2.2.0",
"getrandom 0.2.17",
"hmac 0.12.1",
@@ -3183,7 +3189,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5"
dependencies = [
"base64",
"base64 0.22.1",
"chrono",
"serde",
"serde-value",
@@ -3264,7 +3270,7 @@ version = "0.99.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"either",
@@ -3542,7 +3548,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
dependencies = [
"base64",
"base64 0.22.1",
"http-body-util",
"hyper",
"hyper-util",
@@ -3858,7 +3864,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -3990,7 +3996,7 @@ name = "openshell-cli"
version = "0.0.0"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"clap",
@@ -4066,7 +4072,7 @@ name = "openshell-core"
version = "0.0.0"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"chrono",
"glob",
"ipnet",
@@ -4107,7 +4113,7 @@ version = "0.0.0"
dependencies = [
"async-trait",
"aws-lc-rs",
"base64",
"base64 0.22.1",
"futures",
"openshell-core",
"serde",
@@ -4219,7 +4225,7 @@ dependencies = [
name = "openshell-driver-mxc"
version = "0.0.0"
dependencies = [
"base64",
"base64 0.22.1",
"futures",
"noyalib",
"openshell-core",
@@ -4305,7 +4311,7 @@ dependencies = [
name = "openshell-driver-vm"
version = "0.0.0"
dependencies = [
"base64",
"base64 0.22.1",
"bollard",
"clap",
"flate2",
@@ -4491,6 +4497,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]]
@@ -4543,7 +4550,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"base64 0.22.1",
"bytes",
"capctl",
"clap",
@@ -4648,7 +4655,7 @@ dependencies = [
"aws-config",
"aws-sdk-sts",
"axum",
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"clap",
@@ -4816,7 +4823,7 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-runtime-api",
"base64",
"base64 0.22.1",
"bytes",
"flate2",
"futures",
@@ -4868,7 +4875,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"base64 0.22.1",
"bytes",
"hex",
"libc",
@@ -4894,7 +4901,7 @@ dependencies = [
name = "openshell-tui"
version = "0.0.0"
dependencies = [
"base64",
"base64 0.22.1",
"crossterm 0.28.1",
"futures",
"indexmap",
@@ -5170,7 +5177,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"base64 0.22.1",
"serde_core",
]
@@ -5579,7 +5586,7 @@ version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
dependencies = [
"base64",
"base64 0.22.1",
"prost",
"prost-types",
"serde",
@@ -5988,7 +5995,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
@@ -6029,7 +6036,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
@@ -6637,6 +6644,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
@@ -7002,7 +7010,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"cfg-if",
"crc",
@@ -7104,7 +7112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags 2.13.2",
"byteorder",
"crc",
@@ -7702,7 +7710,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64",
"base64 0.22.1",
"bytes",
"h2",
"http 1.4.0",
@@ -7800,7 +7808,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.13.2",
"bytes",
"futures-util",
@@ -7824,11 +7832,12 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-mcp-types"
version = "0.12.0"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6511f1f32c7cb7fd4525edc0eb4dcf307db8f7eceb2833ab24a37b4cc10cda61"
checksum = "bafe456e348e69895f4d0f1731a8f3094075f0e5f0a3f7d5f3c2443a0412b035"
dependencies = [
"base64",
"base64 0.23.1",
"indexmap",
"serde",
"serde_json",
"thiserror 2.0.20",
@@ -8819,7 +8828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"base64 0.22.1",
"deadpool",
"futures",
"http 1.4.0",
+1 -1
View File
@@ -88,7 +88,7 @@ serde_json = "1"
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
toml = "0.8"
apollo-parser = "0.8.5"
tower-mcp-types = "0.12.0"
tower-mcp-types = "=0.22.2"
regex = "1"
# HTTP client
+7 -1
View File
@@ -397,7 +397,13 @@ support `params` matchers; generic JSON-RPC rules match only the method.
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
are relayed but are not currently parsed for policy enforcement.
Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, and `2025-11-25`. A policy author may omit the entire `mcp` object when using the other endpoint defaults, or omit `mcp.versions` while setting another MCP option. Both forms resolve immediately to the exact allowlist `["2025-11-25"]`; omission never means latest or all known revisions. Defaulting applies only when the corresponding YAML key is absent: `mcp: null`, `versions: null`, and an explicit `versions: []` are invalid. At protobuf ingress, an empty repeated field means omission and uses the same default because protobuf repeated fields do not preserve presence. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision to the registry never widens a previously normalized policy. An explicit nonempty allowlist remains available as an advanced compatibility or downgrade control. The registry is a closed set rather than a date range, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request runtime contract. A version names a core protocol revision only; there is no policy syntax for layering a separately named SEP onto it. The registry owns immutable batch-shape metadata: `2025-03-26` permits nonempty same-side top-level JSON-RPC batches, which OpenShell's planned enforcement caps at 64 members, while `2025-06-18` and `2025-11-25` prohibit top-level arrays. These are declared profile facts, not current forwarding claims. The allowlist does not yet select request parsing or forwarding behavior. Later response-aware runtime state must observe the successful server response, require the selected revision to be in the allowlist, and apply that one exact profile without a union or fallback; OpenShell must not bind the client proposal in `initialize` as though it were the server-selected revision.
Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, `2025-11-25`, and `2026-07-28`. Omitting the `mcp` object or its `versions` key resolves immediately to `["2025-11-25"]`. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision never widens a normalized policy. Explicit `mcp: null`, `versions: null`, and `versions: []` are invalid; at protobuf ingress, an empty repeated field means omission because repeated fields do not preserve presence. The registry is a closed set, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. A version names a core protocol revision; separately named extensions have no policy version selector.
For the 2025 revisions, a valid standalone `initialize` proposes a version in its body. Every subsequent HTTP request selects its inspection revision through one `MCP-Protocol-Version` header. An absent header selects the transport's `2025-03-26` compatibility fallback, which is permitted only when the endpoint allowlist contains it. The supervisor does not inspect initialization responses, bind `MCP-Session-Id`, or infer a version from an earlier request. Duplicate, empty, or unsupported version headers receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. The `2025-03-26` profile permits nonempty same-side top-level JSON-RPC batches capped at 64 members. Later profiles prohibit top-level arrays.
The sessionless `2026-07-28` profile requires an explicit allowlist entry. Each HTTP POST carries one JSON-RPC request or explicitly allowed extension notification. JSON-RPC requests carry their protocol version and client capabilities in `params._meta`. The supervisor requires the version header to match that metadata, `Mcp-Method` to match the JSON-RPC method, and `Mcp-Name` to match the name for `tools/call` and `prompts/get` or the URI for `resources/read`. It decodes the standard base64 header sentinel before comparing names. These checks also apply to unknown extension requests. Extension notifications require an exact method allow rule and `MCP-Protocol-Version: 2026-07-28` to select this profile; they do not require request metadata or method/name mirrors because this revision defines no notification-header contract. This profile has no `initialize` or `notifications/initialized` handshake, standalone GET stream, or DELETE session operation. Clients may call `server/discover` before other methods; `subscriptions/listen` receives notifications on its POST response stream. Client response frames and the stdio-only `notifications/cancelled` method are rejected. Custom tool-schema header mappings and response payloads, including multi-round tool results and SSE messages, are outside request policy inspection.
After the transport selects a revision, the supervisor delegates structural JSON-RPC and exact-profile MCP batch availability, method, direction, message-kind, parameter, and metadata type checks to `tower-mcp-types`, pinned to `0.22.2`. MCP type validation follows Tower's deserialization and inspection APIs; OpenShell does not maintain a second MCP field schema or claim complete JSON-schema validation. Tool authorization and standard HTTP name headers use the same Tower-typed parameters. OpenShell owns request-size and duplicate-key defenses, revision selection and allowlisting, HTTP/body consistency, the batch-member resource bound, strict tool-name policy, authorization, logging, and forwarding. It repeats the request checks after middleware changes the request and before any upstream write. Methods available in the selected profile may use normal MCP policy matching; known methods unavailable in that profile are rejected; unknown extensions require an exact method literal and are never covered by `mcp.allow_all_known_mcp_methods`.
For admitted HTTP requests, the proxy can run an ordered supervisor middleware
chain after L7 policy evaluation and before credential injection. Destination
+51 -47
View File
@@ -1,15 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! `OpenShell`-owned MCP protocol revisions and immutable batch-shape metadata.
use std::collections::BTreeSet;
//! `OpenShell`-owned MCP policy revisions, allowlist parsing, and resource limits.
use crate::proto::{McpOptions, ProviderProfile};
pub use openshell_policy_schema::{
DEFAULT_MCP_PROTOCOL_VERSION, MAX_MCP_LEGACY_BATCH_MESSAGES, McpProtocolVersion,
McpWireProfile, ParseMcpProtocolVersionError,
ParseMcpProtocolVersionError, ParseMcpVersionsError, canonicalize_mcp_versions,
parse_mcp_versions,
};
/// Return whether a policy protocol name denotes MCP.
@@ -48,20 +47,11 @@ pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) {
continue;
}
// Parse into the shared version type before mutation. Comparing the
// set size with the input length detects duplicates without erasing
// the duplicate values that a fail-closed validator must report.
let Ok(versions) = options
.versions
.iter()
.map(|version| version.parse::<McpProtocolVersion>())
.collect::<Result<BTreeSet<_>, _>>()
else {
// Validate the explicit list before mutation so malformed values retain
// their original order and spelling for the checked policy boundary.
let Ok(versions) = parse_mcp_versions(&options.versions) else {
continue;
};
if versions.len() != options.versions.len() {
continue;
}
options.versions = versions
.into_iter()
@@ -85,6 +75,7 @@ mod tests {
McpProtocolVersion::V2025_03_26,
McpProtocolVersion::V2025_06_18,
McpProtocolVersion::V2025_11_25,
McpProtocolVersion::V2026_07_28,
]
);
assert!(
@@ -95,17 +86,47 @@ mod tests {
}
#[test]
fn default_mcp_protocol_version_is_pinned_to_the_2025_11_25_profile() {
fn default_mcp_protocol_version_is_pinned_to_the_2025_11_25_revision() {
assert_eq!(
DEFAULT_MCP_PROTOCOL_VERSION,
McpProtocolVersion::V2025_11_25
);
assert_eq!(DEFAULT_MCP_PROTOCOL_VERSION.as_str(), "2025-11-25");
}
let profile = DEFAULT_MCP_PROTOCOL_VERSION.wire_profile();
assert_eq!(profile.version(), McpProtocolVersion::V2025_11_25);
assert!(!profile.allows_json_rpc_batches());
assert_eq!(profile.max_batch_messages(), None);
#[test]
fn parse_mcp_versions_returns_canonical_order_without_mutating_the_authored_list() {
let values = ["2026-07-28", "2025-03-26", "2025-11-25", "2025-06-18"].map(str::to_string);
let original = values.clone();
let versions = parse_mcp_versions(&values).expect("explicit supported revisions");
assert_eq!(
versions.into_iter().collect::<Vec<_>>(),
McpProtocolVersion::ALL
);
assert_eq!(values, original);
}
#[test]
fn parse_mcp_versions_reports_the_first_error_without_repairing_input() {
let duplicate = ParseMcpVersionsError::Duplicate(McpProtocolVersion::V2025_03_26);
let unsupported = ParseMcpVersionsError::Unsupported(
" 2025-11-25"
.parse::<McpProtocolVersion>()
.expect_err("revision is unsupported"),
);
for (values, expected) in [
(vec![], ParseMcpVersionsError::Empty),
(vec!["2025-03-26", "2025-03-26", " 2025-11-25"], duplicate),
(vec![" 2025-11-25", "2025-03-26", "2025-03-26"], unsupported),
] {
let values = values.into_iter().map(str::to_string).collect::<Vec<_>>();
let original = values.clone();
assert_eq!(parse_mcp_versions(&values), Err(expected));
assert_eq!(values, original);
}
}
fn provider_profile_with_mcp(protocol: &str, options: Option<McpOptions>) -> ProviderProfile {
@@ -166,6 +187,7 @@ mod tests {
Some(McpOptions {
strict_tool_names: Some(true),
versions: vec![
"2026-07-28".to_string(),
"2025-11-25".to_string(),
"2025-03-26".to_string(),
"2025-06-18".to_string(),
@@ -197,6 +219,7 @@ mod tests {
"2025-03-26".to_string(),
"2025-06-18".to_string(),
"2025-11-25".to_string(),
"2026-07-28".to_string(),
],
..McpOptions::default()
}
@@ -207,9 +230,11 @@ mod tests {
fn provider_profile_mcp_normalization_preserves_every_malformed_explicit_list() {
for versions in [
vec!["2025-11-25", "2025-11-25"],
vec!["2026-07-28", "2025-03-26", "2025-03-26", "2025-06-18"],
vec!["2026-07-28", "latest", "2025-03-26"],
vec![" 2025-11-25"],
vec!["2025-11-25 "],
vec!["2026-07-28"],
vec!["2026-07-29"],
vec!["latest"],
vec!["draft"],
] {
@@ -249,6 +274,7 @@ mod tests {
assert_eq!("2025-03-26".parse(), Ok(McpProtocolVersion::V2025_03_26));
assert_eq!("2025-06-18".parse(), Ok(McpProtocolVersion::V2025_06_18));
assert_eq!("2025-11-25".parse(), Ok(McpProtocolVersion::V2025_11_25));
assert_eq!("2026-07-28".parse(), Ok(McpProtocolVersion::V2026_07_28));
for unsupported in [
"",
@@ -256,13 +282,14 @@ mod tests {
" 2025-06-18",
"2025-11-25\n",
"2025-11-24",
"2026-07-28",
"2026-07-28 ",
"2026-07-29",
"draft",
"latest",
] {
let error = unsupported
.parse::<McpProtocolVersion>()
.expect_err("unsupported MCP revision must be rejected");
.expect_err("unsupported revision must fail");
assert_eq!(error.value(), unsupported);
}
}
@@ -289,29 +316,6 @@ mod tests {
assert_eq!(error.value(), rejected);
}
#[test]
fn mcp_protocol_version_wire_profiles_define_batch_metadata() {
assert_eq!(MAX_MCP_LEGACY_BATCH_MESSAGES, 64);
let legacy = McpProtocolVersion::V2025_03_26.wire_profile();
assert_eq!(legacy.version(), McpProtocolVersion::V2025_03_26);
assert!(legacy.allows_json_rpc_batches());
assert_eq!(
legacy.max_batch_messages(),
Some(MAX_MCP_LEGACY_BATCH_MESSAGES)
);
for version in [
McpProtocolVersion::V2025_06_18,
McpProtocolVersion::V2025_11_25,
] {
let profile = version.wire_profile();
assert_eq!(profile.version(), version);
assert!(!profile.allows_json_rpc_batches());
assert_eq!(profile.max_batch_messages(), None);
}
}
#[test]
fn mcp_options_versions_field_number_is_stable() {
let descriptor_set = FileDescriptorSet::decode(crate::FILE_DESCRIPTOR_SET)
@@ -7332,7 +7332,7 @@ mod tests {
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert!(error.message().contains("allow_driver_config"));
assert!(
matches!(driver.create_sandbox_inner(&sandbox).await, Err(KubernetesDriverError::Precondition(message)) if message.contains("allow_driver_config"))
matches!(Box::pin(driver.create_sandbox_inner(&sandbox)).await, Err(KubernetesDriverError::Precondition(message)) if message.contains("allow_driver_config"))
);
}
}
+33 -6
View File
@@ -134,12 +134,24 @@ fn reason_tag(base: &BaseEventData) -> String {
.map_or_else(String::new, |text| format!(" [reason:{text}]"))
}
fn unmapped_fields(base: &BaseEventData) -> Vec<String> {
base.unmapped
fn sorted_unmapped_fields(base: &BaseEventData) -> Vec<(&str, &serde_json::Value)> {
let mut fields: Vec<_> = base
.unmapped
.as_ref()
.and_then(serde_json::Value::as_object)
.into_iter()
.flatten()
.map(|(key, value)| (key.as_str(), value))
.collect();
// Cargo can enable insertion-ordered JSON maps through another dependency.
// Keep shorthand ordering and truncated field selection stable either way.
fields.sort_unstable_by_key(|(key, _)| *key);
fields
}
fn unmapped_fields(base: &BaseEventData) -> Vec<String> {
sorted_unmapped_fields(base)
.into_iter()
.filter_map(|(key, value)| {
let value = match value {
serde_json::Value::Bool(value) => value.to_string(),
@@ -502,9 +514,9 @@ impl OcsfEvent {
if obj.is_empty() {
return None;
}
let fields: Vec<String> = obj
.iter()
.take(3) // Limit to 3 most important fields
let fields: Vec<String> = sorted_unmapped_fields(&e.base)
.into_iter()
.take(3)
.map(|(k, v)| {
let val = v.as_str().map_or_else(|| v.to_string(), String::from);
format!("{k}:{val}")
@@ -676,8 +688,8 @@ mod tests {
#[test]
fn test_http_activity_shorthand_includes_unmapped_attributes() {
let mut base = base(4002, "HTTP Activity", 4, "Network Activity", 99, "Other");
base.add_unmapped("attempt", serde_json::json!(2));
base.add_unmapped("cached", serde_json::json!(true));
base.add_unmapped("attempt", serde_json::json!(2));
let event = OcsfEvent::HttpActivity(HttpActivityEvent {
base,
http_request: Some(HttpRequest::new(
@@ -1246,4 +1258,19 @@ mod tests {
"EVENT [INFO] Network namespace created [ns:openshell-sandbox-abc123]"
);
}
#[test]
fn test_base_event_selects_unmapped_fields_in_key_order() {
let mut b = base(0, "Base Event", 0, "Uncategorized", 99, "Other");
b.set_message("Context");
for (key, value) in [("z", 4), ("c", 3), ("a", 1), ("b", 2)] {
b.add_unmapped(key, serde_json::json!(value));
}
let event = OcsfEvent::Base(BaseEvent { base: b });
assert_eq!(
event.format_shorthand(),
"EVENT [INFO] Context [a:1 b:2 c:3]"
);
}
}
@@ -15,6 +15,7 @@ miette = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yml = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true
+118 -51
View File
@@ -7,7 +7,7 @@
//! pure schema validation, and lexical policy-path normalization. Runtime and
//! protobuf adaptation intentionally live in `openshell-policy`.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs::File;
use std::io::Read;
@@ -17,16 +17,24 @@ use std::str::FromStr;
use miette::{IntoDiagnostic, Result, WrapErr};
use serde::{Deserialize, Deserializer, Serialize};
/// Fixed batch-member bound for the MCP 2025-03-26 wire profile.
/// Fixed resource bound for legacy MCP request batches inspected by Tower.
pub const MAX_MCP_LEGACY_BATCH_MESSAGES: usize = 64;
/// Stable MCP protocol revisions accepted in authored policy.
///
/// This closed vocabulary pins product support and ordering independently of
/// the protocol inspector. Wire semantics remain owned by the inspector.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum McpProtocolVersion {
/// MCP protocol revision `2025-03-26`.
V2025_03_26,
/// MCP protocol revision `2025-06-18`.
V2025_06_18,
/// MCP protocol revision `2025-11-25`.
V2025_11_25,
/// Sessionless MCP protocol revision `2026-07-28`.
V2026_07_28,
}
/// Pinned revision used when authored policy omits MCP versions.
@@ -35,30 +43,22 @@ pub const DEFAULT_MCP_PROTOCOL_VERSION: McpProtocolVersion = McpProtocolVersion:
pub const MCP_VERSION_REMEDIATION: &str = "omit mcp.versions to use the pinned default revision, use an exact supported revision, or omit protocol and mcp for deliberate uninspected L4 passthrough only when that weaker boundary is acceptable";
impl McpProtocolVersion {
pub const ALL: &'static [Self] = &[Self::V2025_03_26, Self::V2025_06_18, Self::V2025_11_25];
/// Every supported policy revision in canonical semantic order.
pub const ALL: &'static [Self] = &[
Self::V2025_03_26,
Self::V2025_06_18,
Self::V2025_11_25,
Self::V2026_07_28,
];
/// Return the exact MCP protocol identifier accepted in policy.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::V2025_03_26 => "2025-03-26",
Self::V2025_06_18 => "2025-06-18",
Self::V2025_11_25 => "2025-11-25",
}
}
#[must_use]
pub const fn wire_profile(self) -> McpWireProfile {
match self {
Self::V2025_03_26 => McpWireProfile {
version: self,
allows_json_rpc_batches: true,
max_batch_messages: Some(MAX_MCP_LEGACY_BATCH_MESSAGES),
},
Self::V2025_06_18 | Self::V2025_11_25 => McpWireProfile {
version: self,
allows_json_rpc_batches: false,
max_batch_messages: None,
},
Self::V2026_07_28 => "2026-07-28",
}
}
}
@@ -77,6 +77,7 @@ impl FromStr for McpProtocolVersion {
"2025-03-26" => Ok(Self::V2025_03_26),
"2025-06-18" => Ok(Self::V2025_06_18),
"2025-11-25" => Ok(Self::V2025_11_25),
"2026-07-28" => Ok(Self::V2026_07_28),
_ => Err(ParseMcpProtocolVersionError {
value: value.to_owned(),
}),
@@ -84,12 +85,14 @@ impl FromStr for McpProtocolVersion {
}
}
/// Error returned for a revision outside the exact policy vocabulary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseMcpProtocolVersionError {
value: String,
}
impl ParseMcpProtocolVersionError {
/// Return the original rejected identifier without normalization.
#[must_use]
pub fn value(&self) -> &str {
&self.value
@@ -108,29 +111,64 @@ impl fmt::Display for ParseMcpProtocolVersionError {
impl std::error::Error for ParseMcpProtocolVersionError {}
/// Immutable batch-shape metadata for an exact MCP revision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct McpWireProfile {
version: McpProtocolVersion,
allows_json_rpc_batches: bool,
max_batch_messages: Option<usize>,
/// Sort MCP policy revisions without hiding invalid input.
///
/// Supported revisions use [`McpProtocolVersion::ALL`] semantic order, followed
/// by unsupported identifiers in lexical order. Duplicate values and the exact
/// spelling of every identifier remain available to subsequent validation.
/// Empty lists remain empty; the caller owns omission and default handling.
pub fn canonicalize_mcp_versions(versions: &mut [String]) {
versions.sort_by(|left, right| {
match (
left.parse::<McpProtocolVersion>(),
right.parse::<McpProtocolVersion>(),
) {
(Ok(left), Ok(right)) => left.cmp(&right),
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
(Err(_), Err(_)) => left.cmp(right),
}
});
}
impl McpWireProfile {
#[must_use]
pub const fn version(self) -> McpProtocolVersion {
self.version
/// Parse an explicit MCP policy allowlist into canonical semantic order.
///
/// This does not choose a default or modify the input. Callers must handle
/// omitted fields before passing an explicit list to this function.
///
/// # Errors
///
/// Returns [`ParseMcpVersionsError::Empty`] for an empty list, or the first
/// unsupported or duplicate revision in input order.
pub fn parse_mcp_versions(
values: &[String],
) -> std::result::Result<BTreeSet<McpProtocolVersion>, ParseMcpVersionsError> {
if values.is_empty() {
return Err(ParseMcpVersionsError::Empty);
}
#[must_use]
pub const fn allows_json_rpc_batches(self) -> bool {
self.allows_json_rpc_batches
let mut versions = BTreeSet::new();
for value in values {
let version = value.parse::<McpProtocolVersion>()?;
if !versions.insert(version) {
return Err(ParseMcpVersionsError::Duplicate(version));
}
}
Ok(versions)
}
#[must_use]
pub const fn max_batch_messages(self) -> Option<usize> {
self.max_batch_messages
}
/// Error returned when an explicit MCP policy allowlist is invalid.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseMcpVersionsError {
/// The explicitly supplied allowlist contains no revisions.
#[error("mcp.versions must contain at least one supported protocol version")]
Empty,
/// An identifier does not exactly match a supported revision.
#[error(transparent)]
Unsupported(#[from] ParseMcpProtocolVersionError),
/// A supported revision occurs more than once in the list.
#[error("duplicate MCP protocol version '{0}'")]
Duplicate(McpProtocolVersion),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -960,21 +998,19 @@ pub fn validate_mcp_config(config: &McpConfig, context: &str) -> Result<()> {
let Some(versions) = config.versions.as_deref() else {
return Ok(());
};
if versions.is_empty() {
miette::bail!(
"{context} has an empty mcp.versions list; omit it to use the pinned default revision"
);
}
let mut seen = std::collections::BTreeSet::new();
for value in versions {
let version = value
.parse::<McpProtocolVersion>()
.map_err(|error| miette::miette!("{context}: {error}; {MCP_VERSION_REMEDIATION}"))?;
if !seen.insert(version) {
miette::bail!("{context} has duplicate protocol version '{value}'");
}
}
Ok(())
parse_mcp_versions(versions)
.map(|_| ())
.map_err(|error| match error {
ParseMcpVersionsError::Empty => miette::miette!(
"{context} has an empty mcp.versions list; omit it to use the pinned default revision"
),
ParseMcpVersionsError::Unsupported(error) => {
miette::miette!("{context}: {error}; {MCP_VERSION_REMEDIATION}")
}
ParseMcpVersionsError::Duplicate(version) => {
miette::miette!("{context} has duplicate protocol version '{version}'")
}
})
}
fn reject_json_unknown_fields(
@@ -1385,6 +1421,37 @@ network_policies:
assert!(parse_policy_bytes(&[0xff]).is_err());
}
#[test]
fn mcp_config_preserves_explicit_sessionless_versions_and_omission() {
let config = parse_mcp_config(serde_json::json!({
"versions": ["2026-07-28", "2025-03-26"],
}))
.expect("explicit supported revisions");
assert_eq!(
config.versions.as_deref(),
Some(["2026-07-28".to_string(), "2025-03-26".to_string()].as_slice())
);
assert!(
parse_mcp_config(serde_json::json!({}))
.unwrap()
.versions
.is_none()
);
assert_eq!(DEFAULT_MCP_PROTOCOL_VERSION.as_str(), "2025-11-25");
}
#[test]
fn mcp_config_rejects_empty_duplicate_and_unknown_versions() {
for versions in [
serde_json::json!([]),
serde_json::json!(["2026-07-28", "2026-07-28"]),
serde_json::json!(["2026-07-29"]),
serde_json::json!([" 2026-07-28"]),
] {
assert!(parse_mcp_config(serde_json::json!({ "versions": versions })).is_err());
}
}
#[test]
fn access_presets_expand_consistently() {
assert_eq!(
+30 -19
View File
@@ -25,7 +25,9 @@ pub use ambiguity::{EndpointAmbiguity, find_endpoint_ambiguities};
use hickory_proto::rr::Name;
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion};
use openshell_core::mcp::{
DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion, canonicalize_mcp_versions,
};
use openshell_core::proto::{
FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule,
LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy,
@@ -360,22 +362,6 @@ fn default_mcp_versions() -> Vec<String> {
vec![DEFAULT_MCP_PROTOCOL_VERSION.as_str().to_string()]
}
fn canonicalize_mcp_versions(versions: &mut [String]) {
// Unknown and duplicate values remain present so canonicalization cannot
// erase evidence that the raw policy was invalid.
versions.sort_by(|left, right| {
match (
left.parse::<McpProtocolVersion>(),
right.parse::<McpProtocolVersion>(),
) {
(Ok(left), Ok(right)) => left.cmp(&right),
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
(Err(_), Err(_)) => left.cmp(right),
}
});
}
/// Sort one protobuf MCP contract without hiding invalid input.
///
/// Exact supported revisions use semantic catalog order. Duplicate and
@@ -2508,7 +2494,7 @@ network_policies:
}
}
const MCP_VERSIONS: [&str; 3] = ["2025-03-26", "2025-06-18", "2025-11-25"];
const MCP_VERSIONS: [&str; 4] = ["2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"];
fn mcp_version_options(versions: &[&str]) -> McpOptions {
McpOptions {
@@ -2609,6 +2595,30 @@ network_policies:
assert!(McpProtocolVersion::ALL.len() > default_mcp_versions().len());
}
#[test]
fn sessionless_mcp_version_requires_an_explicit_policy_opt_in() {
let mut authored =
mcp_version_endpoint_yaml("mcp", Some(" versions: [\"2026-07-28\"]\n"));
authored.push_str(" rules:\n - allow:\n method: tools/list\n");
let explicit = parse_sandbox_policy(&authored)
.expect("the sessionless revision must be accepted when explicitly allowed");
let options = explicit.network_policies["versioned"].endpoints[0]
.mcp
.as_ref()
.expect("MCP options must be materialized");
assert_eq!(options.versions, ["2026-07-28"]);
validate_sandbox_policy(&explicit)
.expect("a sessionless endpoint with an explicit method rule must validate");
let yaml = serialize_sandbox_policy(&explicit)
.expect("an explicitly allowed sessionless revision must serialize");
assert_eq!(
parse_sandbox_policy(&yaml).expect("the sessionless policy must round-trip"),
explicit
);
assert!(!default_mcp_versions().contains(&"2026-07-28".to_string()));
}
#[test]
fn mcp_version_yaml_rejects_explicit_empty_duplicate_unknown_and_misplaced_values() {
let cases = [
@@ -2778,7 +2788,7 @@ network_policies:
"rendered diagnostic omitted remediation choices: {authored_diagnostic}"
);
let policy = mcp_version_policy("mcp", Some(mcp_version_options(&["2026-07-28"])));
let policy = mcp_version_policy("mcp", Some(mcp_version_options(&["2026-07-29"])));
let violations = validate_sandbox_policy(&policy)
.expect_err("unsupported protobuf revisions must fail closed");
assert!(violations.iter().map(ToString::to_string).any(|message| {
@@ -2875,6 +2885,7 @@ network_policies:
let policy = mcp_version_policy(
"mcp",
Some(mcp_version_options(&[
"2026-07-28",
"2025-11-25",
"2025-03-26",
"2025-06-18",
+9 -2
View File
@@ -3119,6 +3119,8 @@ mod tests {
for versions in [
&["2025-03-26"][..],
&["2025-03-26", DEFAULT_MCP_VERSION][..],
&["2026-07-28"][..],
&[DEFAULT_MCP_VERSION, "2026-07-28"][..],
] {
let existing = rule_with_authorizations(
"existing",
@@ -3164,6 +3166,7 @@ mod tests {
let existing = rule_with_authorizations(
"existing",
vec![mcp_endpoint_with_versions(&[
"2026-07-28",
"2025-11-25",
"2025-03-26",
"2025-06-18",
@@ -3175,6 +3178,7 @@ mod tests {
vec![mcp_endpoint_with_versions(&[
"2025-06-18",
"2025-11-25",
"2026-07-28",
"2025-03-26",
])],
&["/usr/bin/new"],
@@ -3197,7 +3201,7 @@ mod tests {
.as_ref()
.expect("MCP endpoint must retain options")
.versions,
["2025-03-26", "2025-06-18", "2025-11-25"]
["2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"]
);
}
@@ -3208,6 +3212,7 @@ mod tests {
rule_with_authorizations(
"mcp",
vec![mcp_endpoint_with_versions(&[
"2026-07-28",
"2025-11-25",
"2025-03-26",
"2025-06-18",
@@ -3225,7 +3230,7 @@ mod tests {
.as_ref()
.expect("MCP endpoint must retain options")
.versions,
["2025-03-26", "2025-06-18", "2025-11-25"]
["2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"]
);
}
@@ -3237,6 +3242,7 @@ mod tests {
"2025-03-26",
"2025-06-18",
"2025-11-25",
"2026-07-28",
])],
&["/usr/bin/client"],
);
@@ -3251,6 +3257,7 @@ mod tests {
let equivalent = rule_with_authorizations(
"proposed",
vec![mcp_endpoint_with_versions(&[
"2026-07-28",
"2025-11-25",
"2025-03-26",
"2025-06-18",
@@ -13,6 +13,7 @@ network_policies:
enforcement: enforce
mcp:
versions:
- "2026-07-28"
- "2025-11-25"
- "2025-03-26"
- "2025-06-18"
+13 -37
View File
@@ -3,7 +3,10 @@
//! Declarative provider type profiles.
use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion};
use openshell_core::mcp::{
DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion, ParseMcpVersionsError,
canonicalize_mcp_versions, parse_mcp_versions,
};
use openshell_core::proto::{
GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary,
NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh,
@@ -499,23 +502,12 @@ fn default_mcp_profile_versions() -> Vec<String> {
fn validate_mcp_profile_versions(
values: &[String],
) -> Result<BTreeSet<McpProtocolVersion>, String> {
if values.is_empty() {
return Err(
"mcp.versions must contain at least one supported protocol version".to_string(),
);
}
let mut versions = BTreeSet::new();
for value in values {
let version = value
.parse::<McpProtocolVersion>()
.map_err(|error| format!("{error}; {MCP_VERSION_REMEDIATION}"))?;
if !versions.insert(version) {
return Err(format!("duplicate MCP protocol version '{value}'"));
parse_mcp_versions(values).map_err(|error| match error {
ParseMcpVersionsError::Unsupported(error) => {
format!("{error}; {MCP_VERSION_REMEDIATION}")
}
}
Ok(versions)
error => error.to_string(),
})
}
fn deserialize_mcp_profile_versions<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
@@ -1678,26 +1670,10 @@ fn materialize_and_canonicalize_mcp_profile_versions(versions: &mut Vec<String>)
if versions.is_empty() {
*versions = default_mcp_profile_versions();
} else {
canonicalize_mcp_profile_versions(versions);
canonicalize_mcp_versions(versions);
}
}
fn canonicalize_mcp_profile_versions(versions: &mut [String]) {
// Preserve unsupported and duplicate values so subsequent validation can
// reject them; sorting must never repair malformed protobuf input.
versions.sort_by(|left, right| {
match (
left.parse::<McpProtocolVersion>(),
right.parse::<McpProtocolVersion>(),
) {
(Ok(left), Ok(right)) => left.cmp(&right),
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
(Err(_), Err(_)) => left.cmp(right),
}
});
}
fn binary_to_proto(binary: &BinaryProfile) -> NetworkBinary {
NetworkBinary {
path: binary.path.clone(),
@@ -4100,7 +4076,7 @@ endpoints:
path: /mcp
protocol: mcp
mcp:
versions: ["2025-11-25", "2025-03-26", "2025-06-18"]
versions: ["2026-07-28", "2025-11-25", "2025-03-26", "2025-06-18"]
strict_tool_names: false
binaries:
- /usr/bin/example-agent
@@ -4108,7 +4084,7 @@ binaries:
)
.expect("profile should parse");
let expected_versions = ["2025-03-26", "2025-06-18", "2025-11-25"];
let expected_versions = ["2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"];
assert_eq!(
profile.endpoints[0]
.mcp
@@ -4559,7 +4535,7 @@ endpoints:
"versions: [\"2025-03-26\", \"2025-03-26\"]",
"versions: [latest]",
"versions: [draft]",
"versions: ['2026-07-28']",
"versions: ['2026-07-29']",
"versions: [\"2025-03-26 \"]",
] {
let yaml = format!(
@@ -653,10 +653,11 @@ impl RequestEnvelope {
}
fn request_payload_digest(request: &Request) -> Result<String, FrameError> {
// Round-tripping through Value canonicalizes every JSON object by key. In
// particular, this makes HashMap-backed provider environments stable
// across process restarts and independently serialized retries.
let normalized = serde_json::to_value(request).map_err(FrameError::Serialize)?;
// Sort every object explicitly: dependency features may make Value retain
// insertion order. Provider environments must hash identically after
// deserialization and across independently serialized retries.
let mut normalized = serde_json::to_value(request).map_err(FrameError::Serialize)?;
normalized.sort_all_objects();
let payload = serde_json::to_vec(&normalized).map_err(FrameError::Serialize)?;
let digest = Sha256::digest(payload);
Ok(format!("{digest:x}"))
@@ -1615,10 +1616,39 @@ mod tests {
revision: 2,
provider_env,
};
assert_eq!(
request_payload_digest(&build(first)).expect("first digest"),
request_payload_digest(&build(second)).expect("second digest")
// Pin the canonical bytes, including the nested environment object.
// Two randomized HashMaps can otherwise happen to iterate identically
// and conceal a serializer that preserves insertion order.
let expected = format!(
"{:x}",
Sha256::digest(
br#"{"generation":1,"operation":"update_provider_environment","provider_env":{"A":"1","B":"2"},"revision":2}"#
)
);
for provider_env in [first, second] {
let request = build(provider_env);
assert_eq!(request_payload_digest(&request).expect("digest"), expected);
// Deserialization reconstructs the map with an independent hash
// seed; validation must retain the sender's canonical digest.
let envelope = RequestEnvelope::new(request).expect("request envelope");
let frame = encode_frame(&envelope).expect("encode envelope");
let mut decoded: RequestEnvelope = decode_frame(&frame).expect("decode envelope");
assert_eq!(decoded.payload_digest, expected);
decoded
.validate_payload_digest()
.expect("round-trip digest");
let Request::UpdateProviderEnvironment { provider_env, .. } = &mut decoded.request
else {
panic!("decoded the wrong request variant");
};
provider_env.insert("A".to_string(), "changed".to_string());
assert!(matches!(
decoded.validate_payload_digest(),
Err(FrameError::PayloadDigestMismatch)
));
}
let mut envelope = RequestEnvelope::new(build(std::collections::HashMap::new()))
.expect("request envelope");
+7 -3
View File
@@ -6369,7 +6369,11 @@ mod tests {
authed_request(CreateSandboxRequest {
name: "mcp-canonical".to_string(),
spec: Some(SandboxSpec {
policy: Some(mcp_policy_with_versions(&["2025-11-25", "2025-03-26"])),
policy: Some(mcp_policy_with_versions(&[
"2026-07-28",
"2025-11-25",
"2025-03-26",
])),
..Default::default()
}),
labels: HashMap::new(),
@@ -6399,7 +6403,7 @@ mod tests {
.as_ref()
.expect("MCP options")
.versions;
assert_eq!(versions, &["2025-03-26", "2025-11-25"]);
assert_eq!(versions, &["2025-03-26", "2025-11-25", "2026-07-28"]);
}
#[tokio::test]
@@ -6590,7 +6594,7 @@ mod tests {
let state = test_server_state().await;
let cases: &[(&str, &[&str])] = &[
("mcp-duplicate-versions", &["2025-11-25", "2025-11-25"]),
("mcp-unsupported-version", &["2026-07-28"]),
("mcp-unsupported-version", &["2026-07-29"]),
];
for &(sandbox_name, versions) in cases {
@@ -472,6 +472,10 @@ request_allowed_for_endpoint(request, endpoint) if {
rule.allow.method
not jsonrpc_response_frame_present(request)
jsonrpc_rule_matches(request, endpoint, rule.allow)
jsonrpc := object.get(request, "jsonrpc", null)
method := object.get(jsonrpc, "method", "")
rule_method := object.get(rule.allow, "method", "")
jsonrpc_allow_rule_classification_allowed(jsonrpc, endpoint, method, rule_method)
}
# MCP can allow the method layer by endpoint option while still using
@@ -487,6 +491,7 @@ request_allowed_for_endpoint(request, endpoint) if {
method := object.get(jsonrpc, "method", "")
is_string(method)
method != ""
object.get(jsonrpc, "mcp_method_classification", "") == "available"
not mcp_tool_call_narrowed_by_policy(endpoint, method)
}
@@ -774,6 +779,23 @@ jsonrpc_rule_matches(request, endpoint, rule) if {
jsonrpc_rule_params_match_for_protocol(jsonrpc, endpoint, rule)
}
jsonrpc_allow_rule_classification_allowed(_, endpoint, _, _) if {
endpoint.protocol == "json-rpc"
}
jsonrpc_allow_rule_classification_allowed(jsonrpc, endpoint, _, _) if {
endpoint.protocol == "mcp"
object.get(jsonrpc, "mcp_method_classification", "") == "available"
}
# Extension methods remain addressable, but only by an exact policy literal.
# A wildcard must not silently authorize methods outside the selected core profile.
jsonrpc_allow_rule_classification_allowed(jsonrpc, endpoint, method, rule_method) if {
endpoint.protocol == "mcp"
object.get(jsonrpc, "mcp_method_classification", "") == "extension"
rule_method == method
}
jsonrpc_rule_method_matches(endpoint, _, rule_method) if {
endpoint.protocol == "json-rpc"
rule_method == "*"
File diff suppressed because it is too large Load Diff
+321 -20
View File
@@ -1,21 +1,25 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! MCP Streamable HTTP request-version selection.
//! MCP Streamable HTTP revision selection and request metadata validation.
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use openshell_core::mcp::McpProtocolVersion;
use crate::l7::jsonrpc::JsonRpcRequestInfo;
use crate::l7::provider::L7Request;
const MCP_PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
const MCP_METHOD_HEADER: &str = "mcp-method";
const MCP_NAME_HEADER: &str = "mcp-name";
/// Protocol revision selected for one MCP HTTP request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum McpRequestProtocolVersion {
/// A valid standalone initialize request selects its revision in the JSON-RPC body.
/// A valid standalone legacy initialize request negotiates its revision in the body.
Initialization,
/// A subsequent request selected an exact revision from its header or the legacy fallback.
/// A request selected an exact revision from its header or the legacy fallback.
Selected(McpProtocolVersion),
}
@@ -26,6 +30,10 @@ pub(super) enum McpProtocolVersionError {
InvalidHeader,
/// The header value is not an MCP revision supported by this `OpenShell` build.
UnsupportedHeaderValue,
/// Required HTTP metadata is missing, malformed, or differs from the inspected body.
InvalidRequestMetadata,
/// The selected revision does not support this HTTP method.
MethodNotAllowed,
/// The selected supported revision is absent from the endpoint allowlist.
NotAllowed(McpProtocolVersion),
}
@@ -35,7 +43,10 @@ impl McpProtocolVersionError {
#[must_use]
pub(super) const fn http_status(self) -> &'static str {
match self {
Self::InvalidHeader | Self::UnsupportedHeaderValue => "400 Bad Request",
Self::InvalidHeader | Self::UnsupportedHeaderValue | Self::InvalidRequestMetadata => {
"400 Bad Request"
}
Self::MethodNotAllowed => "405 Method Not Allowed",
Self::NotAllowed(_) => "403 Forbidden",
}
}
@@ -46,6 +57,8 @@ impl McpProtocolVersionError {
match self {
Self::InvalidHeader => "invalid_mcp_protocol_version_header",
Self::UnsupportedHeaderValue => "unsupported_mcp_protocol_version",
Self::InvalidRequestMetadata => "invalid_mcp_request_metadata",
Self::MethodNotAllowed => "mcp_http_method_not_allowed",
Self::NotAllowed(_) => "mcp_protocol_version_not_allowed",
}
}
@@ -60,6 +73,12 @@ impl std::fmt::Display for McpProtocolVersionError {
Self::UnsupportedHeaderValue => {
formatter.write_str("MCP-Protocol-Version names an unsupported protocol version")
}
Self::InvalidRequestMetadata => {
formatter.write_str("MCP request headers must match the inspected request metadata")
}
Self::MethodNotAllowed => {
formatter.write_str("MCP protocol version 2026-07-28 requires HTTP POST")
}
Self::NotAllowed(version) => write!(
formatter,
"MCP protocol version {version} is not allowed by endpoint policy"
@@ -72,32 +91,107 @@ impl std::error::Error for McpProtocolVersionError {}
/// Select and authorize the protocol revision for one MCP HTTP request.
///
/// MCP initialization negotiates its revision in the JSON-RPC body and is the
/// only request exempt from the header. Every other request is self-contained:
/// an absent header selects the specification-defined `2025-03-26` fallback,
/// and the resulting supported revision must appear in the endpoint allowlist.
/// Legacy initialization negotiates its revision in the body. It cannot exempt
/// an explicit `2026-07-28` request or an endpoint allowing only that revision.
/// An absent header otherwise selects the `2025-03-26` fallback; the selected
/// revision must appear in the endpoint allowlist.
pub(super) fn select_request_protocol_version(
request: &L7Request,
info: &JsonRpcRequestInfo,
allowed_versions: &[McpProtocolVersion],
) -> Result<McpRequestProtocolVersion, McpProtocolVersionError> {
if is_standalone_initialize(info) {
return Ok(McpRequestProtocolVersion::Initialization);
}
let version = match request_protocol_version_header(&request.raw_header)? {
let header = request_protocol_version_header(&request.raw_header)?;
let version = match header {
Some(value) => value
.parse::<McpProtocolVersion>()
.map_err(|_| McpProtocolVersionError::UnsupportedHeaderValue)?,
None => McpProtocolVersion::V2025_03_26,
};
let modern_only = !allowed_versions.is_empty()
&& allowed_versions
.iter()
.all(|version| *version == McpProtocolVersion::V2026_07_28);
if is_standalone_initialize(info) && version != McpProtocolVersion::V2026_07_28 && !modern_only
{
return Ok(McpRequestProtocolVersion::Initialization);
}
if !allowed_versions.contains(&version) {
return Err(McpProtocolVersionError::NotAllowed(version));
}
// Sessionless MCP carries each message in its own POST. Standalone GET
// streams and DELETE session termination belong to the legacy revisions.
if version == McpProtocolVersion::V2026_07_28 && request.action != "POST" {
return Err(McpProtocolVersionError::MethodNotAllowed);
}
Ok(McpRequestProtocolVersion::Selected(version))
}
/// Match the sessionless revision's mandatory HTTP mirrors to the inspected body.
///
/// Only validated request bodies carry this metadata. Extension notifications
/// have no specified mirrored-header contract; unknown `Mcp-Param-*` fields are
/// also left to the server that owns the tool schema.
pub(super) fn validate_request_metadata(
request: &L7Request,
info: &JsonRpcRequestInfo,
) -> Result<(), McpProtocolVersionError> {
if info.mcp_revision != Some(McpProtocolVersion::V2026_07_28) {
return Ok(());
}
let Some(metadata) = &info.mcp_http_metadata else {
return Ok(());
};
let invalid = McpProtocolVersionError::InvalidRequestMetadata;
let version = request_header_value(&request.raw_header, MCP_PROTOCOL_VERSION_HEADER)
.map_err(|_| invalid)?
.ok_or(invalid)?;
if version != McpProtocolVersion::V2026_07_28.as_str() {
return Err(invalid);
}
let method = request_header_value(&request.raw_header, MCP_METHOD_HEADER)
.map_err(|_| invalid)?
.ok_or(invalid)?;
if !is_plain_header_value(method) || method != metadata.method {
return Err(invalid);
}
if let Some(name) = &metadata.name {
let value = request_header_value(&request.raw_header, MCP_NAME_HEADER)
.map_err(|_| invalid)?
.ok_or(invalid)?;
// Decoding happens before equality so header-based routing and
// body-based policy authorize the same tool, prompt, or resource.
if decode_name_header(value)? != *name {
return Err(invalid);
}
}
Ok(())
}
fn is_plain_header_value(value: &str) -> bool {
value
.bytes()
.all(|byte| byte == b'\t' || (b' '..=b'~').contains(&byte))
}
fn decode_name_header(value: &str) -> Result<String, McpProtocolVersionError> {
if let Some(encoded) = value
.strip_prefix("=?base64?")
.and_then(|value| value.strip_suffix("?="))
{
let bytes = STANDARD
.decode(encoded)
.map_err(|_| McpProtocolVersionError::InvalidRequestMetadata)?;
return String::from_utf8(bytes)
.map_err(|_| McpProtocolVersionError::InvalidRequestMetadata);
}
if !is_plain_header_value(value) {
return Err(McpProtocolVersionError::InvalidRequestMetadata);
}
Ok(value.to_string())
}
fn is_standalone_initialize(info: &JsonRpcRequestInfo) -> bool {
!info.is_batch
&& !info.has_response
@@ -111,6 +205,13 @@ fn is_standalone_initialize(info: &JsonRpcRequestInfo) -> bool {
fn request_protocol_version_header(
raw_header: &[u8],
) -> Result<Option<&str>, McpProtocolVersionError> {
request_header_value(raw_header, MCP_PROTOCOL_VERSION_HEADER)
}
fn request_header_value<'a>(
raw_header: &'a [u8],
header_name: &str,
) -> Result<Option<&'a str>, McpProtocolVersionError> {
let header_end = raw_header
.windows(4)
.position(|window| window == b"\r\n\r\n")
@@ -118,12 +219,11 @@ fn request_protocol_version_header(
+ 4;
let headers = std::str::from_utf8(&raw_header[..header_end])
.map_err(|_| McpProtocolVersionError::InvalidHeader)?;
// Forwarding removes Connection-nominated fields. A revision field must
// survive that cleanup. Use the forwarding parser's canonical field names
// so authorization and removal agree, including after middleware rebuilds.
// Forwarding removes Connection-nominated fields. Required MCP metadata
// must survive that cleanup, including after middleware rebuilds.
let nominated = crate::l7::rest::connection_nominated_header_names(&raw_header[..header_end])
.map_err(|_| McpProtocolVersionError::InvalidHeader)?;
if nominated.contains(MCP_PROTOCOL_VERSION_HEADER) {
if nominated.contains(header_name) {
return Err(McpProtocolVersionError::InvalidHeader);
}
let mut values = headers.split("\r\n").skip(1).filter_map(|line| {
@@ -131,7 +231,7 @@ fn request_protocol_version_header(
// HTTP field-value optional whitespace is only SP or HTAB. Using
// Unicode whitespace trimming here would accept bytes that are part
// of the protocol-version value rather than HTTP framing.
name.eq_ignore_ascii_case(MCP_PROTOCOL_VERSION_HEADER)
name.eq_ignore_ascii_case(header_name)
.then_some(value.trim_matches([' ', '\t']))
});
let Some(value) = values.next() else {
@@ -146,8 +246,9 @@ fn request_protocol_version_header(
#[cfg(test)]
mod tests {
use super::*;
use crate::l7::jsonrpc::{JsonRpcInspectionMode, parse_jsonrpc_body};
use crate::l7::jsonrpc::{JsonRpcInspectionMode, McpHttpRequestMetadata, parse_jsonrpc_body};
use crate::l7::provider::BodyLength;
use std::fmt::Write as _;
fn request(method: &str, headers: &str) -> L7Request {
L7Request {
@@ -164,6 +265,21 @@ mod tests {
parse_jsonrpc_body(body, JsonRpcInspectionMode::Mcp)
}
fn modern_request_info(method: &str, name: Option<&str>) -> JsonRpcRequestInfo {
JsonRpcRequestInfo {
calls: Vec::new(),
is_batch: false,
receive_stream: false,
has_response: false,
mcp_revision: Some(McpProtocolVersion::V2026_07_28),
mcp_http_metadata: Some(McpHttpRequestMetadata {
method: method.to_string(),
name: name.map(str::to_string),
}),
error: None,
}
}
#[test]
fn standalone_initialize_uses_body_negotiation_only() {
let info = request_info(
@@ -283,7 +399,7 @@ mod tests {
let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
for value in [
"2026-07-28",
"2099-01-01",
"2025-11-25, 2025-03-26",
"2025-11-25x",
"\u{00a0}2025-11-25",
@@ -315,4 +431,189 @@ mod tests {
);
assert_eq!(error.http_status(), "403 Forbidden");
}
#[test]
fn sessionless_revision_requires_explicit_policy_opt_in() {
let info = modern_request_info("server/discover", None);
let request = request("POST", "MCP-Protocol-Version: 2026-07-28\r\n");
assert_eq!(
select_request_protocol_version(&request, &info, &[McpProtocolVersion::V2025_11_25],),
Err(McpProtocolVersionError::NotAllowed(
McpProtocolVersion::V2026_07_28
))
);
assert_eq!(
select_request_protocol_version(&request, &info, &[McpProtocolVersion::V2026_07_28],),
Ok(McpRequestProtocolVersion::Selected(
McpProtocolVersion::V2026_07_28
))
);
}
#[test]
fn initialize_cannot_exempt_sessionless_requests_from_revision_selection() {
let info = request_info(
br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#,
);
assert_eq!(
select_request_protocol_version(
&request("POST", "MCP-Protocol-Version: 2026-07-28\r\n"),
&info,
&[McpProtocolVersion::V2026_07_28],
),
Ok(McpRequestProtocolVersion::Selected(
McpProtocolVersion::V2026_07_28
))
);
assert_eq!(
select_request_protocol_version(
&request("POST", ""),
&info,
&[McpProtocolVersion::V2026_07_28],
),
Err(McpProtocolVersionError::NotAllowed(
McpProtocolVersion::V2025_03_26
))
);
assert_eq!(
select_request_protocol_version(
&request("POST", "MCP-Protocol-Version: \r\n"),
&info,
&[McpProtocolVersion::V2025_11_25],
),
Err(McpProtocolVersionError::InvalidHeader)
);
}
#[test]
fn sessionless_revision_accepts_only_post() {
let info = modern_request_info("subscriptions/listen", None);
for method in ["GET", "DELETE", "PUT", "post"] {
let error = select_request_protocol_version(
&request(method, "MCP-Protocol-Version: 2026-07-28\r\n"),
&info,
&[McpProtocolVersion::V2026_07_28],
)
.expect_err("sessionless messages require POST");
assert_eq!(error, McpProtocolVersionError::MethodNotAllowed);
assert_eq!(error.http_status(), "405 Method Not Allowed");
}
}
#[test]
fn sessionless_standard_headers_match_each_request_shape() {
for (method, name) in [
("server/discover", None),
("subscriptions/listen", None),
("example/extension", None),
("tools/call", Some("get_weather")),
("prompts/get", Some("summarize")),
("resources/read", Some("file:///documents/readme.txt")),
] {
let mut headers =
format!("mCp-PrOtOcOl-VeRsIoN:\t2026-07-28 \r\nMcP-MeThOd: {method}\t\r\n");
if let Some(name) = name {
write!(headers, "mCp-NaMe: {name}\r\n").unwrap();
}
assert_eq!(
validate_request_metadata(
&request("POST", &headers),
&modern_request_info(method, name),
),
Ok(()),
"standard metadata for {method} must match its body fields"
);
}
}
#[test]
fn sessionless_metadata_requires_unambiguous_matching_headers() {
let info = modern_request_info("tools/call", Some("get_weather"));
let valid = "MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: tools/call\r\nMcp-Name: get_weather\r\n";
for headers in [
valid.replace("MCP-Protocol-Version: 2026-07-28\r\n", ""),
valid.replace("2026-07-28", "2025-11-25"),
valid.replace("Mcp-Method: tools/call\r\n", ""),
valid.replace("tools/call", "Tools/Call"),
valid.replace("Mcp-Name: get_weather\r\n", ""),
valid.replace("get_weather", ""),
valid.replace("get_weather", "Get_Weather"),
format!("{valid}mcp-protocol-version: 2026-07-28\r\n"),
format!("{valid}mcp-method: tools/call\r\n"),
format!("{valid}mcp-name: get_weather\r\n"),
format!("{valid}Connection: keep-alive, MCP-Protocol-Version\r\n"),
format!("{valid}Connection: keep-alive, Mcp-Method\r\n"),
format!("{valid}Connection: keep-alive, Mcp-Name\r\n"),
] {
assert_eq!(
validate_request_metadata(&request("POST", &headers), &info),
Err(McpProtocolVersionError::InvalidRequestMetadata),
"invalid headers: {headers:?}"
);
}
}
#[test]
fn sessionless_names_decode_utf8_base64_before_matching() {
for name in ["天気", " padded ", "=?base64?literal?=", "line1\nline2"] {
let encoded = STANDARD.encode(name);
let headers = format!(
"MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: prompts/get\r\nMcp-Name: =?base64?{encoded}?=\r\n"
);
assert_eq!(
validate_request_metadata(
&request("POST", &headers),
&modern_request_info("prompts/get", Some(name)),
),
Ok(())
);
}
}
#[test]
fn sessionless_names_reject_invalid_encoding_and_unsafe_plain_values() {
for (name, value) in [
("天気", "天気"),
(" padded ", " padded "),
("=?base64?literal?=", "=?base64?literal?="),
("weather", "=?base64?%%%?="),
("weather", "=?base64?/w==?="),
("weather", "=?BASE64?d2VhdGhlcg==?="),
("bad\u{7f}name", "bad\u{7f}name"),
] {
let headers = format!(
"MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: prompts/get\r\nMcp-Name: {value}\r\n"
);
assert_eq!(
validate_request_metadata(
&request("POST", &headers),
&modern_request_info("prompts/get", Some(name)),
),
Err(McpProtocolVersionError::InvalidRequestMetadata)
);
}
}
#[test]
fn sessionless_mirrors_leave_unknown_headers_and_removed_session_fields_alone() {
let info = modern_request_info("tools/call", Some("get_weather"));
let headers = "MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: tools/call\r\nMcp-Name: get_weather\r\nMcp-Param-Region: west\r\nMcp-Session-Id: unused\r\nLast-Event-ID: unused\r\n";
assert_eq!(
validate_request_metadata(&request("POST", headers), &info),
Ok(())
);
}
#[test]
fn mirrored_headers_are_not_required_for_legacy_or_extension_notifications() {
let request = request("POST", "");
let mut info = modern_request_info("example/notification", None);
info.mcp_http_metadata = None;
assert_eq!(validate_request_metadata(&request, &info), Ok(()));
let mut info = modern_request_info("tools/call", Some("weather"));
info.mcp_revision = Some(McpProtocolVersion::V2025_11_25);
assert_eq!(validate_request_metadata(&request, &info), Ok(()));
}
}
File diff suppressed because it is too large Load Diff
@@ -747,6 +747,7 @@ where
RelayRequestOptions {
resolver,
body_classifier: None,
mcp_request_validation: None,
credential_generation: None,
generation_guard,
websocket_extensions: WebSocketExtensionMode::Preserve,
@@ -773,6 +774,8 @@ pub(crate) enum WebSocketExtensionMode {
pub(crate) struct RelayRequestOptions<'a> {
pub(crate) resolver: Option<&'a SecretResolver>,
pub(crate) body_classifier: Option<&'a openshell_core::secrets::body::BodyCredentialClassifier>,
/// Revalidate buffered MCP requests after header transformations.
pub(crate) mcp_request_validation: Option<McpRequestValidation<'a>>,
pub(crate) credential_generation: Option<CredentialGenerationGuard<'a>>,
pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>,
pub(crate) websocket_extensions: WebSocketExtensionMode,
@@ -785,6 +788,14 @@ pub(crate) struct RelayRequestOptions<'a> {
pub(crate) port: u16,
}
/// Policy and logging context for checking the MCP request sent upstream.
#[derive(Clone, Copy)]
pub(crate) struct McpRequestValidation<'a> {
pub(crate) config: &'a crate::l7::L7EndpointConfig,
pub(crate) ctx: &'a crate::l7::relay::L7EvalContext,
pub(crate) redacted_target: &'a str,
}
#[derive(Clone, Copy)]
pub(crate) struct CredentialGenerationGuard<'a> {
state: &'a openshell_core::provider_credentials::ProviderCredentialState,
@@ -917,6 +928,33 @@ where
let rewrite_result =
rewrite_http_header_block(&header_bytes, options.resolver).map_err(miette::Report::new)?;
if let Some(validation) = options.mcp_request_validation {
// Header credential resolution and hop-by-hop cleanup must finish
// before checking MCP mirrors. MCP bodies are already fully buffered
// and are not eligible for credential body rewriting.
let mut raw_header = rewrite_result.rewritten.clone();
raw_header.extend_from_slice(&req.raw_header[header_end..]);
let outgoing = L7Request {
action: req.action.clone(),
target: req.target.clone(),
query_params: req.query_params.clone(),
raw_header,
body_length: req.body_length,
};
if !crate::l7::relay::enforce_final_mcp_protocol_version(
validation.config,
&outgoing,
client,
validation.ctx,
validation.redacted_target,
observer,
)
.await?
{
return Ok(RelayOutcome::Consumed);
}
}
if let Some(guard) = options.generation_guard {
guard.ensure_current()?;
}
@@ -2832,13 +2870,27 @@ pub(crate) async fn send_json_response<C: AsyncWrite + Unpin>(
body: serde_json::Value,
client: &mut C,
status: &str,
) -> Result<()> {
send_json_response_with_allow(policy_name, body, client, status, None).await
}
/// Send a JSON response, including the required `Allow` field for an HTTP 405.
/// The allowed methods are supplied by the protocol adapter, never the peer.
pub(crate) async fn send_json_response_with_allow<C: AsyncWrite + Unpin>(
policy_name: &str,
body: serde_json::Value,
client: &mut C,
status: &str,
allowed_methods: Option<&'static str>,
) -> Result<()> {
let body_bytes = body.to_string();
let allow = allowed_methods.map_or_else(String::new, |methods| format!("Allow: {methods}\r\n"));
let response = format!(
"HTTP/1.1 {status}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
X-OpenShell-Policy: {}\r\n\
{allow}\
Connection: close\r\n\
\r\n\
{}",
@@ -6075,6 +6127,95 @@ mod tests {
(observer, receiver)
}
#[tokio::test]
async fn endpoint_observation_records_mcp_denial_after_header_cleanup() {
for disconnect_client in [false, true] {
let (observer, mut receiver) = test_endpoint_observer().await;
let config = crate::l7::parse_l7_config(
&regorus::Value::from_json_str(
r#"{"protocol":"mcp","mcp_versions":["2025-11-25"]}"#,
)
.expect("parse MCP config JSON"),
)
.expect("parse MCP config");
let ctx = crate::l7::relay::L7EvalContext {
host: "mcp.example.test".into(),
port: 8000,
policy_name: "mcp-policy".into(),
..Default::default()
};
let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
// The incoming version is allowed, but Connection nominates it for
// removal. The final gate must observe the outgoing request's denial.
let raw_request = format!(
"POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2025-11-25\r\nConnection: close, MCP-Protocol-Version\r\nContent-Length: {}\r\n\r\n{body}",
body.len(),
);
let request =
request_from_buffered_http("POST", "/mcp", "/mcp", raw_request.into_bytes())
.expect("parse buffered MCP request");
let (mut client, peer) = tokio::io::duplex(4096);
let mut peer = Some(peer);
if disconnect_client {
drop(peer.take());
}
let (mut upstream, mut upstream_peer) = tokio::io::duplex(4096);
let result = tokio::time::timeout(
std::time::Duration::from_secs(1),
relay_http_request_with_response_middleware_guarded_observed(
&request,
&mut client,
&mut upstream,
RelayRequestOptions {
mcp_request_validation: Some(McpRequestValidation {
config: &config,
ctx: &ctx,
redacted_target: "/mcp",
}),
..Default::default()
},
None,
Some(&observer),
),
)
.await
.expect("reject before upstream I/O");
if disconnect_client {
assert!(
result.is_err(),
"denial delivery must fail after disconnect"
);
} else {
assert!(matches!(
result.expect("deliver denial"),
RelayOutcome::Consumed
));
}
assert!(matches!(
receiver.try_recv().expect("final MCP denial observation"),
EndpointStatusCommand::Observe {
result: EndpointResult::PolicyDenied,
..
}
));
assert!(receiver.try_recv().is_err(), "one result per exchange");
drop(client);
drop(upstream);
let mut sent = Vec::new();
upstream_peer.read_to_end(&mut sent).await.unwrap();
assert!(sent.is_empty(), "rejected request reached upstream");
if let Some(mut peer) = peer {
let mut response = String::new();
peer.read_to_string(&mut response).await.unwrap();
assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}");
assert!(
response.contains("mcp_protocol_version_not_allowed"),
"{response}"
);
}
}
}
async fn assert_observed_response_result(response: &'static [u8], expected: EndpointResult) {
let (observer, mut receiver) = test_endpoint_observer().await;
let (mut upstream, mut upstream_peer) = tokio::io::duplex(4096);
+86 -1
View File
@@ -4818,7 +4818,8 @@ process:
"path": path,
"query_params": {},
"jsonrpc": {
"method": method
"method": method,
"mcp_method_classification": "available"
}
}
})
@@ -6309,6 +6310,90 @@ 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");
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"
);
}
#[test]
fn l7_mcp_extension_requires_an_exact_method_literal() {
let data = r#"
network_policies:
exact_extension:
name: exact_extension
endpoints:
- host: mcp.extension-exact.test
port: 8000
path: /mcp
protocol: mcp
enforcement: enforce
rules:
- allow:
method: tools/vendor
binaries:
- { path: /usr/bin/curl }
wildcard_extension:
name: wildcard_extension
endpoints:
- host: mcp.extension-wildcard.test
port: 8000
path: /mcp
protocol: mcp
enforcement: enforce
rules:
- allow:
method: tools/*
binaries:
- { path: /usr/bin/curl }
denied_extension:
name: denied_extension
endpoints:
- host: mcp.extension-denied.test
port: 8000
path: /mcp
protocol: mcp
enforcement: enforce
rules:
- allow:
method: tools/vendor
deny_rules:
- method: tools/*
binaries:
- { path: /usr/bin/curl }
"#;
let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml");
let mut exact = l7_jsonrpc_input("mcp.extension-exact.test", 8000, "/mcp", "tools/vendor");
exact["request"]["jsonrpc"]["mcp_method_classification"] = serde_json::json!("extension");
assert!(eval_l7(&engine, &exact));
exact["request"]["jsonrpc"]["mcp_method_classification"] = serde_json::json!("unavailable");
assert!(
!eval_l7(&engine, &exact),
"known methods unavailable in the selected profile must fail closed"
);
let mut wildcard =
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"
);
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"
);
}
#[test]
@@ -5115,6 +5115,7 @@ where
crate::l7::rest::RelayRequestOptions {
resolver: options.secret_resolver,
body_classifier: options.body_classifier,
mcp_request_validation: None,
credential_generation: options.credential_generation,
generation_guard: Some(options.generation_guard),
websocket_extensions: options.websocket_extensions,
@@ -5817,22 +5818,21 @@ async fn handle_forward_proxy(
crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&l7_config.config),
)
};
// Forward HTTP shares the MCP transport gate with CONNECT before
// method authorization. Borrow the buffered request so checking
// the version does not copy the inspected body.
if !crate::l7::relay::enforce_mcp_protocol_version(
// Policy evaluation must use the selected revision's inspection,
// including method classification, just as the CONNECT relays do.
let Some(info) = crate::l7::relay::enforce_mcp_protocol_version(
&l7_config.config,
&jsonrpc_request,
&info,
info,
client,
&l7_ctx,
&telemetry_path,
endpoint_observer.as_ref(),
)
.await?
{
else {
return Ok(());
}
};
forward_request_bytes = jsonrpc_request.raw_header;
Some(info)
} else {
@@ -8456,14 +8456,41 @@ network_policies: {}
return;
};
for (body, version_header) in [
// Every case uses the complete forwarding and middleware path. Sessionless
// requests carry their own metadata, and subscription responses retain SSE bytes.
// Share the proxy's identity cache across requests from this client binary
// so each profile does not hash the entire test executable again.
let identity_cache = Arc::new(BinaryIdentityCache::new());
for (body, mcp_headers, response_content_type, response_body) in [
(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#,
"",
"application/json",
r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"test","version":"1"}}}"#,
),
(
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
"MCP-Protocol-Version: 2025-11-25\r\n",
"application/json",
r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#,
),
(
r#"{"jsonrpc":"2.0","id":3,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#,
"MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: server/discover\r\n",
"application/json",
r#"{"jsonrpc":"2.0","id":3,"result":{"supportedVersions":["2026-07-28"],"capabilities":{},"ttlMs":0,"cacheScope":"private"}}"#,
),
(
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#,
"MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: tools/call\r\nMcp-Name: echo\r\n",
"application/json",
r#"{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"hello"}]}}"#,
),
(
r#"{"jsonrpc":"2.0","id":5,"method":"subscriptions/listen","params":{"notifications":{"toolsListChanged":true},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#,
"MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: subscriptions/listen\r\n",
"text/event-stream",
"event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n",
),
] {
let upstream_listener = TcpListener::bind((upstream_ip, 0))
@@ -8487,12 +8514,20 @@ network_policies:
port: {upstream_port}
path: /mcp
protocol: mcp
mcp_versions: ["2025-11-25", "2026-07-28"]
enforcement: enforce
rules:
- allow:
method: initialize
- allow:
method: tools/list
- allow:
method: server/discover
- allow:
method: tools/call
tool: echo
- allow:
method: subscriptions/listen
binaries:
- {{ path: "{executable}" }}
"#,
@@ -8530,12 +8565,11 @@ network_policies:
break;
}
}
socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
)
.await
.unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {response_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
response_body.len(),
);
socket.write_all(response.as_bytes()).await.unwrap();
String::from_utf8(request).expect("UTF-8 MCP request")
});
let proxy_listener = TcpListener::bind("127.0.0.1:0")
@@ -8544,7 +8578,7 @@ network_policies:
let proxy_address = proxy_listener.local_addr().unwrap();
let target = format!("http://{upstream_ip}:{upstream_port}/mcp");
let request = format!(
"POST {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nContent-Type: application/json\r\n{version_header}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
"POST {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\n{mcp_headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
let client = tokio::spawn(async move {
@@ -8572,7 +8606,7 @@ network_policies:
None,
socket_addrs,
engine,
Arc::new(BinaryIdentityCache::new()),
Arc::clone(&identity_cache),
Arc::new(AtomicU32::new(std::process::id())),
None,
AgentProposals::default(),
@@ -8593,16 +8627,33 @@ network_policies:
let response = client.await.expect("join MCP client");
assert!(response.starts_with(b"HTTP/1.1 200 OK"));
let response_header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|end| end + 4)
.expect("response has HTTP headers");
assert_eq!(
&response[response_header_end..],
response_body.as_bytes(),
"MCP response bytes must reach the client unchanged"
);
let response_headers = std::str::from_utf8(&response[..response_header_end])
.expect("UTF-8 response headers");
assert!(
response_headers.contains(&format!("Content-Type: {response_content_type}\r\n"))
);
let forwarded = upstream.await.expect("join MCP upstream");
assert!(forwarded.starts_with("POST /mcp HTTP/1.1\r\n"));
if version_header.is_empty() {
if mcp_headers.is_empty() {
assert!(
!forwarded
.to_ascii_lowercase()
.contains("mcp-protocol-version:")
);
} else {
assert!(forwarded.contains(version_header));
for header in mcp_headers.lines() {
assert!(forwarded.contains(header), "missing forwarded {header}");
}
}
}
}
@@ -9851,6 +9902,8 @@ network_policies:
is_batch: false,
receive_stream: false,
has_response: true,
mcp_revision: None,
mcp_http_metadata: None,
error: None,
}),
};
+38 -2
View File
@@ -531,13 +531,49 @@ Verify initialization and `read_status` before confirming that `delete_resource`
returns a policy denial. Tool argument matching is not supported, so an allowed
tool can receive any arguments accepted by the server.
Omitting `mcp.versions` allows only the `2025-11-25` revision. To support an
older server, list the exact revisions it needs, as described in [MCP Version
Omitting `mcp.versions` allows only the `2025-11-25` revision. To support a
server that uses another revision, list the exact revisions it needs, as
described in [MCP Version
Selection](/how-it-works/policies/schema#mcp-version-selection). Server
responses and SSE messages are relayed without MCP policy parsing. Do not put an
MCP endpoint on the same host and port as an endpoint that uses a different
protocol. `openshell policy update` rejects this combination.
A server that uses the sessionless `2026-07-28` revision has no initialization
step. Its clients send their protocol version and capabilities with every
request, and can call `server/discover` to learn what the server supports. This
rule allows discovery and `read_status` on such a server:
```yaml
mcp_server:
endpoints:
- host: mcp.example.com
port: 443
path: /mcp
protocol: mcp
enforcement: enforce
mcp:
versions: ["2026-07-28"]
rules:
- allow:
method: server/discover
- allow:
method: tools/list
- allow:
method: tools/call
tool: read_status
binaries:
- path: /usr/bin/python3.12
```
OpenShell rejects a `2026-07-28` request that lacks a required
`MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` header, or whose headers do
not match its body. Refer to [Sessionless MCP
Requests](/how-it-works/policies/schema#sessionless-mcp-requests) for the
complete request checks. To serve clients of both revisions on one endpoint,
list both revisions and combine the rules from the two examples. OpenShell
inspects each request under the revision that the request selects.
MCP and JSON-RPC endpoints carry only HTTP requests, because their rules apply
to each request. OpenShell answers a request to these endpoints that carries an
`Upgrade` header, such as a WebSocket upgrade, with `403 Forbidden` before it
+73 -11
View File
@@ -203,7 +203,7 @@ network_policies:
| `mcp.versions` | list of strings | `["2025-11-25"]` | Allowed MCP revisions. Refer to [MCP Version Selection](#mcp-version-selection). |
| `mcp.max_body_bytes` | integer | `65536` | Maximum MCP request body size for inspection. |
| `mcp.strict_tool_names` | bool | `true` | Requires tool names to match `^[A-Za-z0-9_.-]{1,128}$`. |
| `mcp.allow_all_known_mcp_methods` | bool | `false` | When `true`, the endpoint allows every MCP method except those that deny rules match. If rules name specific tools, `tools/call` is limited to those tools. Rules can omit `method`. Refer to [MCP Rules](#mcp-rules). |
| `mcp.allow_all_known_mcp_methods` | bool | `false` | When `true`, the endpoint allows every core MCP method available in the request's revision, except those that deny rules match. An extension method still needs an allow rule that names it exactly. If rules name specific tools, `tools/call` is limited to those tools. Rules can omit `method`. Refer to [MCP Rules](#mcp-rules). |
| `json_rpc.max_body_bytes` | integer | `65536` | Maximum JSON-RPC request body size for inspection. |
#### Endpoint Constraints
@@ -347,9 +347,12 @@ matcher fields in one rule.
- Tool arguments are not matched, so an allowed tool accepts any arguments.
- One denied call denies an entire batched request.
- Server responses and server-to-client messages are not inspected.
- Only an allow rule that names an extension method exactly can allow it.
Method globs and `mcp.allow_all_known_mcp_methods` do not. An extension
method is one that no supported MCP revision defines.
A client sends `initialize` and `notifications/initialized` before calling
tools, so allow both:
Under the 2025 revisions, a client sends `initialize` and
`notifications/initialized` before calling tools, so allow both:
```yaml showLineNumbers={false}
rules:
@@ -366,23 +369,81 @@ deny_rules:
tool: send_email
```
OpenShell uses the `tower-mcp-types` library to inspect each request under its
MCP revision. It checks the JSON-RPC structure, whether a client can send the
method in that revision, and the parameter types of known methods. It rejects a
body that repeats a key within any JSON object, and a method that MCP defines
but the request's revision does not, such as `server/discover` under
`2025-11-25`. A request that fails these checks returns `400` with the error
`invalid_mcp_request`, even on an endpoint that uses `enforcement: audit`. These
type checks are not complete JSON Schema validation.
#### MCP Version Selection
`mcp.versions` lists the MCP revisions an endpoint accepts: `2025-03-26`,
`2025-06-18`, or `2025-11-25`. When omitted, only `2025-11-25` is allowed.
`2025-06-18`, `2025-11-25`, or `2026-07-28`. When omitted, the list is exactly
`["2025-11-25"]`, so the endpoint does not accept `2026-07-28` until you list
it.
```yaml showLineNumbers={false}
mcp:
versions: ["2025-03-26", "2025-11-25"]
```
OpenShell does not check the revision of a single `initialize` request, because
the client negotiates the revision in that request. For other requests,
OpenShell reads the revision from the `MCP-Protocol-Version` header, or uses
`2025-03-26` when the header is absent. A duplicate, empty, or
unsupported header value returns `400`, and a supported revision that the
endpoint does not allow returns `403`. For a client that requires an unsupported
revision, omit `protocol` and `mcp` to allow its traffic without MCP inspection.
OpenShell selects each request's revision from its `MCP-Protocol-Version`
header. When the header is absent, OpenShell selects `2025-03-26`, so an
endpoint whose clients omit the header must allow `2025-03-26`. A duplicate,
empty, or unsupported header value returns `400`, and a supported revision that
the endpoint does not allow returns `403`.
OpenShell inspects each request under its selected revision only. For example,
only `2025-03-26` accepts JSON-RPC batches, with at most 64 messages each, and
later revisions reject a request body that is a JSON array.
Under the 2025 revisions, a client starts with a single `initialize` request
that proposes a revision in its body, so OpenShell does not check that request's
revision against `mcp.versions`. An endpoint that allows only `2026-07-28`
rejects `initialize`.
For a client that requires an unsupported revision, omit `protocol` and `mcp` to
allow its traffic without MCP inspection.
#### Sessionless MCP Requests
The `2026-07-28` revision has no `initialize` handshake or session. Each request
carries the client's protocol version and capabilities. A client can call
`server/discover` to learn what the server supports, and can call
`subscriptions/listen` to receive notifications on that request's response
stream. Each HTTP `POST` carries one JSON-RPC request or extension notification.
OpenShell checks each `2026-07-28` request for the following:
- `params._meta` contains `io.modelcontextprotocol/protocolVersion` set to
`2026-07-28`, and `io.modelcontextprotocol/clientCapabilities`. Extension
requests need this metadata too.
- The `MCP-Protocol-Version` header is `2026-07-28`. Without it, OpenShell
selects `2025-03-26` and rejects the request.
- The `Mcp-Method` header equals the JSON-RPC `method`.
- For `tools/call` and `prompts/get`, the `Mcp-Name` header equals
`params.name`. For `resources/read`, it equals `params.uri`. OpenShell decodes
a `=?base64?...?=` value before comparing it.
A request with missing or invalid metadata, or with a missing, duplicate, or
mismatched `Mcp-Method` or `Mcp-Name` header, returns `400`. OpenShell also
rejects `initialize`, `notifications/initialized`, `notifications/cancelled`,
JSON-RPC responses from the client, and batches. A request that uses an HTTP
method other than `POST` returns `405`.
OpenShell repeats these checks after middleware changes a request. A middleware
that replaces the body of a `2026-07-28` request must also update `Mcp-Method`
and `Mcp-Name` to match the new body.
An extension notification needs `MCP-Protocol-Version: 2026-07-28` and an allow
rule that names its `method` exactly, such as `method: vendor/notice`. It does
not need `params._meta`, `Mcp-Method`, or `Mcp-Name`.
OpenShell forwards `Mcp-Param-*` headers without comparing them with tool
arguments.
### JSON-RPC Rules
@@ -391,6 +452,7 @@ revision, omit `protocol` and `mcp` to allow its traffic without MCP inspection.
| `method` | string | Yes | Exact method name, or `*` for all methods. Other globs are rejected. |
Parameters are not matched. One denied call denies an entire batched request.
OpenShell rejects a body that repeats a key within any JSON object.
```yaml showLineNumbers={false}
rules:
+1 -1
View File
@@ -618,7 +618,7 @@ Read `last_result` to choose the next check:
- `NoObservedExchange`: no result has been reported for the current configuration and supervisor session. Try the operation and inspect its logs if no result appears.
- `HttpResponseReceived`: the server returned a final HTTP status below 400, including a protocol upgrade. Informational responses alone do not establish success. Check the client's response for protocol or tool errors; an HTTP 200 response can still contain an error.
- `PolicyDenied`: OpenShell denied the request, including MCP protocol-version or request-body policy checks. Check the sandbox policy and denial logs.
- `PolicyDenied`: OpenShell denied the request, including MCP protocol-version, request-header, request-body, and HTTP-method checks. Check the sandbox policy and denial logs.
- `CredentialUnavailable`: required credentials were unavailable. Check the endpoint's attached provider.
- `TlsFailed`: TLS setup or the handshake failed. Check certificates and TLS configuration.
- `TransportFailed`: the network exchange failed. Check name resolution, connectivity, and the server process.
+7 -11
View File
@@ -35,7 +35,11 @@ bridge at `host.openshell.internal` (the alias `e2e/with-docker-gateway.sh`
attaches to the CI job container on the e2e network), at `host.docker.internal`
on local Docker Desktop, or via `--add-host ...:host-gateway` on local Linux.
The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the endpoint MCP method profile. OpenShell enforces that allowlist on each non-initialize request using `MCP-Protocol-Version`, with `2025-03-26` as the missing-header fallback. The conformance runner selects the revision used by its client and server; OpenShell's request-version check does not yet provide complete revision-specific message parsing or response validation. The policy keeps OpenShell deny-by-default at the network boundary while allowing the upstream scenarios to exercise MCP behavior. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL.
The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the selected MCP method profile. The renderer accepts OpenShell's supported revisions, `2025-03-26`, `2025-06-18`, `2025-11-25`, and `2026-07-28`. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL.
OpenShell checks each request against the policy revision and delegates JSON-RPC structure and MCP method, direction, message-kind, parameter, and metadata type checks to `tower-mcp-types`, pinned to `0.22.2`. These checks follow Tower's deserialization and inspection APIs and do not establish complete JSON-schema conformance. OpenShell owns revision allowlisting, HTTP/body consistency, request limits, and policy enforcement. For the 2025 revisions, a valid standalone `initialize` proposes a version; later requests select their revision through `MCP-Protocol-Version`, with `2025-03-26` as the missing-header fallback. The sessionless `2026-07-28` profile carries one JSON-RPC request or explicitly allowed extension notification per POST. Requests require per-request metadata and matching protocol-version, method, and applicable name headers. Extension notifications require an exact method allow rule and the version header, but no request metadata or method/name mirrors. Responses and SSE payloads are relayed without policy parsing. The conformance runner and its reference client exercise behavior beyond these request inspection checks.
`OPENSHELL_MCP_CONFORMANCE_SPEC_VERSION` defaults to `2025-11-25`. The default scenarios in `e2e/mcp-conformance.sh` are `initialize`, `tools_call`, and `elicitation-sep1034-client-defaults`, selected for the pinned upstream fixture and this default revision. A passing default run does not establish `2026-07-28` conformance coverage. To exercise that revision through this harness, select an upstream fixture and scenario handlers that implement its sessionless request contract, then set the spec version and scenario list together.
For local runs, the wrapper builds `openshell/supervisor:dev` automatically
when no supervisor image override is set. Set `SUPERVISOR_IMAGE` to use a
@@ -65,14 +69,6 @@ docker run --rm openshell-mcp-conformance-client:local \
./node_modules/.bin/tsx src/index.ts list --client --spec-version 2025-11-25
```
Then confirm each scenario has a compatible handler in the pinned
`examples/clients/typescript/everything-client.ts`. The default list skips
opt-in scenarios, including auth/OAuth flows and the slow `sse-retry` scenario.
Set `OPENSHELL_MCP_CONFORMANCE_SCENARIOS=sse-retry` or pass `sse-retry` as an
argument to run it explicitly.
Then confirm each scenario has a compatible handler in the pinned `examples/clients/typescript/everything-client.ts`. The default list skips opt-in scenarios, including auth/OAuth flows and the slow `sse-retry` scenario. Set `OPENSHELL_MCP_CONFORMANCE_SCENARIOS` to `sse-retry` or pass `sse-retry` as an argument to run it explicitly.
The wrapper caches the pinned upstream checkout, the local conformance runner
build, and the Docker client image. Set
`OPENSHELL_MCP_CONFORMANCE_FORCE_REBUILD=1` to refresh those build artifacts, or
`OPENSHELL_MCP_CONFORMANCE_DOCKER_PULL=1` to pull the client image base during a
rebuild.
The wrapper caches the pinned upstream checkout, the local conformance runner build, and the Docker client image. Set `OPENSHELL_MCP_CONFORMANCE_FORCE_REBUILD` to `1` to refresh those build artifacts, or `OPENSHELL_MCP_CONFORMANCE_DOCKER_PULL` to `1` to pull the client image base during a rebuild.
+1 -1
View File
@@ -30,7 +30,7 @@ if len(sys.argv) != 5:
raw_url, policy_file, policy_template, mcp_version = sys.argv[1:5]
# Keep this boundary check synchronized with McpProtocolVersion::ALL. The
# renderer is standalone Python, so it cannot import the Rust registry.
supported_mcp_versions = {"2025-03-26", "2025-06-18", "2025-11-25"}
supported_mcp_versions = {"2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"}
if mcp_version not in supported_mcp_versions:
raise SystemExit(f"unsupported MCP protocol version: {mcp_version!r}")
parsed = urlparse(raw_url)
+381
View File
@@ -0,0 +1,381 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! MCP request profiles through the sandbox's transparent network interception.
//!
//! A shared fixture serves legacy initialization, sessionless discovery, named
//! tools, and a bounded subscription stream. Upstream receipts distinguish a
//! policy denial from a tool request accepted by the fixture.
#![cfg(feature = "e2e-host-gateway")]
use std::io::Write;
use openshell_e2e::harness::container::ContainerHttpServer;
use openshell_e2e::harness::sandbox::SandboxGuard;
use tempfile::NamedTempFile;
const SERVER_ALIAS: &str = "mcp-sessionless.openshell.test";
const SERVER_SCRIPT: &str = r#"
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
SESSIONLESS_VERSION = "2026-07-28"
received = []
class Handler(BaseHTTPRequestHandler):
def reply(self, status, payload, content_type="application/json"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
# The container fixture's readiness probe uses the root URL.
self.reply(200 if self.path == "/" else 405, b"")
def read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() != "chunked":
return self.rfile.read(int(self.headers.get("Content-Length", "0")))
body = bytearray()
while True:
size = int(self.rfile.readline().split(b";", 1)[0].strip(), 16)
if size == 0:
while self.rfile.readline().strip():
pass
return bytes(body)
body.extend(self.rfile.read(size))
self.rfile.read(2)
def do_POST(self):
message = json.loads(self.read_body())
method = message["method"]
params = message.get("params", {})
version = (params.get("protocolVersion") if method == "initialize"
else self.headers.get("MCP-Protocol-Version"))
# Record parsed requests before fixture admission so rejected revisions
# and tool attempts stay visible. HTTPServer handles requests serially.
received.append([version, method, params.get("name")])
if self.path != "/mcp" or version not in SUPPORTED_VERSIONS:
self.reply(400, b"request revision did not reach the fixture intact")
return
if version == SESSIONLESS_VERSION:
meta = params.get("_meta", {})
if (self.headers.get("MCP-Protocol-Version") != version
or self.headers.get("Mcp-Method") != method
or meta.get("io.modelcontextprotocol/protocolVersion") != version
or meta.get("io.modelcontextprotocol/clientCapabilities") != {}
or (method == "tools/call"
and self.headers.get("Mcp-Name") != params["name"])):
self.reply(400, b"request metadata did not reach the fixture intact")
return
if method == "initialize" and version != SESSIONLESS_VERSION:
result = {
"protocolVersion": version,
"capabilities": {"tools": {}},
"serverInfo": {"name": "openshell-profile-fixture", "version": "1"},
}
elif method == "notifications/initialized" and version != SESSIONLESS_VERSION:
self.reply(202, b"")
return
elif method == "server/discover" and version == SESSIONLESS_VERSION:
result = {
"supportedVersions": [version],
"capabilities": {"tools": {"listChanged": True}},
"ttlMs": 0,
"cacheScope": "private",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "openshell-sessionless-fixture", "version": "1"
}
},
}
elif method == "tools/call":
# Both tool names work upstream; OpenShell owns the policy denial.
result = {
"content": [{"type": "text", "text": params["name"]}],
"isError": False,
"_meta": {"fixtureRequests": list(received)},
}
if version == SESSIONLESS_VERSION:
result["resultType"] = "complete"
elif method == "subscriptions/listen" and version == SESSIONLESS_VERSION:
subscription_meta = {"io.modelcontextprotocol/subscriptionId": message["id"]}
events = [
{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": params["notifications"], "_meta": subscription_meta},
},
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
"params": {"_meta": subscription_meta},
},
{
"jsonrpc": "2.0",
"id": message["id"],
"result": {"resultType": "complete", "_meta": subscription_meta},
},
]
body = "".join("event: message\ndata: " + json.dumps(event) + "\n\n" for event in events)
self.reply(200, body.encode(), "text/event-stream")
return
else:
self.reply(400, b"unexpected method for the selected fixture revision")
return
self.reply(200, json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}).encode())
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
"#;
const CLIENT_HELPERS: &str = r#"
import json
import urllib.error
import urllib.request
# Direct client connections pass through the sandbox's transparent interception.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def post(request_id, method, params, version):
params = dict(params)
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if method != "initialize":
headers["MCP-Protocol-Version"] = version
if version == "2026-07-28":
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "openshell-e2e", "version": "1"},
}
headers["Mcp-Method"] = method
if method == "tools/call":
headers["Mcp-Name"] = params["name"]
message = {"jsonrpc": "2.0", "method": method, "params": params}
if request_id is not None:
message["id"] = request_id
request = urllib.request.Request(
f"http://{HOST}:{PORT}/mcp",
data=json.dumps(message).encode(),
headers=headers,
method="POST",
)
try:
with opener.open(request, timeout=15) as response:
return response.status, response.headers.get_content_type(), response.read()
except urllib.error.HTTPError as error:
return error.code, error.headers.get_content_type(), error.read()
"#;
const CLIENT_SCRIPT: &str = r#"
VERSION = "2026-07-28"
status, content_type, body = post(1, "server/discover", {}, VERSION)
assert status == 200, ("discovery", status, body)
assert content_type == "application/json", content_type
discovery = json.loads(body)
assert discovery["id"] == 1, discovery
assert discovery["result"]["supportedVersions"] == [VERSION], discovery
status, _, body = post(2, "tools/call", {"name": "read_status", "arguments": {}}, VERSION)
assert status == 200, ("allowed tool", status, body)
tool = json.loads(body)
assert tool["id"] == 2, tool
assert tool["result"]["content"] == [{"type": "text", "text": "read_status"}], tool
status, _, body = post(3, "tools/call", {"name": "read_details", "arguments": {}}, VERSION)
assert status == 403, ("denied tool", status, body)
status, content_type, body = post(4, "subscriptions/listen", {"notifications": {"toolsListChanged": True}}, VERSION)
assert status == 200, ("subscription", status, body)
assert content_type == "text/event-stream", (content_type, body)
events = [json.loads(line[6:]) for line in body.decode().splitlines() if line.startswith("data: ")]
assert len(events) == 3, events
assert events[0]["method"] == "notifications/subscriptions/acknowledged", events
assert events[0]["params"]["notifications"] == {"toolsListChanged": True}, events
assert events[1]["method"] == "notifications/tools/list_changed", events
assert events[1]["params"]["_meta"]["io.modelcontextprotocol/subscriptionId"] == 4, events
assert events[2]["id"] == 4 and events[2]["result"]["resultType"] == "complete", events
print("MCP_SESSIONLESS_OK discovery=200 allowed_tool=200 denied_tool=403 subscription=200")
"#;
const PROFILE_CLIENT_SCRIPT: &str = r#"
expected_receipts = []
for version in SELECTED_VERSIONS:
if version == "2026-07-28":
status, _, body = post(1, "server/discover", {}, version)
assert status == 200, (version, "discovery", status, body)
assert json.loads(body)["result"]["supportedVersions"] == [version], body
expected_receipts.append([version, "server/discover", None])
else:
status, _, body = post(1, "initialize", {
"protocolVersion": version,
"capabilities": {},
"clientInfo": {"name": "openshell-e2e", "version": "1"},
}, version)
assert status == 200, (version, "initialize", status, body)
assert json.loads(body)["result"]["protocolVersion"] == version, body
expected_receipts.append([version, "initialize", None])
status, _, body = post(None, "notifications/initialized", {}, version)
assert status == 202, (version, "initialized", status, body)
expected_receipts.append([version, "notifications/initialized", None])
status, _, body = post(2, "tools/call", {"name": "read_status", "arguments": {}}, version)
assert status == 200, (version, "allowed tool", status, body)
tool = json.loads(body)
assert tool["id"] == 2, tool
assert tool["result"]["content"] == [{"type": "text", "text": "read_status"}], tool
expected_receipts.append([version, "tools/call", "read_status"])
assert tool["result"]["_meta"]["fixtureRequests"] == expected_receipts, tool
status, _, body = post(3, "tools/call", {"name": "read_details", "arguments": {}}, version)
assert status == 403, (version, "denied tool", status, body)
# The fixture accepts both tools. A later allowed call proves the denial
# came from the proxy and no denied operation reached the upstream.
status, _, body = post(4, "tools/call", {"name": "read_status", "arguments": {}}, version)
assert status == 200, (version, "receipt tool", status, body)
receipt = json.loads(body)
assert receipt["id"] == 4, receipt
expected_receipts.append([version, "tools/call", "read_status"])
assert receipt["result"]["_meta"]["fixtureRequests"] == expected_receipts, receipt
print(f"MCP_PROFILE_OK version={version} allowed_tool=200 denied_tool=403 receipts=verified")
"#;
async fn start_server(alias: &str, versions: &[&str]) -> Result<ContainerHttpServer, String> {
let versions = serde_json::to_string(versions).map_err(|err| err.to_string())?;
let script = format!("SUPPORTED_VERSIONS = {versions}\n{SERVER_SCRIPT}");
ContainerHttpServer::start_python(alias, &script).await
}
fn write_policy(host: &str, port: u16, versions: &[&str]) -> Result<NamedTempFile, String> {
let mut file = NamedTempFile::new().map_err(|err| format!("create temp policy: {err}"))?;
let legacy_rules = if versions.iter().any(|version| *version != "2026-07-28") {
" - allow:\n method: initialize\n - allow:\n method: notifications/initialized\n"
} else {
""
};
let versions = serde_json::to_string(versions).map_err(|err| err.to_string())?;
let policy = format!(
r#"version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
read_write: [/sandbox, /tmp, /dev/null]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_policies:
mcp_sessionless:
name: mcp_sessionless
endpoints:
- host: {host}
port: {port}
path: /mcp
protocol: mcp
enforcement: enforce
allowed_ips: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]
mcp:
versions: {versions}
max_body_bytes: 65536
rules:
{legacy_rules} - allow:
method: server/discover
- allow:
method: tools/call
tool: read_status
- allow:
method: subscriptions/listen
deny_rules:
- method: tools/call
tool: read_details
binaries:
- path: /usr/bin/python*
- path: /usr/local/bin/python*
- path: /sandbox/.uv/python/*/bin/python*
"#
);
file.write_all(policy.as_bytes())
.map_err(|err| format!("write temp policy: {err}"))?;
file.flush()
.map_err(|err| format!("flush temp policy: {err}"))?;
Ok(file)
}
async fn run_client(
server: &ContainerHttpServer,
versions: &[&str],
client: &str,
) -> Result<SandboxGuard, String> {
let policy = write_policy(&server.host, server.port, versions)?;
let policy_path = policy
.path()
.to_str()
.ok_or("temp policy path is not UTF-8")?;
let selected_versions = serde_json::to_string(versions).map_err(|err| err.to_string())?;
let script = format!(
"HOST = {:?}\nPORT = {}\nSELECTED_VERSIONS = {selected_versions}\n{CLIENT_HELPERS}\n{client}",
server.host, server.port
);
SandboxGuard::create(&["--policy", policy_path, "--", "python3", "-c", &script]).await
}
#[tokio::test]
async fn sessionless_discovery_tools_and_subscription_use_request_metadata() {
let versions = ["2026-07-28"];
let server = start_server(SERVER_ALIAS, &versions)
.await
.expect("start sessionless MCP fixture");
let sandbox = run_client(&server, &versions, CLIENT_SCRIPT)
.await
.expect("run sessionless MCP client in sandbox");
assert!(
sandbox.create_output.contains(
"MCP_SESSIONLESS_OK discovery=200 allowed_tool=200 denied_tool=403 subscription=200"
),
"expected completed sessionless MCP assertions, got:\n{}",
sandbox.create_output
);
}
#[tokio::test]
async fn legacy_and_multi_version_profiles_authorize_tools_through_sandbox() {
for versions in [
&["2025-03-26"][..],
&["2025-06-18"][..],
&["2025-11-25", "2026-07-28"][..],
] {
// Each scenario starts with fresh upstream receipts. Its distinct alias
// avoids the sessionless test's fixture, and cleanup precedes alias reuse.
let server = start_server("mcp-profiles.openshell.test", versions)
.await
.unwrap_or_else(|err| panic!("{versions:?}: start MCP fixture: {err}"));
let mut sandbox = run_client(&server, versions, PROFILE_CLIENT_SCRIPT)
.await
.unwrap_or_else(|err| panic!("{versions:?}: run MCP sandbox client: {err}"));
for version in versions {
let marker = format!(
"MCP_PROFILE_OK version={version} allowed_tool=200 denied_tool=403 receipts=verified"
);
assert!(
sandbox.create_output.contains(&marker),
"{versions:?}: expected completed {version} assertions, got:\n{}",
sandbox.create_output
);
}
sandbox.cleanup().await;
}
}
+1
View File
@@ -1142,6 +1142,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"thiserror",
]
[[package]]
+1
View File
@@ -1211,6 +1211,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"thiserror",
]
[[package]]