Skip to content

Enforce permitted MCP request profiles at the forwarding boundary #2174

Description

@shiju-nv

User Story

As a sandbox operator, I want MCP traffic checked against an explicitly permitted request revision and my method/tool policy, so that valid supported clients work and the request reaching the upstream matches the request OpenShell authorized.

Problem Statement

MCP revisions differ in request grammar, batching, method availability and HTTP metadata. An endpoint allowlist alone does not establish which revision governs a request, whether every operation is authorized, or whether middleware preserves that decision. A version-independent parser can reject valid older traffic or accept a request shape unavailable in the selected revision.

The acceptance scope is correctness around the request OpenShell authorizes and forwards. This replaces the earlier proposal's OpenShell-owned wire registry and negotiated-session/response guarantees with the request contract below. It does not describe those broader guarantees as implemented.

Impact / Why This Matters

Unconditional MCP batch rejection breaks the March 2025 request contract; unconditional acceptance admits forms later revisions prohibit. Operators also need protection against a request being inspected under one interpretation and forwarded under another after a rewrite. Requiring a proxy session registry or extra handshake round trips would add operational burden without establishing a demonstrated requirement for the method/tool authorization boundary.

Proposed Design

An operator configures a nonempty allowlist of supported exact revisions with mcp.versions. The supported vocabulary is 2025-03-26, 2025-06-18, 2025-11-25 and opt-in 2026-07-28. Omission keeps the pinned ["2025-11-25"] default; adding support in a dependency cannot widen an existing policy.

OpenShell selects one endpoint-permitted profile for each request outside valid standalone legacy initialization. That initialization is an explicit bootstrap exception: structural inspection and endpoint method policy still apply, but the client's proposed params.protocolVersion is not treated as an effective or negotiated revision. For subsequent requests, the allowlist never unions profile grammar. The missing-header fallback for legacy requests is March 2025, which must itself be allowed; it is distinct from the policy default. July 2026 requires its sessionless request metadata and matching HTTP mirrors.

Tower owns revision-specific JSON-RPC/MCP structure, method availability, direction, typed parameters and batch grammar. OpenShell owns its supported policy vocabulary, endpoint authorization, request limits, HTTP/body consistency, audit behavior and forwarding. Typed inspection is not a claim of complete JSON-schema validation.

Every policy-relevant operation is checked, including every batch member. In enforce mode a denied member prevents forwarding the complete batch. Malformed or ambiguous protocol input remains rejected in audit mode. Unknown extensions require exact-method permission; deny rules retain precedence. Generic JSON-RPC keeps its separate policy contract.

The final forwarded representation must preserve the authorization decision. Replaced bodies are reinspected and reauthorized; final header-selected grammar and required metadata mirrors are checked before forwarding. Middleware cannot change method, path or query. Future changes that alter policy inputs must reauthorize those inputs. This does not claim arbitrary tool-argument authorization or unconditional OPA reevaluation for every header change.

Client and server retain negotiation and session lifecycle ownership. This scope does not certify initialization results, validate server response contents, correlate callbacks/resumed responses, or prove equality with an earlier negotiated revision. It also does not claim complete MIME/Accept or lifecycle conformance: legacy DELETE cleanup is unsupported. Existing response forwarding, framing and streaming behavior is preserved within the tested boundaries. Any demonstrated interpretation or forwarding bypass of the retained request contract remains a correctness defect that must be fixed before closure.

Acceptance Criteria

  • Canonical revision allowlists survive all policy, provider, protobuf/storage, update, projection and activation paths without silent loss or widening.
  • Outside valid standalone legacy initialization, each inspected request uses one supported permitted profile; conflicting, duplicate, unknown or disallowed selection signals fail closed. Bootstrap initialization remains structurally inspected and subject to method policy without treating the proposal as the effective or negotiated revision.
  • Supported valid requests and explicitly permitted extensions work; unavailable methods, invalid shapes, ambiguous JSON and bounded-request violations are rejected.
  • Enforce-mode batch denial is atomic, with valid, malformed and audit controls proving the documented behavior.
  • Middleware rewrites preserve the configured method/tool policy behavior. In enforce mode, denied rewrites forward no request bytes; audit may forward otherwise valid policy-denied rewrites. Malformed or profile-invalid rewrites are rejected in both modes, and forwarded rewrites have the expected representation.
  • Authenticated proxy evidence covers every supported revision and a multi-version endpoint; relay evidence covers both entry paths, bootstrap, final-request checks and relevant transport rejection cases.
  • Clients require no proxy-managed session registration, added handshake round trip, manual session bookkeeping or extra version-selection knob.
  • User/architecture documentation states the default, ownership boundary, audit behavior and compatibility limits consistently.
  • Required implementation and proof commits are merged, required checks/reviews pass, and the final upstream source matches the acceptance evidence with no unresolved in-scope correctness defect.

Alternatives Considered

  • Reject or accept batches for every MCP endpoint: either choice violates one of the supported request profiles.
  • Use a disable_batch switch or dependency-selected defaults: these create contradictory policy settings or silently changing policy semantics.
  • Permit only one revision per endpoint: this excludes legitimate clients using different supported revisions at the same endpoint.
  • Maintain a second MCP schema registry in OpenShell: this duplicates protocol ownership already provided by Tower.
  • Enforce initialization responses and session equality now: this can detect additional server/session inconsistencies, but requires an explicit policy guarantee, correlation/state ownership and resource limits. It is separately scoped unless a concrete failure shows it is necessary for the retained authorization boundary.

Agent Investigation

The implementation sequence includes #3199, #3241, #3251 and the current #3335. The latter delegates inspection to pinned tower-mcp-types 0.22.2 and includes the canonical policy-schema integration and deterministic boundary-digest correction.

Published proof commits 5e9a833ba and d34c38946 cover atomic March batch denial, actual middleware tool rewrites and per-profile sandbox execution. The final sandbox harness executed both tests successfully. The supported November conformance scenarios passed separately; neither result establishes complete conformance across all revisions. Final-head hosted Branch Checks passed; local workspace/server verification and required human review remain in progress. Issue closure requires merged acceptance evidence, not merely this implementation status.

Checklist

  • I've reviewed existing issues and the architecture docs
  • This is a design proposal, not a "please build this" request

Activity

  1. rpelevin commented on Jul 8, 2026

    @rpelevin

    This is the design direction I would make test-visible. mcp.versions should be an endpoint compatibility allowlist, but each initialization/session should resolve to exactly one enforced MCP profile before any JSON-RPC body is interpreted.

    The invariant I would want covered is:

    • unknown, missing, or unsupported MCP revision fails closed before forwarding;
    • 2025-03-26 batch acceptance is profile-bound and atomic: every member is parsed, direction-checked, policy-checked, and correlated before any member is forwarded;
    • later single-message profiles reject top-level arrays with a typed denial, not a recovered string reason;
    • generic protocol: json-rpc batch behavior remains separate from protocol: mcp;
    • selected profile, session id, request id, frame kind/direction, denial reason, and forward/no-effect outcome are recorded on the same decision receipt.

    I would avoid disable_batch as durable policy because it can drift from the negotiated revision. It is fine as a temporary compatibility switch, but the long-term source of truth should be the selected MCP profile.

    The failure mode to pin is "accepted by parser, rejected by profile" versus "malformed for JSON-RPC" versus "denied by policy." Those should be distinct terminal outcomes, because operators need to know whether a request was not MCP-valid, not policy-authorized, or not safely interpretable.

    I have not tested a patch or verified the source locally. This is only a boundary/test-shape pass on the issue as described.

  2. ddurst-nvidia commented on Jul 8, 2026

    @ddurst-nvidia
    Contributor

    Open questions on my end:

    UX

    protocol: mcp
    mcp:
      versions:
        - "2025-03-26"
        - "2025-11-25"
    
    • I like the list in theory.
    • I'd want to understand the semantics around draft, and how it's updated (today's draft might not be tomorrow's draft).
      • If we exclude it, the only real option would be offering an L4 pass through? Which I'm fine with, I'd just want to make it clear in documentation.
      • If there are options for a specific version, how do we expose them? Does this even happen at all?
    • I assume, that the block would happen as the final result of the initialize? Or would we be filtering the Client/Server negotiations?

    Support Floor

    I expect we should be setting a support floor as a general rule of thumb, and I'd like for that floor to be mid-2025 at latest. This would just provide us with a cleaner way to express what is and isn't supported by both policy & the gateway. Error on an older version should point people to an ungoverned L4 escape hatch if that's acceptable for them (they can always use a different proxy from there).

    If we have to support older, we have to support older, but we won't be dragging everyone else around with us into the support.

    SEP's

    More than once, a SEP has become popular before it's been supported by an MCP version. While I don't expect we'd want to support one-off SEP's, I do expect we'll want the policy semantics to remain expressive enough, to allow for them to be exposed for an MCP Protocol version that was written before them (either strictly encoded in a branch, or with overrides).

    Cost

    OpenShell still resolves exactly one revision for each initialization exchange and each established session.

    This isn't free, but it's not expensive. In all cases, this is at least introspection during initialization.

    • 2025-06-18 introduces a REQUIRED header for this (MCP-Protocol-Version), that must match the initialized version.
      • That's likely a check we must perform on each subsequent request.

    Promises

    We should enumerate what we're guarding for and promise not to let through, for each version and in general.

    There's part of MCP support that is "we allow MCP to work through this", but that might not be much better than an L4 promise.

  3. drew commented on Jul 10, 2026

    @drew
    Collaborator

    📋 triage-agent

    Triage Assessment

    Classification: feature-valid

    Summary

    The proposal addresses a real version-model gap and fits the architecture already anticipated by McpOptions. Stable MCP revisions differ in batching, headers, lifecycle, and transport rules, while OpenShell currently applies one version-independent MCP inspection mode.

    Investigation

    proto/sandbox.proto:148-177 explicitly reserves MCP options for OpenShell-owned version profiles but currently exposes only tool-name and method-default booleans. JsonRpcInspectionOptions selects only generic JSON-RPC versus MCP plus strict tool names (crates/openshell-supervisor-network/src/l7/jsonrpc.rs:18-47), and both modes share the array/batch path at lines 179-212. Batch members are then evaluated atomically in l7/relay.rs:1497-1517.

    This is not a duplicate of #2082/#2083: those address unconditional batch rejection for the currently targeted revision, while this issue supplies the version model needed to preserve the different 2025-03-26 contract. Related policy and inbound-authorization issues do not cover negotiated MCP revision binding.

    Recommendation

    Accept the endpoint allowlist plus one selected profile per initialization/session. Before implementation, settle migration/default behavior for existing policies, the supported-version floor and treatment of drafts/SEPs, bootstrap parsing of initialize, server-selected revision binding, Mcp-Session-Id correlation, and subsequent MCP-Protocol-Version validation. Keep generic protocol: json-rpc behavior separate.

  4. added
    area:policyPolicy engine and policy lifecycle work
    topic:l7Application-layer policy and inspection work
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Jul 10, 2026
  5. added theissue type on Jul 10, 2026
  6. ddurst-nvidia commented on Jul 10, 2026

    @ddurst-nvidia
    Contributor

    @drew - Of note here, and as discussed with @krishicks:

    Accept the endpoint allowlist plus one selected profile per initialization/session

    The "one selected profile" aspect of this might be introducing more complexity than you think it might, and will depend on where it's enforced. It's a version negotiation during initialize [2026-11-05 lifecycle] selecting the highest between MCP server & client. I have not vetted prior versions, to see how different it is per version of the spec.

    If "only one" is allowed, and depending on how that's enforced, you'll need to be careful about how that might interfere with a handshake.

  7. github-actions commented on Jul 25, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

  8. joshrotenberg commented on Aug 1, 2026

    @joshrotenberg

    Update: tower-mcp-types 0.17.1 is now published with transport-free inspection APIs designed for this intermediary/policy-engine use case.

    The release adds:

    • JsonRpcPayload::inspect for strict classification of requests, notifications, success responses, error responses, and non-empty same-side batches;
    • McpProtocolRevision and McpInspector for exact-revision inspection with no implicit latest profile;
    • inspection profiles for 2025-03-26, 2025-06-18, 2025-11-25, and 2026-07-28; OpenShell can choose whichever subset it intends to support;
    • typed method-parameter validation and optional client/server direction checks;
    • distinct Available, Unavailable, and Extension method classifications;
    • structured errors carrying stable kinds plus relevant fields, revisions, methods, and batch indexes.

    The API and trust boundary are documented in the inspection module. The implementation landed in tower-mcp #1130 and #1132, then was exercised by the tower-mcp runtime in #1133.

    This appears directly useful to OpenShell rather than requiring it to adopt another runtime abstraction. OpenShell already uses tower-mcp-types, while its current MCP path still manually classifies JSON-RPC envelopes and batches before invoking the aggregate MCP conversions. The new API can replace that duplicated structural and revision-semantic parsing while leaving OpenShell in control of policy, transport/session state, authorization, auditing, response correlation, and forwarding.

    A possible OpenShell integration contract would be:

    1. Treat mcp.versions as a non-empty allowlist of exact revisions, never as the union of every listed wire form.
    2. For legacy stateful revisions, permit only a narrowly validated initialization bootstrap, then require the server-selected exact revision to be endpoint-allowed and bind it to the resulting session before ordinary MCP traffic.
    3. For sessionless 2026-07-28, select and validate the exact profile per request from its version metadata rather than creating session state.
    4. Invoke McpInspector with the selected revision and observed direction before policy evaluation:
      • Available proceeds to OpenShell policy;
      • Unavailable fails closed rather than becoming an extension;
      • Extension remains subject to explicit OpenShell extension policy.
    5. Derive batch behavior only from the selected profile: 2025-03-26 may accept atomic same-side batches (with initialize excluded), while later profiles reject top-level arrays. Generic protocol: json-rpc remains separate.

    This provides a version-aware path for the concern in #2083. Whether that PR is reworked, superseded, or retained as a narrower interim change is an OpenShell maintainership decision.

    The remaining choices are appropriately OpenShell-owned: which profiles the product supports, the support floor, migration behavior for existing policies, and whether/how explicitly named SEP extensions are exposed. I would avoid a moving draft alias because it would let policy semantics change without a policy edit; unsupported drafts can remain on an explicitly uninspected path.

    If this direction aligns with the project, I would be happy to help prepare the implementation as reviewable commits after those product choices are settled.

  9. github-actions commented on Aug 28, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

  10. purp commented on Sep 4, 2026

    @purp
    Collaborator

    📋 triage-agent

    This epic is not inactive. Active work as of today:

    state:stale was applied on 2026-08-28 and is inaccurate. The cause is mechanical, not a judgment about this epic: PR #3027 cross-referenced this issue on 2026-08-31, but a cross-reference does not bump an issue's updated_at, which is what the stale sweep measures. Filed as #3190.

    This comment bumps updated_at, so the next sweep should clear the label on its own.

  11. added
    state:acceptedA maintainer decided OpenShell should pursue this issue
    and removed
    state:staleInactive item at risk of automatic closure.
    on Sep 4, 2026
  12. changed the title [-]Define version-aware MCP wire profiles for Streamable HTTP and JSON-RPC[/-] [+]Enforce permitted MCP request profiles at the forwarding boundary[/+] on Sep 17, 2026
  13. shiju-nv commented on Sep 29, 2026

    @shiju-nv
    CollaboratorAuthor

    Closing #2174 as complete under its agreed request authorization and forwarding scope.

    The implementation is merged through #3027, #3192, #3199, #3241, #3251 and #3335. The final integration, #3335, merged as 1358941b.

    The acceptance criteria are satisfied:

    • Exact mcp.versions allowlists survive policy, provider, storage, update and activation paths. Supported revisions are 2025-03-26, 2025-06-18, 2025-11-25 and opt-in 2026-07-28; omission retains the pinned November default.
    • Outside valid standalone legacy initialization, each request is inspected under one permitted profile. Initialization remains structurally inspected and subject to method policy without treating the client's proposal as the negotiated revision. The March missing-header fallback must itself be allowed; profile grammars are never combined.
    • Tower performs revision-specific inspection, while OpenShell enforces request bounds and method/tool policy. Unsupported forms and malformed or ambiguous input are rejected. Unknown extensions require exact-method permission, and deny rules retain precedence.
    • Enforce-mode batch denial is atomic. Middleware body replacements are reinspected and reauthorized, and final profile and header/body consistency checks run before forwarding. Denied batches and rewrites send no request bytes upstream in enforce mode. Audit may forward otherwise valid policy-denied traffic; malformed or profile-invalid traffic remains rejected.
    • Authenticated sandbox tests cover every supported revision, including November and July selected under one unchanged allowlist. Relay tests cover both entry paths, initialization, middleware rewrites, final-request checks and transport rejection. Clients need no proxy-managed session registration or additional handshake. User and architecture documentation describe the defaults, ownership and compatibility limits.

    Client/server negotiation and session lifecycle remain client/server responsibilities. This closure does not claim negotiated-session equality, initialization-result or server-response validation, callback/resume correlation, arbitrary tool-argument authorization, an unconditional final OPA rerun, or full MCP/JSON-schema/MIME/Accept conformance. Legacy DELETE cleanup remains unsupported. These limits remain explicit in the issue and documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:policyPolicy engine and policy lifecycle workarea:supervisorProxy and routing-path workstate:acceptedA maintainer decided OpenShell should pursue this issuetopic:compatibilityCompatibility-related worktopic:l7Application-layer policy and inspection work

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions