7 min read

Do MCP capability downgrades weaken agent controls?

MCP capability downgrades need security tests that prove approvals, per-call review, and tamper-evident audit capture survive older clients.

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:

{
  "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:

{
  "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

Revoke a running agent
Revoke an agent run from the Sessions journal when its authority should stop immediately.

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:

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:

$ 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

Approval follows the process
New agent processes need local session approval, regardless of protocol version or declared capabilities.

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 conditionLocal stateExpected result
Oldest accepted versionVault lockedAction denied and recorded
Empty capabilitiesVault unlocked, new processSession approval requested and recorded
Older versionApproved process, per-call keyPer-call review requested for every action
Unsupported versionAny stateInitialization rejected, no outbound action
Current versionRevoked processAction 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:

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:

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

Verify unusual client activity
Verify the hash-chained audit trail offline with sp audit verify, without opening the vault.

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.

FAQ

How many older MCP protocol versions should I test?

Test the version you still claim to support, plus the oldest version your compatibility code can accidentally accept. Then test a client that declares a current version but omits optional capability fields. The second case catches parser and defaulting mistakes that a normal old-client test misses.

Can an MCP client capability remove an approval prompt?

No. A client’s capability declaration describes what it can do or display. It does not decide whether the action gateway checks identity, asks for review, or records an action.

What should happen if an MCP client sends malformed capabilities?

Reject malformed initialization messages cleanly, then deny protected actions until the session has completed a valid initialization and authorization path. Do not guess what a malformed client intended. A permissive parser is an authorization decision disguised as compatibility.

How do I test that session approval cannot be reused?

If approval attaches to a session, bind it to the actual client process or a similarly strong runtime identity, not a caller-supplied session label. A new process must receive its own approval even when it reuses the same client name and requests the same tools.

Should per-call approval work with a client that lacks notification support?

Review belongs to the sensitive action and the credential policy, not to a notification feature advertised by the client. If the client cannot present a normal interactive review, the gateway should use its local review path or deny the action. Silent fallback is the unsafe choice.

Why must downgraded clients still produce audit records?

Because capability-dependent logging creates a blind spot exactly when compatibility code takes an unusual route. Record the attempt, authorization result, target identity, and outcome in the same audit path used by current clients.

What does an offline audit-chain verification prove?

It proves the encrypted hash chain has not been altered or broken; it does not prove that every intended test event was recorded. Pair verification with assertions that the expected events exist and carry the right authorization and outcome fields.

Do MCP downgrade tests need end-to-end coverage?

Yes, if the test harness can act as a real client process and inspect the local approval and audit surfaces. Unit tests find parser regressions, but they rarely prove that process identity, a human decision, execution, and journaling stayed connected.

Is a missing MCP capability automatically a security issue?

No. Missing optional capabilities should reduce convenience only. They must never turn a protected call into an unreviewed call, erase the record of an attempt, or keep authority alive after the client exits.

Which downgrade-test failures should block a release?

A release-blocking failure is any case where a legacy or partial client can execute with credentials after a missing approval, bypass a per-call review requirement, inherit another process’s session authorization, or create no tamper-evident audit event. Cosmetic differences in presentation can wait; those failures cannot.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov