Skip to main content
An agent that can do everything its owner can do is a liability. This tutorial builds the opposite: a user hands an agent a read-only token, an MCP server checks it on every call, the agent is stopped when it reaches for a write tool, and the user can pull the plug at any time. It runs in one process with an in-memory SQLite database, no model and no API key. The full script is in 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.
The owner is created first with auth.user.create({ id: 'user-alice', email, name }).

2. Configure the MCP server

The OAuth server and the resource server share one createMcpModule. 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.
Delegation links are agent to agent, so every actor in the chain needs an agent id. OAuth clients get theirs from a binding, set once after the client registers:
From then on every token issued to that client carries 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 carries mcp:read mcp:write. The agent then trades it for a narrower token.
Token exchange can only narrow. Asking for a scope the subject token lacks fails with 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.
The delegated rule covers read only, so no grant matches a write and matchedGrant is absent. See Explain a decision.

5. Add a hook (optional)

A beforeAuthorize 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.
The blocked call returns code hook_denied and writes an audit row with that code. See Lifecycle hooks.

6. Revoke the parent delegation

This writes a 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

IDs and the port are random and timestamps are the time of your run. The 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.query returns 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.rejected is 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 no matchedGrant when 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.
Last modified on October 18, 2026