Repository navigation
Enforce permitted MCP request profiles at the forwarding boundary #2174
Description
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_batchswitch 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
rpelevin commentedon Jul 8, 2026
This is the design direction I would make test-visible.
mcp.versionsshould 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:
2025-03-26batch acceptance is profile-bound and atomic: every member is parsed, direction-checked, policy-checked, and correlated before any member is forwarded;protocol: json-rpcbatch behavior remains separate fromprotocol: mcp;I would avoid
disable_batchas 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.
ddurst-nvidia commentedon Jul 8, 2026
Open questions on my end:
UX
draft, and how it's updated (today'sdraftmight not be tomorrow'sdraft).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
This isn't free, but it's not expensive. In all cases, this is at least introspection during initialization.
2025-06-18introduces a REQUIRED header for this (MCP-Protocol-Version), that must match the initialized version.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.
drew commentedon Jul 10, 2026
ddurst-nvidia commentedon Jul 10, 2026
@drew - Of note here, and as discussed with @krishicks:
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.
github-actions commentedon Jul 25, 2026
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.
joshrotenberg commentedon Aug 1, 2026
Update:
tower-mcp-types 0.17.1is now published with transport-free inspection APIs designed for this intermediary/policy-engine use case.The release adds:
JsonRpcPayload::inspectfor strict classification of requests, notifications, success responses, error responses, and non-empty same-side batches;McpProtocolRevisionandMcpInspectorfor exact-revision inspection with no implicitlatestprofile;2025-03-26,2025-06-18,2025-11-25, and2026-07-28; OpenShell can choose whichever subset it intends to support;Available,Unavailable, andExtensionmethod classifications;The API and trust boundary are documented in the
inspectionmodule. 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:
mcp.versionsas a non-empty allowlist of exact revisions, never as the union of every listed wire form.2026-07-28, select and validate the exact profile per request from its version metadata rather than creating session state.McpInspectorwith the selected revision and observed direction before policy evaluation:Availableproceeds to OpenShell policy;Unavailablefails closed rather than becoming an extension;Extensionremains subject to explicit OpenShell extension policy.2025-03-26may accept atomic same-side batches (withinitializeexcluded), while later profiles reject top-level arrays. Genericprotocol: json-rpcremains 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
draftalias 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.
github-actions commentedon Aug 28, 2026
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.
purp commentedon Sep 4, 2026
This epic is not inactive. Active work as of today:
state:accepted,state:pr-opened)gator:in-review) with one blocking finding openstate:stalewas 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'supdated_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.feat(mcp): upgrade tower-mcp-types to 0.17.1
feat(mcp): upgrade tower-mcp-types to 0.22.2
feat(mcp): upgrade tower-mcp-types to 0.22.2
feat(mcp): upgrade tower-mcp-types to 0.22.2
[-]Define version-aware MCP wire profiles for Streamable HTTP and JSON-RPC[/-][+]Enforce permitted MCP request profiles at the forwarding boundary[/+]feat(mcp): upgrade tower-mcp-types to 0.22.2
feat(mcp): upgrade tower-mcp-types to 0.22.2
feat(mcp): inspect requests with Tower-selected protocol profiles (NV…
shiju-nv commentedon Sep 29, 2026
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:
mcp.versionsallowlists survive policy, provider, storage, update and activation paths. Supported revisions are2025-03-26,2025-06-18,2025-11-25and opt-in2026-07-28; omission retains the pinned November default.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.