Files
OpenShell/e2e/mcp-conformance/render-policy.py
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

88 lines
3.1 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Render the OpenShell client policy for a conformance server URL.
Parses the server URL into host/port/path, substitutes them into
policy-template.yaml, writes the rendered policy, and prints the (possibly
rewritten) URL the client should connect to. Used both to seed the reusable
client sandbox with a placeholder policy and to install the per-scenario policy
in client-through-openshell.sh.
Usage: render-policy.py <server-url> <policy-file> <policy-template> <mcp-version>
"""
import json
import os
import string
import sys
from ipaddress import ip_address
from pathlib import Path
from urllib.parse import urlparse, urlunparse
if len(sys.argv) != 5:
raise SystemExit(
"usage: render-policy.py <server-url> <policy-file> "
"<policy-template> <mcp-version>"
)
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", "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)
if parsed.scheme not in ("http", "https"):
raise SystemExit(f"unsupported conformance server URL scheme: {parsed.scheme!r}")
host = parsed.hostname
if not host:
raise SystemExit(f"conformance server URL is missing a host: {raw_url}")
expected_host = os.environ.get("OPENSHELL_MCP_CONFORMANCE_EXPECTED_SERVER_HOST")
if expected_host:
try:
actual_ip = ip_address(host)
actual_ip = getattr(actual_ip, "ipv4_mapped", None) or actual_ip
expected_ip = ip_address(expected_host)
expected_ip = getattr(expected_ip, "ipv4_mapped", None) or expected_ip
except ValueError as err:
raise SystemExit(
"conformance server URL host must be an IP address when "
"OPENSHELL_MCP_CONFORMANCE_EXPECTED_SERVER_HOST is set"
) from err
if actual_ip != expected_ip:
raise SystemExit(
f"conformance server URL host {actual_ip} does not match expected "
f"runner host {expected_ip}"
)
target_host = (
"host.openshell.internal" if host in {"localhost", "127.0.0.1", "::1"} else host
)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
path = parsed.path or "/"
netloc_host = (
f"[{target_host}]"
if ":" in target_host and not target_host.startswith("[")
else target_host
)
netloc = f"{netloc_host}:{port}"
rewritten = urlunparse(
(parsed.scheme, netloc, path, parsed.params, parsed.query, parsed.fragment)
)
template = string.Template(Path(policy_template).read_text(encoding="utf-8"))
policy = template.substitute(
host_spec=f"host: {json.dumps(target_host)}",
port_spec=f" port: {port}",
path=json.dumps(path),
mcp_version=json.dumps(mcp_version),
)
Path(policy_file).write_text(policy, encoding="utf-8")
print(rewritten)