examples/agent-delegation-e2e.
What you build
1. Create the agents and the delegation
Alice owns a parent agent with read and write access. She delegates read only to a child agent. The child has no permissions of its own.auth.user.create({ id: 'user-alice', email, name }).
2. Configure the MCP server
The OAuth server and the resource server share onecreateMcpModule. Passing auth keeps clients, codes, tokens and refresh families in the TheAuth database, so there are no storage callbacks to write. Three options matter here. tokenExchange lets the agent narrow its token, delegationStore makes the module re-check the delegation chain against live records, and resolveActorAgentId maps the token subject to an agent id.
agent_id, and the live delegation check resolves the client to reportBot without a hand written mapper. Your resolveActorAgentId runs first and returns undefined for anything it does not handle, which falls through to the built-in mapping. See MCP OAuth 2.1 for the full option list.
3. Delegate the scoped token
Alice approves the agent app through the authorization code flow with PKCE. That grant carriesmcp:read mcp:write. The agent then trades it for a narrower token.
invalid_scope. The new token carries an act claim that records which client is acting for the user. See MCP server grants.
4. Verify on every call
The MCP server checks the bearer token and the scope the tool needs before it runs anything.read_report needs mcp:read, so it passes. delete_report needs mcp:write, so requireScopes returns a 403 with an insufficient_scope challenge and the tool never runs. The server writes an audit row for both outcomes with recordAuditEvent.
The denial is typed. check.error.code is one of UNAUTHORIZED, INVALID_TOKEN, TOKEN_EXPIRED, INVALID_AUDIENCE, INVALID_ISSUER, DELEGATION_REVOKED, INSUFFICIENT_SCOPE or DPOP_REQUIRED. check.rejected names the client, subject, agent and token id, but only when the token’s signature verified, so it is safe to attribute in an audit log.
Before the 403, the script asks theAuth why the write would be denied. explain() only reads: it writes no audit row and spends nothing.
matchedGrant is absent. See Explain a decision.
5. Add a hook (optional)
AbeforeAuthorize hook runs for auth.authorize() and receives the delegation chain behind the agent. This one logs the chain depth and denies a resource on a blocklist. Hooks fail closed: a throw or a timeout denies the call.
hook_denied and writes an audit row with that code. See Lifecycle hooks.
6. Revoke the parent delegation
delegation.revoke audit row on the delegate’s chain with revokedBy set. The agent’s token has not expired and its signature is still valid. The next call is rejected anyway, because delegationStore finds the revoked link in the chain from the user to the agent.
Expected output
read rows come from auth.authorize(). audit.query returns newest first, so the script reverses it to print write order. Rows from the same second still come back in write order.
Rough edges to know about
audit.queryreturns newest first. Reverse it if you want to read a trail top to bottom.- Audit timestamps still have one second resolution, so rows from one run print the same time. Write order is reliable even so.
check.rejectedis only filled for a token whose signature verified (expired, revoked, wrong audience, missing scope, dead delegation). A forged or malformed token yields a plain 401 with no attribution.explain()shows nomatchedGrantwhen no rule matches at all, so the output says why a write fails but not which near miss was closest.
Next steps
Delegation chains
Depth limits, constraints and cascading revocation.
MCP OAuth 2.1
The authorization server, scopes and the live delegation check.
MCP server grants
Token exchange, client credentials and jwt-bearer.
Audit trail
Query, export and verify the audit log.