Cereal docs
Tools and integrations

MCP tools and request signing

Cereal calls your endpoint. Here is how you prove the call is Cereal's.

A connection is a named MCP endpoint an agent may call. An agent's definition refers to it by slug, never by URL, which is what makes the same definition portable to another environment where sources points somewhere else.

A connection has two names, exactly as a provider does. The slug is the identifier a definition holds, unique per environment and immutable once the row exists. The name is the display label: free text, reworded whenever you like, and resolved by nothing. Omit it and the label is the slug.

const connection = await client.connections.upsert({
  slug: "research-agent",
  name: "Research agent (production)",
  url: "https://app.example.com/api/cereal/mcp",
  auth: { type: "cereal-signed" },              // or { type: "bearer", secret } | { type: "oauth", … }
  hooksUrl: "https://app.example.com/api/cereal/hooks",
  hookEvents: ["agent.message", "tool.call.started"]
})

// A live probe: connect, list tools, disconnect. Costs no tokens.
await client.connections.test({ id: connection.id })
// In the agent definition
tools: [{ type: "mcp", connection: "research-agent", allow: ["read_source", "publish_brief"] }]

allow is an allow-list against the server's catalogue, resolved once per turn.

The signature

Every request Cereal sends to a cereal-signed connection, MCP JSON-RPC POSTs and hook POSTs alike, carries a compact JWS in Authorization: Bearer, EdDSA over Ed25519. There is no shared secret to store or rotate: Cereal publishes the public half and you verify.

GET https://app.trycereal.ai/.well-known/cereal/orgs/<organization>/jwks.json serves your organization's JWKS of OKP/Ed25519 keys (kid, alg: "EdDSA", use: "sig"), cacheable for five minutes. Several kids may be active at once during a rotation, so never pin one. The SDK builds that URL from expect.org, so there is nothing to configure but the slug.

Header: { "alg": "EdDSA", "kid": …, "typ": "cereal+jwt" }.

ClaimValue
iss"cereal"
subThe connection id (con_…)
audThe connection URL exactly as registered
iat / expIssued at, and iat + 300
jtiUnique per request
cereal.purpose"mcp" or "hook"
cereal.org / cereal.envThe organization and environment slugs the call came from
cereal.connectionThe connection slug
cereal.sessionIdPresent when the call belongs to a session
cereal.agentId, cereal.agentVersionThe version that issued the call
cereal.identity{ scope, id } of the principal, those two fields only
body_sha256Lowercase hex SHA-256 of the exact raw body bytes

Two of those do the real work. aud stops a token minted for one endpoint being replayed at another, and body_sha256 binds the token to its payload. Without it a token is a five-minute bearer credential replayable against a different tool call.

Verifying by hand

agent.handlers checks all of this before anything reaches a tool. For a route you wrote yourself, a proxy, or a middleware:

import { createCerealVerifier, CerealSignatureError } from "@trycereal/sdk/verify"

const verify = createCerealVerifier({
  audience: "https://app.example.com/api/cereal/hooks",
  purpose: "hook",
  expect: { org: "acme" }        // which organization's keys to trust
})

export async function POST(request: Request) {
  try {
    const { claims, connection, session, body } = await verify(request)
    // `body` is the raw text, already read; `request` is still readable.
  } catch (error) {
    if (error instanceof CerealSignatureError) {
      return Response.json({ reason: error.reason }, { status: 401 })
    }
    throw error
  }
}

The verifier's options, and the eight values error.reason takes, are on @trycereal/sdk/verify.

Which environment may call you

const verify = createCerealVerifier({
  audience: "https://app.example.com/api/cereal/mcp",
  expect: { org: "acme", env: "prod" }        // or env: ["prod", "staging"]
})

Set expect.org and expect.env to the organization and environment allowed to call your endpoint. The verifier fetches that organization's keys and checks the signed claims. Do not choose a trusted key set from unverified request data.

Defined agents learn these values from the environment-bound API key during initialization in each serving process, including fresh serverless instances. For a standalone verifier, configure the expected organization and environment explicitly.

When the request also carries a user's token

An oauth connection with signed: true sends two credentials: the end user's access token in Authorization, where the MCP authorization spec says a resource server's credential goes, and Cereal's own JWS in X-Cereal-Signature.

const verify = createCerealVerifier({
  audience: "https://mcp.example.com/mcp",
  expect: { org: "acme" },
  header: "x-cereal-signature"       // defaults to "authorization"
})

There is no fallback between the two. Verifying Authorization on an oauth connection would mean checking the user's token as though Cereal had signed it, so a missing signature header is missing_token rather than a search.

Event hooks

A connection may also subscribe to session events through hooksUrl and hookEvents. Every delivery is signed exactly as an MCP request is, with cereal.purpose: "hook". The envelope, the outbox and the dead-letter queue are on event hooks.

On this page