Per-user OAuth
A tool that acts as the person the session is for.
Some MCP servers should act as the person, not as your company. Register the
connection with auth: { type: "oauth" } and Cereal holds a grant per
(connection, identity, resource).
await client.connections.upsert({
slug: "linear",
url: "https://mcp.linear.app/mcp",
auth: { type: "oauth", scopes: ["read", "issues:create"], signed: true }
})The connection is the consent boundary
A grant is keyed (connection, identity, resource), with no agent id and no session
id. That absence is what "authorize once, and every agent that uses this connection
can act" means. Two agents that need different scopes from one server need two
connections.
Where somebody is asked: in chat, or out of band
Nobody has to connect anything before a session starts. What differs is where the
ask happens, and that is one field on the connection: connect, either "in-chat"
(the default) or "out-of-band", which an agent may override per tools[] entry.
In chat. The server's tools are in the prompt from the first turn whether or
not this person has connected it, each description marked as unconnected, and
the model gets one extra tool: sys_request_connection. Calling it opens the
flow, appends connection.authorization_required (so your hook fires), and
returns a result saying the person has been asked. The agent can then explain,
in its own words, what it needs and why, and carry on with everything else it
was asked to do. If the model instead calls the server's tool first, that comes
back as an authorization-required result and asks the same way. When the grant
arrives, the next call picks it up: no restart, no new session.
Out of band. The tools are absent until this person has a grant. Nothing is opened,
nothing is appended, and the transcript never mentions it: the turn does not have that
server. Use it when your app owns the connect flow and you do not want the agent
improvising one. connections.status, below, is what your UI reads.
await client.connections.upsert({
slug: "linear",
url: "https://mcp.linear.app/mcp",
auth: { type: "oauth", scopes: ["read", "issues:create"] },
connect: "out-of-band"
})Set policies: { authorizations: { whenRequired: "pause" } } on the agent to end
the turn on requires_action once it has asked, for an agent that has nothing
useful to do until the connection works. The default is to keep going.
A connections sidebar
connections.status answers the whole question in one read: every connection the
session's agent version names, whether its person can use each one, and a link for the
ones they cannot. It reuses an active authorization flow when one exists.
Put it behind a route of your own, because it needs an API credential and because only your app knows whether this viewer may see that session:
// app/api/cereal/connections/route.ts
export async function GET(request: Request) {
const sessionId = new URL(request.url).searchParams.get("sessionId")!
await assertViewerOwns(sessionId) // your rule, not Cereal's
return Response.json(await client.connections.status({ sessionId }))
}Render links for disconnected accounts:
import { useEffect, useState } from "react"
import { connectionStatus, type CerealConnectionStatus } from "@trycereal/sdk/browser"
export function Connections({ sessionId }: { sessionId: string }) {
const [rows, setRows] = useState<ReadonlyArray<CerealConnectionStatus>>([])
useEffect(() => {
let active = true
connectionStatus({ sessionId, endpoint: "/api/cereal/connections" })
.then((next) => { if (active) setRows(next) })
.catch((error) => { if (active) console.error(error) })
return () => { active = false }
}, [sessionId])
return rows.filter((row) => !row.connected).map((row) => (
<a key={row.connectionId} href={row.authorizeUrl} target="_blank" rel="noreferrer">
Connect {row.name}
</a>
))
}Call it again when the session stream reports a connection.authorized, and the
rows that are now connected drop out.
The link that fixes it is minted on demand, for an authenticated caller. An authorize URL is a capability, so it is never in the event stream.
for (const row of await agent.authorizations.pending(session.id)) {
// row.connection, row.scopes, row.url, row.expiresAt
await slack.postMessage(`Connect ${row.connection}: ${row.url}`)
}Or drive the flow directly, which is what a "Connections" screen in your own app does:
const { url, expiresAt } = await client.connections.authorize({
connectionId,
identity: { scope: "user", id: "u_412" },
// Your own route knows which of your users is looking at the screen; Cereal does
// not. Must be on the deployment's redirect allow-list, matched exactly.
redirectUri: "https://app.example.com/oauth/done",
sessionId: session.id,
scopes: ["issues:create"] // a step-up; unioned with what the grant holds
})Redirect URIs are an exact-match allow-list. No prefixes and no wildcards: open redirectors are how authorization codes get stolen.
Managing grants
await client.connections.grants.list({ identity: { scope: "user", id: "u_412" } })
await client.connections.grants.revoke({ id: grantId })A grant read never contains a token. The shape has no field for one.
Migrating from a system you already run
await client.connections.grants.import({
connectionId,
mode: "skip_existing", // never downgrades somebody who re-authorized in Cereal
grants: [
{
identity: { scope: "user", id: "u_412" },
accessToken: "…",
refreshToken: "…",
externalId: "legacy:8814" // provenance, and the idempotency witness
}
]
})Idempotent on (connectionId, identity, resource), at most 200 rows per call.
What Cereal does for you
- Scope precedence:
connections.authorize({ scopes }), then the server'sWWW-Authenticatechallenge, then the connection's ownauth.scopes, then the resource server'sscopes_supported. The result is unioned with whatever the grant already holds, so an upgrade never narrows. Your configured scopes rank abovescopes_supportedon purpose: an operator who configuredreadshould not be sent to a consent screen asking for everything the server publishes. - Refresh is single-flight per grant. The four parallel tool calls of one step produce one refresh rather than four, which on a server that rotates refresh tokens is the difference between one valid token and three invalidated ones.
- Discovery, PKCE and dynamic client registration are handled.
resourceis sent always, per the spec. - A stale grant (
invalid_grant) routes back into the authorize path rather than into a new error every caller has to learn.