# Do MCP capability downgrades weaken agent controls?

Protocol compatibility is part of the attack surface. When an MCP client negotiates an older version or leaves out a capability, it should lose conveniences, not the controls that keep an agent from acting with somebody else’s authority.

I have seen this failure arrive as a harmless compatibility patch: a client does not advertise a notification feature, so the code takes an old branch; the old branch was written before per-session approval existed; an action slips through because nobody treated that branch as an authorization path. The code still passes happy-path tests. It is still wrong.

MCP capability downgrades need security tests because initialization metadata changes the path through the gateway before the agent makes its first sensitive request. Test that path as an adversary would: declare an older protocol version, omit fields, send an empty capability object, restart the process, lock the vault, revoke the session, and inspect what the audit journal says afterward. If the answer changes from "deny or ask" to "run," compatibility has become a credential bypass.

## A downgrade changes the route, not the authority

A compatibility path may change message shapes, available notifications, progress handling, or how much context the client receives. It must not change who is allowed to use a credential or whether a person must approve the call.

That distinction sounds obvious until code starts branching on `protocolVersion` or `capabilities`. A developer often writes the branch to avoid sending an unsupported server message. Later, someone puts session setup, approval delivery, or audit initialization inside the same branch because it is nearby. The downgrade then changes a security boundary by accident.

The MCP specification defines initialization as an exchange where the client declares a protocol version and capabilities, and the server responds with its own version and capabilities. Treat those fields as claims from an untrusted peer. They can guide interoperability. They cannot grant power.

Sallyport makes this separation practical: its vault gate, session authorization, and per-call key setting sit below the MCP conversation, so an agent cannot receive a credential merely by describing itself as an older client. That is the right shape, but it still needs regression tests at the protocol boundary.

Write the security invariant before writing the matrix:

> For every accepted MCP initialization variant, a protected action is denied while the vault is locked; a new client process requires session approval; a credential marked for per-call review requires review for each use; and the action creates an audit event whether it succeeds, fails, or is denied.

That sentence gives reviewers something sharper than "legacy clients work." A legacy client can work only if it preserves those outcomes.

## Initialization input deserves hostile test cases

A well-behaved client sends a clean initialization request once, lists the capabilities it supports, and proceeds after it receives the response. Security tests should start there and then remove each assumption one at a time.

Use a small client fixture that can send raw JSON-RPC messages rather than a high-level MCP library that fills in defaults for you. Library tests are useful, but they hide the wire conditions that cause downgrade bugs.

A baseline request might look like this:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "CURRENT_TEST_VERSION",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": { "name": "compat-fixture", "version": "1.0" }
  }
}
```

The fixture should then produce variants with only one meaningful difference:

- the oldest supported protocol version
- the current version with `{}` as capabilities
- the current version with `capabilities` omitted, if your parser accepts it
- a capability object with known optional entries absent
- an unsupported older version that the server must reject

Do not combine all omissions in the first test. When a combined malformed request fails, you learn very little. One-variable cases tell you which default or branch changed the result.

For every accepted case, assert the same protected-action outcome before approval. The exact transport response will differ by implementation, but the shape must be unambiguous:

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "error": {
    "code": -32001,
    "message": "Session authorization required"
  }
}
```

Do not assert only the text. Assert a stable error category or internal result code, the absence of any outbound HTTP request or SSH invocation, and an audit record for the denied attempt. A friendly error with a real side effect behind it is a failed test.

The awkward case is an initialization request whose fields parse but make no sense together. Perhaps the client says it speaks a version your server recognizes while it declares no abilities that a normal client at that version would use. Your gateway does not need to lecture the client. It needs to choose a safe route: accept the limited session and retain all local controls, or reject initialization. It must not silently choose an unprotected route because the ordinary approval presentation is unavailable.

## Older wire formats cannot create an old security model

Teams often say they support an older protocol version when they mean they can still parse its messages. That is only half the job. You also need to decide which current security behavior survives when the older wire format has no field for it.

The answer should usually be all of it. Session authorization is local state. Per-call review is local state. Audit capture is local state. None requires the client to carry an equivalent field in an older request.

This is where capability negotiation and authorization get blurred. A client may lack a feature for receiving a structured status update. That tells you how to communicate the approval state, not whether to waive it. If your product cannot present a review to the user for that invocation, deny the call with a clear error. Asking a client to promise that it showed a prompt is not a substitute for a local decision.

Avoid the popular fallback of "approve the whole session if the client cannot handle per-call prompts." It is popular because it removes friction and makes a demo pass. It is wrong because it changes a credential owner’s per-use decision into a one-time decision without their consent. The credential’s setting should win.

Keep the version adapter narrow. It should translate request and response syntax, filter messages the peer cannot understand, and map compatible error forms. It should not decide whether an action executes. Put authorization in one path that every adapter calls.

A useful code-review question is: can a compatibility branch return an execution handle, an injected authorization header, or an SSH result without first passing the same authorization function as the newest client? If the answer is yes, the branch needs a test now and a redesign soon.

## Session approval must follow the process that asked

Per-session approval is easy to test badly. If the test client keeps one process alive, approves it, and runs ten calls, you only prove that a happy session remains approved. You have not proved that the next agent process will need its own decision.

Use two independently launched fixture processes. Give them the same displayed client name, the same protocol version, and the same capability declaration. Process A initializes and requests a protected action. Approve its session. Process B then initializes and requests the identical action.

Process B must receive a new approval requirement. It must not inherit A’s authorization because it shares a client label, a working directory, a transport endpoint, or a cached connection detail. If your implementation exposes code-signing authority in the approval card, assert that the card identifies the process authority that actually launched B. Do not make the test assert the decorative parts of the card; assert the identity signal a human uses to decide.

Then test the lifecycle edges that production agents hit:

1. Approve process A and let it exit normally. Start process B with identical metadata. B must require approval.
2. Approve A, then terminate it without a graceful shutdown. Start B. B must require approval.
3. Approve A, revoke A from the session journal while it remains alive, then make another call from A. That call must stop before execution.
4. Revoke A while its first action waits for an external response. The pending action must not create an approved second action after revocation.

The third and fourth cases expose a common mistake: the system checks approval only when it opens a connection or only when it creates an action object. A session can change while a process is still running. Check authority at the point the gateway commits to the outbound action.

Do not use a caller-provided session ID as the approval key. A test can make that defect obvious: have B replay A’s session label and prove that the gateway still asks. A client name is an identifier for display and diagnostics, not proof that the same executable is asking again.

## Per-call review belongs to the credential use

A per-call key setting means the gateway asks for consent each time that credential is used. It does not mean "once for each tool name," "once for each open connection," or "once unless the client is old." The thing under review is the real request that will carry the secret or use the SSH key.

Build a test with a credential marked for per-call review and a fixture that sends two equivalent actions in one already-approved session. For HTTP, use two calls to a test endpoint that returns a harmless marker. For SSH, use two harmless commands against an isolated test host. The test should observe two separate review events and two separate action records.

A minimal expected event sequence looks like this:

```text
session_authorized process=fixture-A
call_review_requested action=41 credential=deploy-token
call_completed action=41 result=success
call_review_requested action=42 credential=deploy-token
call_completed action=42 result=success
```

The important assertion is not the numbering. It is that action 42 cannot use the approval granted for action 41. Make the second request arrive after the first is complete, then make another case where both arrive close together. Concurrency exposes implementations that store one temporary "review passed" bit on the session instead of binding it to a single action.

Now repeat the test with an older protocol declaration and with the capability that your normal approval-status path uses omitted. The review mechanism may present differently on the local machine, but the result must stay the same. If a user dismisses, cancels, or times out the second review, the test endpoint must see one request, not two.

Keep a hard assertion about secret exposure. The MCP response should contain the action result, error, or review-required state. It must not contain an API key, an SSH private key, a redacted placeholder standing in for the secret, or a tool parameter that lets the agent reconstruct it. A downgrade is a tempting place for a compatibility adapter to serialize extra context. Inspect the raw transcript, not only your structured test object.

## Audit capture belongs below protocol negotiation

An audit journal that only records successful modern-client calls is a comfort blanket. The cases you need later are the denied calls, the canceled reviews, the rejected initialization requests, and the strange compatibility paths that made an engineer say "that should never happen."

Record the facts needed to reconstruct the decision: the agent run or session, the action identity, the channel, the requested target, the credential reference or safe identifier, the authorization state, the review outcome, and the execution outcome. Avoid storing the secret itself. For failed initialization, record enough peer and parse context to explain the rejection without turning the journal into a copy of untrusted payloads.

Sallyport projects its Sessions and Activity journals from one encrypted, hash-chained audit log. That design matters for downgrade testing because the client capability should not decide whether a second journal exists. The same write path should see a current client, a partial client, and a denied call.

Use offline chain verification as one assertion after every end-to-end case:

```text
$ sp audit verify
verified: 18 records
chain: intact
```

The exact wording may vary, but the test must require a successful verification result without opening the vault. This test catches a broken or omitted chained write. It does not prove the right event was written, so pair it with a query of the session and activity views that checks for the expected denied, approved, canceled, or completed records.

Test ordering as well. If a review is denied, the journal should show the request and denial, but it must not show a fabricated completion. If the transport action fails after approval, record that failure as execution failure, not as an authorization denial. Those are different operational questions. Mixing them makes incident review painful and lets a broken adapter look like a user choice.

## Build the compatibility matrix around security outcomes

A full Cartesian product of every version, capability, channel, and credential setting will grow until nobody runs it. Keep a small required matrix that covers each security decision, then add a case when a new adapter branch appears.

Use rows for the initialization shape and columns for the local decision state. For example, run an oldest-supported client, an empty-capabilities client, and a normal current client against a locked vault, an unapproved session, a session-approved credential, and a per-call-review credential. Run each meaningful cell through HTTP and SSH if both channels share the same authorization path but use different execution helpers.

The expected outcome should be written in plain terms before the test runs:

| Client condition | Local state | Expected result |
| --- | --- | --- |
| Oldest accepted version | Vault locked | Action denied and recorded |
| Empty capabilities | Vault unlocked, new process | Session approval requested and recorded |
| Older version | Approved process, per-call key | Per-call review requested for every action |
| Unsupported version | Any state | Initialization rejected, no outbound action |
| Current version | Revoked process | Action denied and recorded |

This table earns its place because it forces a decision about unsupported versions. Do not allow the server to "try its best" after negotiation fails. That phrase often means it executes a request on a parser path that received less testing than the supported path. Reject the session, log the rejection safely, and require a supported client.

For each row, collect three independent observations: the raw MCP response, an observation at the fake HTTP service or test SSH host, and the audit result. An error response alone cannot prove that no action reached the outside world. A test endpoint alone cannot prove that the gateway recorded the denial. You need all three.

## A failure transcript should name the broken boundary

When a compatibility test fails, do not file it as "old MCP client has issues." That description guarantees another developer will fix presentation and miss authorization.

Name the invariant and show the timeline. This is a failure report worth acting on:

```text
Client B initialized with oldest accepted version and no optional capabilities.
Client A had already received session approval and was still running.
Client B sent an HTTP action using the same displayed clientInfo name.
Gateway injected the credential and the test endpoint received the request.
No approval card appeared for B.
Activity journal recorded completion, but Sessions journal contained only A.
```

That sequence tells you the defect is process identity or authorization cache scope, not version parsing. It also tells you whether the audit log contains a record that may mislead an investigator. Add the regression test at that level, then trace inward until the implementation has one authorization check that B cannot bypass.

A second failure pattern is more subtle:

```text
Client initialized without the capability used for approval status updates.
Credential required per-call review.
First action displayed local review and completed.
Second action completed immediately.
Audit log recorded two completed actions and one review event.
```

This points to a review token that escaped its action scope. The fix is not to forbid the old client. The fix is to bind the approved review to an action nonce or internal action record and consume it exactly once.

These transcripts also help support teams distinguish a user decision from a software failure. If the journal says a user denied a review, investigate the request. If it says no review happened for a credential that requires one, stop treating the event as normal agent behavior.

## Locking and revocation are separate tests

The vault gate, session approval, and per-call review answer different questions. A downgrade suite should test them separately because a passing approval test can hide a vault-lock failure, and vice versa.

First lock the vault. Launch every compatibility fixture, including the oldest accepted version. Each protected action must fail before credential injection and before the external service sees anything. Do not accept a result that merely asks the agent to provide credentials itself. The agent must never receive them.

Then unlock the vault but leave the session unapproved. The fixture should get one session-authorization path for its run. Approve it, make a protected action, and then revoke the session while the process remains alive. The next action must fail even though the vault stays unlocked.

Finally, use a per-call-review credential in that approved session. Approve one action, deny the next, and inspect the journal. This sequence proves the decision ladder retains its order: the vault blocks all action when locked; session approval establishes authority for that process; the credential setting can require a fresh decision on top of it.

Do not collapse those tests into one long scenario unless you also keep focused tests for each boundary. Long end-to-end tests are good at proving that a path exists. They are poor at telling you why it broke after a refactor.

## Release gates should punish silent success

Compatibility bugs deserve a release block when they let an action happen quietly. A minor mismatch in a status message can be fixed in the next patch. A downgraded client that inherits approval, skips per-call review, acts while the vault is locked, or disappears from the audit trail has crossed a security boundary.

Make those tests mandatory in continuous integration for every change that touches MCP initialization, protocol adapters, approval delivery, process tracking, credential injection, or audit projection. Do not wait for a protocol-version bump. The risk comes from branching around a missing capability, and ordinary feature work creates those branches all the time.

The test that pays for itself is usually the ugly one: an old client declaration, an empty capability object, a reused display name, a second process, and a second credential use after the first was approved. Keep it. That is how you find the friendly compatibility code that quietly turned a human decision into a cache hit.
