Use Shared Signals to move revocation into the MCP server, then let Verify enforce it across every federated app
Part one of this series put authorization in one place: the MCP server. Every tool call exchanges the user's bearer for a fresh OBO at IBM Verify, attaches a Rich Authorization Request describing the exact action, and walks the OBO into HashiCorp Vault, which mints a five-minute PostgreSQL credential. The agent transports. Verify decides. Vault mints. The MCP server stands aside.
That covers the happy path. It does not cover what happens when the clinician taps Deny three times in a row, or when Identity Protection flags the user, or when anything else upstream concludes that this session should not continue.
The OBO that Vault verifies is short-lived. The Postgres lease is shorter. The user's underlying IBM Verify access token is not. Once a bearer is in the wild, it works for every app federated to the same tenant. The clinical record system. The HR portal. Whatever else trusts the same identity. A token issued at sign-in does not naturally know when something has gone wrong on a separate downstream call.
So the perimeter has to tell it.
Put revocation in the same MCP server that already owns authorization. Emit a signed event. Let IBM Antenna deliver it, and let Verify enforce the revoke.
I extended this cookbook with exactly that pattern. Same agent, same MCP server, same Verify tenant, same Vault. New piece: IBM Antenna v26.03 sitting between the MCP server and the IBM Verify session admin API, carrying signed CAEP events over the OpenID Shared Signals Framework.
An earlier post in this series put the per-call wrapper inside the agent process itself. The agent loop ran in our SvelteKit chat route. It composed the RAR per tool call, made the Token Exchange against IBM Verify, and decided when to act on an mfa_denied. The Vercel-breach follow-up showed the same wrapper at the agent level composing two-route RAR for a banking transfer. That architecture works. I created quite a few demos with it.
The lesson learned over the next two builds is that the wrapper is more durable when it lives in the MCP server, not the agent. Both places see the user identity. Both places can build the RAR. What changes is what surrounds them. The agent process is whatever framework you chose this quarter: SvelteKit, FastAPI, Strands, Bedrock AgentCore Runtime. Swap the framework and the wrapper goes with it. The MCP server is the contract every agent in your environment talks to. Move the wrapper there once and every future agent inherits the security model for free.
The IDP knows the user and the policy but not the shape of the tool call. The model in the loop knows the shape of the tool call but not the user. The agent process, depending on how it is hosted, can know both, or only one. The MCP server is the one component that always knows both, on a per-call basis, regardless of what is on the other side of it.
So in this post, and in the cookbook, the wrapper lives in the MCP server. If you already built the wrapper inside an agent (or you read along earlier in the series and built one there), the pattern still works. It is the same RAR, the same Token Exchange, the same threshold counter, the same CAEP emit. What you get by moving it down is one less component that has to stay disciplined.
The important design choice is that the MCP server emits an event, not a product-specific API call. In this build, Verify consumes that event and revokes sessions. The same event could also drive other actions without changing the MCP server at all
The simplest trigger is a counter. The dispatch-wrapper counts consecutive MFA denials on sensitive reads per session principal. The first deny increments the counter. The second deny increments the counter. The third deny trips the threshold.
USER_DENIED result from IBM Verify, increments an in-memory counter keyed by the session principal, returns a normal "mfa_denied" error to the agent. No event emitted.session-revoked payload, POSTs it to the Antenna transmitter ingestion endpoint, and surfaces a different error string back to the agent. The agent matches on that substring and stops the loop.Three is the demo number. Production builds pair it with non-MFA risk signals, things like time of day, geo, device posture, the velocity of denials across all of a user's sessions, the shape of the requested RAR itself. The point is that the same component that owns authorization on the way in also owns the decision that things have gone wrong on the way back. There is no second perimeter to keep in sync with the first.
The wrapper is one file: mcp-server/src/ssf/dispatch-wrapper.ts. It counts. The event builder is one more file: mcp-server/src/ssf/antenna-emitter.ts. It POSTs. Plus a tiny deny-counter.ts that holds the per-user counter state. Together, under 300 lines across three files. A reviewer can read all three in ten minutes and grep the rest of the MCP server source to confirm nobody else emits revocation events.
CAEP is the Continuous Access Evaluation Profile, an OpenID Foundation specification. It defines a small vocabulary of event types that an identity provider, or an application acting as one, can emit when something about a session changes. The event type for this flow is session-revoked. The other event types in CAEP 1.0 are token-claims-change, credential-change, assurance-level-change, and device-compliance-change. The CAEP 2.0 draft adds session-established, session-presented, and risk-level-change — useful to know they exist when you're wiring downstream consumers, even if your transmitter only emits a subset. The official OpenID CAEP 1.0 specification is here: openid.net/specs/openid-caep-1_0-final.html.
The payload is JSON. The shape has to be exact. Three field-level mistakes are easy to make, and all three can fail quietly because the JSON still parses and the ingester returns 201.
{
"sub_id": {
"format": "email",
"verifyUserId": "643002NOIP",
"email": "clinician@example.com"
},
"events": {
"https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
"event_timestamp": 1748208000,
"initiatingEntity": "policy",
"reasonAdmin": {
"en": "3 consecutive MFA denials on VIP read attempts"
},
"reasonUser": {
"en": "3 consecutive MFA denials on VIP read attempts"
}
}
}
}
event_timestamp is seconds, not milliseconds. Date.now() will produce a value the transmitter accepts and never signs. Use Math.floor(Date.now()/1000).initiatingEntity is camelCase. The snake_case initiator_entity is wrong and silently ignored.reasonAdmin and reasonUser are language-keyed dicts like { "en": "..." }, not flat strings. A plain string here also persists unsigned.The format is not forgiving. The Antenna ingester returns HTTP 201 for any of those three malformed payloads — the failure only surfaces in the transmitter log as persisted unsigned SSF event with no follow-up signing or delivery. docker logs vva-antenna-transmitter | grep "persisted unsigned" is the first thing to check when a CAEP event seems to vanish. Read the spec twice, then read the ingester logs the first time something silently fails.
IBM Antenna is the component that takes a CAEP payload and turns it into something Verify can act on. Version 26.03 ships as two container images with one binary. A transmitter image and a receiver image. Same binary, different YAML.
The transmitter is the ingestion side. It receives the CAEP event over HTTPS, validates it against an ingestion rule defined in the transmitter YAML, signs it as a Security Event Token (a JWS-signed JSON object per RFC 8417), and holds it for delivery. The relevant slice of infra/antenna/deploying/transmitter/configs/transmitter.yml:
transmitter:
# Issuer claim on the signed SETs.
issuer: "https://antenna-transmitter"
event_types:
- "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
runtime:
enabled: true
sign_poll_set: true # every outbound SET is RS256-signed
jwks:
signing_keystore: jwks_keys # keystore name from storage.yml
default_signing_key: jwtsigner # key label inside that keystore
ingester:
enabled: true
sources: # each source = one validated ingestion rule
- id: mcp # MCP server POSTs to /sources/mcp/events
type: http_push
transform_rule:
type: javascript
content: "@js/mcp_mapper.js" # pass-through: MCP emits canonical CAEP
The receiver is the consumer side. It polls the transmitter for unread events using the SSF poll-based delivery model from RFC 8936. When a signed SET arrives, the receiver matches the event type against an action rule. The session-revoked action rule runs a small JavaScript function that calls IBM Verify's session admin API. The relevant slice of infra/antenna/deploying/receiver/configs/receiver.yml:
receiver:
# Expose POST /mgmt/v2.0/receivers/config so create-stream.sh can subscribe.
# Bind 9043 to 127.0.0.1 only -- this endpoint ships unauthenticated.
management:
enabled: true
runtime:
enabled: true
action_rules: # can list MULTIPLE rules per event type
- event_type: "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
type: javascript
content: "@js/session_revoked.js"
And the action handler — the actual API call that does the kill:
function deleteSessions(userId, token) {
let url = `https://${TENANT}/v1.0/auth/sessions/${userId}`;
let result = http.delete(
{ headers: { "Authorization": `Bearer ${token}` } },
url
);
logger.info(`[deleteSessions] status=${result.statusCode}`);
}
function main() {
const SETPayload = JSON.parse(eventStr).claimsJson;
let token = getToken(); # client_credentials
let userId = SETPayload.sub_id.verifyUserId; # from the CAEP event
deleteSessions(userId, token); # tenant-wide kill
}
main();
The handler is purpose-narrow on purpose. ~140 lines including the OAuth2 client-credentials helper. Reading it is the auditor's verification that the receiver does exactly one thing — call the Verify session admin API — and nothing else.
More samples are found here: https://github.com/ibm-verify/verify-antenna-recipes
Every YAML file under infra/antenna/deploying/{transmitter,receiver}/configs/ must start with version: 26.03 as the first key. A missing version produces "No configuration to merge" with no further explanation. Configs mount at /configs, not the path the recipe repo's docker-compose suggests. action_rules live under receiver.runtime.action_rules. Stream create moved to the receiver at POST :9043/mgmt/v2.0/receivers/config. The receiver's management port ships unauthenticated; bind it to 127.0.0.1 only and never expose it on another interface.
The synchronous side of the kill is the reply the user sees. The MCP server hands a stable error back to the agent. The string contains the phrase "your session has been revoked." The UI matches on that substring, stops the agent loop, clears local state, and routes the clinician back to sign-in.
The asynchronous side is the actual revoke. The receiver's poll interval is configurable, typically a few seconds. The receiver fetches the signed SET by sending a POST to the transmitter's /streams/{id}/events endpoint — the SSF poll-based delivery in RFC 8936 is HTTP POST, not GET, because the receiver also acknowledges previously-consumed events in the same request body. The JavaScript action rule then runs, and the receiver calls DELETE /v1.0/auth/sessions/{userId} against the IBM Verify admin API. In my lab, the whole cycle landed in about 30 seconds or less, depending on poll cadence and queue position.
This API call invalidates every active session for the user. Not just the one with the MCP server. Every application federated to the same Verify tenant returns 401 on the next request. The user is signed out of Verify, of the clinical record system, of dashboards, Mobile app, any other app using Verify.
Here is what the operator sees on the MCP server and Antenna logs during that third-deny moment, with identifiers and internal hostnames scrubbed.
[mcp-server] mfa challenge fired jti=8b3f...a91c
[mcp-server] USER_DENIED count=1
[mcp-server] mfa challenge fired jti=e441...77f0
[mcp-server] USER_DENIED count=2
[mcp-server] mfa challenge fired jti=f99e...3b21
[mcp-server] USER_DENIED count=3
[mcp-server] threshold reached → emit CAEP sub_id=clinician@example.com
[mcp-server] POST antenna-tx/sources/mcp/events payload=session-revoked
[antenna-tx] event received 201
[antenna-tx] persisted unsigned SSF event eventId=8605...88aa
[antenna-tx] signing SET alg=RS256
[antenna-tx] persisted signed SSF event eventId=599f...0bae
[antenna-rx] POST /streams/<id>/events HTTP/2.0 200 # RFC 8936 poll
[antenna-rx] SET received type=session-revoked
[antenna-rx] action_rule matched handler=session_revoked.js
[antenna-rx] DELETE /v1.0/auth/sessions/userId 204 No Content
[clinician] next request, any federated app 401 Unauthorized
RFC 8936 defines poll-based delivery as the default for Security Event Tokens because the receiver does not have to expose a public endpoint. The receiver pulls. The transmitter signs and holds. The architecture survives behind a strict firewall, on a private subnet, with no inbound public traffic. That is a real operational property, and the reason the standard chose poll over push as the default profile.
Re-read the action rule on the receiver. It is a small JavaScript function that takes a signed SET as input, decides whether to act, and makes one HTTP call. In the cookbook that HTTP call is DELETE /v1.0/auth/sessions/{userId} against IBM Verify. There is nothing in the SSF spec, nor in the Antenna binary, nor in the cookbook's wiring, that pins the action to a session revocation.
The same pipeline, with a different action rule, can call any API you choose to wire to it.
active flag to false. Every downstream tool that checks the registry stops accepting calls from that agent on the next pull.jti already attached, joining every other log line the user touched across Vault, PostgreSQL, IBM Verify, and the MCP server.This is the design point that usually gets lost in the session revocation demo. The kill switch is not something Antenna gives you by default. The kill switch is what the action rule happened to be wired to in the cookbook. The pattern is simple: the MCP server emits one signed event into a generic pipeline, and the receiver-side action rule decides what the event triggers.
The MCP server emits one signed event. Antenna delivers it. The action rule decides what to do with it. Revoke the session, disable the agent, page the SOC, or all of the above.
That separation is the useful part. You write the perimeter logic once, in the MCP server's dispatch-wrapper, and then you wire up whatever consequences you want without ever touching the MCP server again.
Same fifteen-minute audit pattern as part one, extended for the revocation path. An auditor should be able to inspect the implementation, trace the revocation flow end to end, and verify that the control is enforced in practice
mcp-server/src/ssf/dispatch-wrapper.ts. Confirm the counter is keyed by session principal, not by global process state. Confirm the counter resets on a successful MFA approve, so a clinician who taps Deny twice and Approve once does not get a free emit.mcp-server/src/ssf/antenna-emitter.ts. Confirm event_timestamp is in seconds, initiatingEntity is camelCase, and reasonAdmin / reasonUser are language-keyed dicts. Those three field-level mistakes are the most common silent failure mode.infra/antenna/synthetic-probe.sh. It POSTs a known-good CAEP event to the transmitter, polls the receiver, and confirms the SET signature validates against the transmitter's JWKS. If the probe prints PASS, the SET pipeline is wired correctly.infra/antenna/deploying/receiver/configs/receiver.yml (the receiver.runtime.action_rules block) and infra/antenna/recipes/mcp-ssf/verify-receiver/configs/js/session_revoked.js.tpl. Confirm the action_rule routes session-revoked events to @js/session_revoked.js and that handler calls DELETE /v1.0/auth/sessions/{userId} against your Verify tenant and nowhere else. Confirm the SSF management API client used by the receiver has the narrowest entitlements: read users and groups, revoke a session for a user, revoke all sessions for a user, read OIDC and OAuth application grants, read OIDC and OAuth consents — and nothing more.scripts/smoke-test-ssf.sh. It runs the three-deny demo end to end against your tenant. The next request from any federated app returns 401. If it does not, something in the chain is misconfigured and the test surfaces which link broke.
jti reconstructs any tool call, and any kill, end to end across Splunk, Sentinel, QRadar, or Chronicle.Same auditor discipline as part one. If a future PR ever has the MCP server call the Verify session admin API directly, or skip the SET signing, or use a Vault token with broader entitlements than the kill needs, fail the PR. The pattern only works if the perimeter stays disciplined about what authority lives where, and the kill switch is no exception.
mcp-verify-vault. Adds the dispatch-wrapper, the Antenna transmitter and receiver, the action rule, and a smoke test that runs the three-deny demo end to end against your own IBM Verify tenant. Cookbook is rendered live at the Pages link; source + issues at the GitHub link.Try to break it. Tap Deny twice, then Approve once, then tap Deny three more times. Does the counter reset, or do you get a false emit on the second cluster? Read the dispatch-wrapper and decide whether the answer should make you happy.
Wire a second action rule. The Antenna receiver supports more than one action rule per event type. Add a second one that posts the CAEP event to a Slack webhook, or to your SOAR platform, or to an in-house registry. The MCP server does not change. The new consequence is live.
Audit the kill. Walk the five checks in section seven against your own tenant. Either the chain holds or you have found a real bug worth filing.
One signed event, many consequences. The MCP server doesn't change when you add the second action rule. Or the third. The perimeter stays disciplined; the response surface grows.
That is what a kill switch looks like when revocation lives in the same perimeter that owns authorization. The cookbook makes it real.