Skip to content

Commit 8c9606b

Browse files
committed
fix(mcp): permit streamable http control traffic
Signed-off-by: ddurst <267424412+ddurst-nvidia@users.noreply.github.com>
1 parent d43582b commit 8c9606b

4 files changed

Lines changed: 157 additions & 33 deletions

File tree

crates/openshell-supervisor-network/data/sandbox-policy.rego

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -435,21 +435,37 @@ request_allowed_for_endpoint(request, endpoint) if {
435435
jsonrpc_rule_matches(request, rule.allow)
436436
}
437437

438-
# MCP Streamable HTTP uses GET on the JSON-RPC endpoint as a receive stream for
439-
# server-to-client messages. The stream itself has no client-to-server JSON-RPC
440-
# request body to inspect; allow it once the endpoint path and binary matched.
441-
request_allowed_for_endpoint(request, endpoint) if {
438+
jsonrpc_family_endpoint(endpoint) if {
442439
endpoint.protocol == "json-rpc"
440+
}
441+
442+
jsonrpc_family_endpoint(endpoint) if {
443+
endpoint.protocol == "mcp"
444+
}
445+
446+
# The following methodless allowances are a narrow MCP Streamable HTTP
447+
# conformance exception. Receive streams and client JSON-RPC responses do not
448+
# carry a method to match with mcp_method/rpc_method, so enforcement is scoped
449+
# to JSON-RPC-family endpoints after host, path, binary, and parse-shape checks.
450+
# MCP version 2026-07-28 removes the GET stream endpoint and client-sent
451+
# JSON-RPC responses, so these allowances should be version-gated or removed
452+
# once OpenShell enforces that transport version.
453+
# MCP Streamable HTTP uses GET on the JSON-RPC-family endpoint as a receive
454+
# stream for server-to-client messages. The stream itself has no
455+
# client-to-server JSON-RPC request body to inspect; allow it once the endpoint
456+
# path and binary matched.
457+
request_allowed_for_endpoint(request, endpoint) if {
458+
jsonrpc_family_endpoint(endpoint)
443459
request.method == "GET"
444460
is_object(request.jsonrpc)
445461
jsonrpc_no_parse_error(request.jsonrpc)
446462
}
447463

448464
# MCP clients send JSON-RPC responses (for example elicitation replies) back to
449465
# the server without a method. Allow response-only POSTs once endpoint path and
450-
# binary matching has already selected this JSON-RPC endpoint.
466+
# binary matching has already selected this JSON-RPC-family endpoint.
451467
request_allowed_for_endpoint(request, endpoint) if {
452-
endpoint.protocol == "json-rpc"
468+
jsonrpc_family_endpoint(endpoint)
453469
request.method == "POST"
454470
is_object(request.jsonrpc)
455471
jsonrpc_no_parse_error(request.jsonrpc)

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -271,31 +271,37 @@ where
271271
None
272272
};
273273
let jsonrpc_info = if config.protocol.is_jsonrpc_family() {
274-
match crate::l7::http::read_body_for_inspection(
275-
client,
276-
&mut req,
277-
config.json_rpc_max_body_bytes,
278-
)
279-
.await
280-
{
281-
Ok(body) => Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_mode(
282-
&body,
283-
jsonrpc_inspection_mode(config.protocol),
284-
)),
285-
Err(e) => {
286-
if is_benign_connection_error(&e) {
287-
debug!(
288-
host = %ctx.host,
289-
port = ctx.port,
290-
error = %e,
291-
"JSON-RPC L7 connection closed"
292-
);
293-
} else {
294-
let detail =
295-
parse_rejection_detail(&e.to_string(), ParseRejectionMode::L7Endpoint);
296-
emit_parse_rejection(ctx, &detail, "l7-jsonrpc");
274+
if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&req) {
275+
Some(crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream())
276+
} else {
277+
match crate::l7::http::read_body_for_inspection(
278+
client,
279+
&mut req,
280+
config.json_rpc_max_body_bytes,
281+
)
282+
.await
283+
{
284+
Ok(body) => Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_mode(
285+
&body,
286+
jsonrpc_inspection_mode(config.protocol),
287+
)),
288+
Err(e) => {
289+
if is_benign_connection_error(&e) {
290+
debug!(
291+
host = %ctx.host,
292+
port = ctx.port,
293+
error = %e,
294+
"JSON-RPC L7 connection closed"
295+
);
296+
} else {
297+
let detail = parse_rejection_detail(
298+
&e.to_string(),
299+
ParseRejectionMode::L7Endpoint,
300+
);
301+
emit_parse_rejection(ctx, &detail, "l7-jsonrpc");
302+
}
303+
return Ok(());
297304
}
298-
return Ok(());
299305
}
300306
}
301307
} else {

crates/openshell-supervisor-network/src/opa.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2921,6 +2921,66 @@ network_policies:
29212921
assert!(!eval_l7(&engine, &deny_input));
29222922
}
29232923

2924+
#[test]
2925+
fn l7_mcp_receive_stream_get_is_allowed_for_matching_endpoint() {
2926+
let data = r#"
2927+
network_policies:
2928+
mcp_stream:
2929+
name: mcp_stream
2930+
endpoints:
2931+
- host: mcp.stream.test
2932+
port: 8000
2933+
path: /mcp
2934+
protocol: mcp
2935+
enforcement: enforce
2936+
rules:
2937+
- allow:
2938+
mcp_method: initialize
2939+
binaries:
2940+
- { path: /usr/bin/curl }
2941+
"#;
2942+
let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml");
2943+
let allow_input = serde_json::json!({
2944+
"network": { "host": "mcp.stream.test", "port": 8000 },
2945+
"exec": {
2946+
"path": "/usr/bin/curl",
2947+
"ancestors": [],
2948+
"cmdline_paths": []
2949+
},
2950+
"request": {
2951+
"method": "GET",
2952+
"path": "/mcp",
2953+
"query_params": {},
2954+
"jsonrpc": {
2955+
"method": null,
2956+
"params": {},
2957+
"error": null
2958+
}
2959+
}
2960+
});
2961+
assert!(eval_l7(&engine, &allow_input));
2962+
2963+
let deny_input = serde_json::json!({
2964+
"network": { "host": "mcp.stream.test", "port": 8000 },
2965+
"exec": {
2966+
"path": "/usr/bin/curl",
2967+
"ancestors": [],
2968+
"cmdline_paths": []
2969+
},
2970+
"request": {
2971+
"method": "GET",
2972+
"path": "/other",
2973+
"query_params": {},
2974+
"jsonrpc": {
2975+
"method": null,
2976+
"params": {},
2977+
"error": null
2978+
}
2979+
}
2980+
});
2981+
assert!(!eval_l7(&engine, &deny_input));
2982+
}
2983+
29242984
#[test]
29252985
fn l7_jsonrpc_response_post_is_allowed_for_matching_endpoint() {
29262986
let data = r#"
@@ -2947,6 +3007,32 @@ network_policies:
29473007
assert!(!eval_l7(&engine, &deny_input));
29483008
}
29493009

3010+
#[test]
3011+
fn l7_mcp_response_post_is_allowed_for_matching_endpoint() {
3012+
let data = r#"
3013+
network_policies:
3014+
mcp_response:
3015+
name: mcp_response
3016+
endpoints:
3017+
- host: mcp.response.test
3018+
port: 8000
3019+
path: /mcp
3020+
protocol: mcp
3021+
enforcement: enforce
3022+
rules:
3023+
- allow:
3024+
mcp_method: initialize
3025+
binaries:
3026+
- { path: /usr/bin/curl }
3027+
"#;
3028+
let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml");
3029+
let allow_input = l7_jsonrpc_response_input("mcp.response.test", 8000, "/mcp");
3030+
assert!(eval_l7(&engine, &allow_input));
3031+
3032+
let deny_input = l7_jsonrpc_response_input("mcp.response.test", 8000, "/other");
3033+
assert!(!eval_l7(&engine, &deny_input));
3034+
}
3035+
29503036
#[test]
29513037
fn l7_jsonrpc_params_rules_filter_tools_call() {
29523038
let data = r#"
Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
# Add client scenarios here when enabling broader MCP conformance suites that
5-
# exercise features OpenShell does not yet support through the MCP proxy.
6-
client: []
4+
# Add client scenarios here when enabling broader MCP conformance suites.
5+
#
6+
# elicitation-sep1034-client-defaults is allowed because the pinned upstream
7+
# TypeScript conformance client appears to advertise the wrong SDK capability
8+
# shape for default application: it sets elicitation.applyDefaults, while
9+
# @modelcontextprotocol/sdk 1.29.0 applies defaults only when
10+
# elicitation.form.applyDefaults is true. Reproduce without OpenShell in the
11+
# path from .cache/mcp-conformance after npm install/build:
12+
# Remove this entry when OPENSHELL_MCP_CONFORMANCE_REF is bumped to an upstream
13+
# conformance commit where the bundled client advertises
14+
# elicitation.form.applyDefaults.
15+
#
16+
# node dist/index.js client \
17+
# --command "env MCP_CONFORMANCE_SCENARIO=elicitation-defaults \
18+
# ./node_modules/.bin/tsx examples/clients/typescript/everything-client.ts" \
19+
# --scenario elicitation-sep1034-client-defaults \
20+
# --spec-version 2025-11-25
21+
client:
22+
- elicitation-sep1034-client-defaults
723
server: []

0 commit comments

Comments
 (0)