You need: auth composed, and a front end on the same Worker from pithy ui add. That is the whole prerequisite.
An agent that uses your app is a user of it
Your users’ agents — Claude, Cursor, anything that speaks MCP — can reach your app through an MCP endpoint of its own, in your Worker. Each agent acts as the person who approved it, through your own routes, with exactly the access that person’s role has.
There is no permission vocabulary to design. The token says who, and your routes already know what that person may do. Protecting a route is the gate an agent meets, adding a route is where a route opts in as a tool, and pithy ui writes the two screens it needs.
Compose it beside auth
One command, which takes the floor below as a flag:
pithy add mcp --floor power:agents:usepithy add mcp writes the registration into the Worker’s pithy.config.ts beside auth(), every option spelled out and database read from auth’s. What it comes to, written by hand:
import { auth, mcp } from "@pithy-sh/auth";
const config = {
capabilities: [
auth({ /* … */ }),
mcp({ floor: { power: "agents:use" } }),
],
app,
};That mounts the endpoint at POST /mcp. You write no route. A client is pointed at <your origin>/mcp and finds everything else from there.
floor is required, and there is no default. It decides who may connect an agent at all:
floor | Who may connect |
|---|---|
{ power: "agents:use" } | A caller whose role in their organization holds that power |
{ entitlement: "pro" } | A caller holding that entitlement |
"signed-in" | Every signed-in user |
An endpoint open to every signed-in user is remote automation of a surface built for a person at a keyboard. That changes rate, cost and abuse even when every route gate holds. So "signed-in" is a choice you write down, and pithy doctor says it out loud on every run.
--floor takes signed-in, power:<name> or entitlement:<name>, and the command refuses to run without one. The other options have defaults you rarely change: path (/mcp), consentPath (/agent-consent, the screen pithy ui writes) and database (DB, which must match auth({ database }) — pithy add mcp reads it from auth’s registration). pithy add mcp refuses a Worker that does not compose auth, naming pithy add auth, and one whose auth({ plugins }) already holds an OAuth provider; mcp() refuses to compose in either case too.
Scaffold the two screens
pithy ui addWith mcp composed, the auth screens bring two more with them, and there is no flag of their own:
/agent-consent— where an agent’s authorization lands. A signed-out person signs in here first. The one decision: let this application act as you. The application’s name is shown as the application gave it, which nobody has checked, and the emphasis goes to what is checked: where the access will be sent, and, for an application known by its metadata document, the host that serves that document./connected-applications— what the person has approved, each with that checked host when it has one, and the revoke.
The consent screen is shown every time. An application asking again is a new decision the person sees, never a silent skip. A request asking to be shown nothing (prompt=none) is refused with consent_required.
Opt a route in as a tool
A route is a tool only when it says so, on its describeRoute:
app.get(
"/boards",
describeRoute({
summary: "List the boards.",
operation: "boards.list",
tool: true,
verification: ["bearer", "session"],
responses: { 200: BoardList },
}),
requireAuth(),
handler,
);operationis the route’s permanent name. The tool an agent sees is<capability>_<operation>, so this one isapp_boards_list. Never rename it once published: an agent calls a tool by name.tool: trueis for aGET. Anything else declares what it does:tool: { effect: "writes" }, or"sends","charges","deletes". The effect is written into the tool’s description and its MCP annotations.powerinstalls a role gate on the route itself. With it, a caller whose role does not hold the power gets a 403, and the same power decides whether the tool is listed.
createBackend refuses to boot on a non-GET tool with no effect, a GET that declares one, a tool with no operation, or two routes sharing one.
What an agent sees
tools/list holds only what the caller may call, filtered by the same role check the route makes. A tool their role withholds is absent, not locked.
A tool call runs your route, as the person. It is dispatched through the whole composed app, so every guard, validator and gate runs exactly as it does for their own client. The route’s answer is the tool’s result; a refusal comes back as a result with isError set.
The token itself is never asked for permission. The endpoint checks its signature, issuer, audience and expiry, that the person’s consent is still on record, that the session they approved in is still live, and that the account is in good standing — then it is the person, and nothing more.
How a client gets in
- It calls
/mcpwith no token and gets a401naming the protected-resource metadata. - It reads that, finds your authorization server, and identifies itself: by the URL of a Client ID Metadata Document it publishes, or, if it predates those, by registering itself.
- It opens the person’s browser on your origin, at
/agent-consent, with PKCE. - The person signs in if they need to, and approves.
- The client receives a token bound to your endpoint, so it cannot be replayed anywhere else.
Both ways a client identifies itself are served, and the MCP specification prefers the first. The authorization server’s metadata says client_id_metadata_document_supported: true. A client that publishes a metadata document uses that document’s HTTPS URL as its client id and makes no registration call: your Worker fetches the document at the first authorization and checks it. A document whose client_id is not its own URL is refused, and so is a redirect URI it does not list. The fetch goes to a public host over HTTPS only, follows no redirect, and stops at 5 KB and 5 seconds, and the document is cached by its own caching headers for at most an hour. A client that predates metadata documents registers itself dynamically, at POST /auth/oauth2/register, as before.
dev, staging and prod each publish their own issuer and resource, built from the address each one serves. Nothing to override.
What is recorded
A grant, a refusal and a revocation are audit events: auth/mcp_grant_approved, auth/mcp_grant_denied and auth/mcp_grant_revoked. The approvals and the tokens minted under them live in pithy_mcp_* tables beside auth’s, and an erasure removes a person’s.
Every tool call is recorded too, as one mcp/tool_call event, once audit is composed. It names the person the call ran as, actorType: "user", with actorKind: "agent", so the one filter that finds a management agent’s rows finds these too. The tool is its resourceId, and metadata carries the OAuth client’s id and the name it gave itself, at registration or in its metadata document, which nothing has verified, the route’s status, the time from receiving the call to answering it, and the size of the answer.
The outcome is success, failure, or denied when the route answered 401 or 403, or when the call named a tool the caller was not offered, which is never dispatched. The arguments are recorded as a shape, never as values: the route’s path parameters, and of the query and the body only the top-level key names, each with its value’s JSON type. The event shares the request id with whatever the route itself emits. A request the endpoint refuses before it reaches a tool — no token, a token that does not verify, a consent or session no longer on record, a person below the floor — writes no tool-call event, and a failed audit write never fails the call.
Check it worked
pithy doctorprints anagentsline naming your floor- A
POST /mcpwith no token answers401with aWWW-Authenticatechallenge - A client pointed at
<origin>/mcpopens your consent screen, and after approval lists the tools you opted in - A person whose role lacks a route’s power does not see its tool
- Revoking the application on
/connected-applicationsmakes its next call fail