Files
OpenShell/e2e/rust/tests/mcp_sessionless.rs
Shiju 1358941b81 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>
2026-09-28 20:48:50 +00:00

382 lines
16 KiB
Rust

// 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;
}
}