Cereal docs
Tools and integrations

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's WWW-Authenticate challenge, then the connection's own auth.scopes, then the resource server's scopes_supported. The result is unioned with whatever the grant already holds, so an upgrade never narrows. Your configured scopes rank above scopes_supported on purpose: an operator who configured read should 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. resource is 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.

On this page