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" }.
| Claim | Value |
|---|---|
iss | "cereal" |
sub | The connection id (con_…) |
aud | The connection URL exactly as registered |
iat / exp | Issued at, and iat + 300 |
jti | Unique per request |
cereal.purpose | "mcp" or "hook" |
cereal.org / cereal.env | The organization and environment slugs the call came from |
cereal.connection | The connection slug |
cereal.sessionId | Present when the call belongs to a session |
cereal.agentId, cereal.agentVersion | The version that issued the call |
cereal.identity | { scope, id } of the principal, those two fields only |
body_sha256 | Lowercase 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.