examples/mcp-revocation-demo. For the reference behind it, see MCP token revocation.
package.json, so it imports the built package by relative path. In your own project, import from @glinr/theauth and @glinr/theauth/mcp.
What you build
1. Create the agents, the delegation and the token
Alice owns an assistant that can read and write reports. The assistant delegates read only to a sub-agent, which has no permissions of its own.mcp.clients.bindAgent(clientId, agentId). Alice approves the assistant app through the authorization code flow, which gives a token for mcp:read mcp:write whose agent_id is the assistant. The sub-agent app then trades that token for a narrower one with RFC 8693 token exchange:
invalid_grant.
The MCP module reads revocation state in one of three modes. The default with a database is live, and this script runs the issuing module that way.
2. A call inside the scope
The helpercallTool is what an MCP server does for each request: mcp.requireScopes(request, [scope]), then one audit row for the outcome.
read_report needs mcp:read, which the token has, so the answer is 200.
3. A call outside the scope, and why
delete_report needs mcp:write. The token was narrowed to mcp:read, so requireScopes answers 403 INSUFFICIENT_SCOPE and the tool never runs.
auth.explain() then says why the sub-agent could not do this, without writing an audit row. The sub-agent holds one delegated grant, read on mcp:reports:*. Nothing matches a write, so the decision is deny with code no_permission. The useful part is nearMisses[0]: the delegated grant matched the resource and failed only on the action, and the hint says so in one sentence. See Explain a decision.
4. Read the token
The script decodes the JWT payload and prints the parts that matter. It never prints the raw token.actis the RFC 8693 actor claim. Here it holds one entry, and itssubis the OAuth client id of the sub-agent app. The script labels it for you.agent_idis the sub-agent, the acting agent and the last entry ofagt.theauth_dlglistsagt, the agents on the authority path with the root first (assistant, then sub-agent), andlnk, the delegation link between them. This signed claim is the binding. It holds ids only, no permissions or names.theauth_pjtilists the jtis of the token this one was exchanged from. The script does not print it. Revoking the assistant’s token would revoke this one too.
5. Revoke the parent delegation
delegation.revoke audit row that names Alice, and records the link in the shared revocation table.
6. The next request fails
Same token, same tool. The resource server checks the signature, then the binding: is every agent inagt still active, and is every link in lnk still live? The link is revoked, so the answer is 401 DELEGATION_REVOKED.
The script prints the audit rows for Alice’s two agents in write order: the allowed read, the denied write, the revoke, and the denied read after it. audit.query returns newest first, so the script reverses the list.
One detail in that trail: the denial rows land under the sub-agent, because the server attributes a rejection to check.rejected.agentId, which comes from the token’s agent_id claim. The delegation.revoke row is on the delegate’s chain, which is also the sub-agent.
7. The honest tradeoff: cached and offline
Live mode reads the database on every request. That is a handful of indexed queries, and the count grows with the length of the delegation path. Two other modes skip some or all of that, and the script shows what you give up. Cached. The script builds a second MCP module withrevocation: { mode: "cached", pollIntervalMs: 200, maxStalenessMs: 1000 }. It is given only the database handle, with no in process revocation bus, so it learns about a revoke the way a separate server instance would: by polling the shared table. The call right after the revoke still answers 200, because the cache holds the earlier verdict. The script then retries until the poll lands and the answer turns into 401. The documented bound is maxStalenessMs, which defaults to 5000 and was lowered here so the run is short. The process that performs the revoke clears its own cache at once, so you only see this gap on other instances.
Offline. A third module with mode: "offline" checks the signature and exp and nothing else. The script revokes, waits 1.5 seconds, and the call still answers 200. The token stays good for about 60 minutes, because accessTokenTtl is 3600 seconds here. Nothing shortens that, and a server in this mode would not see a revoked parent token, agent or user either, unless the handler calls revalidate(). If you use offline mode, set accessTokenTtl to a few minutes. The script then sends the same token to the live module, which refuses it.
Pick the mode by what a stale answer costs. The table in MCP token revocation has the numbers.
8. Ask the issuer
A resource server that does not want to trust the JWT alone can call the introspection endpoint (RFC 7662). It always reads live state, in any mode. The caller must be a confidential client. The module config lists which clients may introspect which audiences:orders-api gets {"active":false,"theauth_inactive_reason":"delegation_revoked"}. A caller that is not listed gets {"active":false} and nothing else, so it cannot tell a revoked token from one it was never meant to see.
Expected output
Where this stops
- Offline mode trusts the token until
exp. Cached mode has the poll delay you saw in step 7a. - Banning or deleting a user, and signing out everywhere, revoke that user’s tokens too, and the token endpoint refuses their refresh tokens and authorization codes. Signing out does not delete refresh token families. The live check at the token endpoint is what stops the refresh.
client_credentialsandjwt-bearertokens are not covered by the user and client watermarks, because those grants do not read the revocation table before minting. Agent bound tokens are still refused once the agent is revoked.- The authorization code path does not yet refuse a client that an admin bound to another user’s agent. This is an open policy decision.
- A request that passed validation before the revoke commits finishes. For long handlers, call
principal.session.revalidate()between steps. - A server that verifies the JWT itself, or caches introspection answers, sees the revoke when that cache ends or the token expires.
- The raw token in the script is a bearer token. Revocation does not stop replay while the token is live. DPoP does.
Next steps
MCP token revocation
Freshness modes, the shared denylist and where revocation stops short.
Delegation chains
Depth limits, constraints and cascading revocation.
Explain a decision
Matched grants, near misses and constraint checks.
Delegate a scoped token
The longer walkthrough with hooks and an HTTP server.