MCP tools and request signing
Plane calls your endpoint. Here is how you prove the call is Plane's.
A connection is a named MCP endpoint an agent may call. An agent's definition
refers to it by name, never by URL, which is what makes the same definition
promotable to another environment where orders points somewhere else.
await client.connections.upsert({
name: "support-bot",
url: "https://app.example.com/api/plane/mcp",
auth: { type: "plane-signed" }, // or { type: "bearer", secret } | { type: "oauth", … }
hooksUrl: "https://app.example.com/api/plane/hooks",
hookEvents: ["agent.message", "tool.call.started"]
})
// A live probe: connect, list tools, disconnect. Costs no tokens.
await client.connections.test({ name: "support-bot" })// In the agent definition
tools: [{ type: "mcp", connection: "support-bot", allow: ["lookup_order", "create_return"] }]allow is an allow-list against the server's catalogue, resolved once per turn.
The signature
Every request Plane sends to a plane-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: Plane publishes the public half and
you verify.
GET <planeBaseUrl>/.well-known/plane/jwks.json serves a 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.
Header: { "alg": "EdDSA", "kid": …, "typ": "plane+jwt" }.
| Claim | Value |
|---|---|
iss | "plane" |
sub | The connection id (con_…) |
aud | The connection URL exactly as registered |
iat / exp | Issued at, and iat + 300 |
jti | Unique per request |
plane.purpose | "mcp" or "hook" |
plane.org / plane.env | The organization and environment slugs the call came from |
plane.connection | The connection name |
plane.sessionId | Present when the call belongs to a session |
plane.agentId, plane.agentVersion | The version that issued the call |
plane.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
plane.handlers checks all of this before anything reaches a tool. For a route you
wrote yourself, a proxy, or a middleware:
import { createPlaneVerifier, PlaneSignatureError } from "@plane/sdk/verify"
const verify = createPlaneVerifier({
jwksUrl: `${process.env.PLANE_BASE_URL}/.well-known/plane/jwks.json`,
audience: "https://app.example.com/api/plane/hooks",
purpose: "hook"
})
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 PlaneSignatureError) {
return Response.json({ reason: error.reason }, { status: 401 })
}
throw error
}
}error.reason is one of missing_token, invalid_signature, unknown_key,
expired, audience_mismatch, body_hash_mismatch, purpose_mismatch,
tenant_mismatch.
Use createPlaneVerifier rather than verifyPlaneRequest in a server: it holds the
JWKS cache across requests, and a per-call verifier puts a JWKS fetch in front of
every tool call.
Which environment may call you
const verify = createPlaneVerifier({
jwksUrl: `${process.env.PLANE_BASE_URL}/.well-known/plane/orgs/acme/jwks.json`,
audience: "https://app.example.com/api/plane/mcp",
expect: { org: "acme", env: "prod" } // or env: ["prod", "staging"]
})One field, and it says "only production may call me".
Plane publishes one JWKS per organization, so a verifier is configured with a key set out of band and then asserts the claims — never the other way round. A verifier that chose its key set from the token's own claims would let anyone with any Plane environment pick which key verifies their forgery. Which is why this is stronger than a key set per environment: your production endpoint can refuse development traffic even though one organization signed both.
plane sync fills expect in from the key it synced with, so the common path is
correct by construction. Asking for an expect and getting no claim is a
tenant_mismatch.
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 Plane's own JWS in X-Plane-Signature.
const verify = createPlaneVerifier({
jwksUrl: `${process.env.PLANE_BASE_URL}/.well-known/plane/jwks.json`,
audience: "https://mcp.example.com/mcp",
header: "x-plane-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 Plane 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. Deliveries are an outbox: the event and its delivery rows commit together, so there is no window in which an event exists with no delivery queued. Delivery is at-least-once and the delivery id is the idempotency key.
{
"type": "plane.hook",
"version": 1,
"connection": "support-bot",
"session": {
"id": "ses_…", "agentId": "agt_…", "agentVersion": 3,
"metadata": { "customerId": "cus_42" },
"identity": { "scope": "user", "id": "u_412", "display": "Ada Lovelace" }
},
"event": { "type": "agent.message", "seq": 12, "…": "…" }
}Ephemeral events (agent.text_delta and friends) cannot be subscribed to: a hook that
fired per token would be a denial of service against your own endpoint, at a rate the
model chooses.
Retries back off and then dead-letter. hooks.stats, hooks.deliveries,
hooks.redeliver, hooks.redeliverAll and hooks.discard are the queue's API, and a
dead delivery also becomes a hook.dead_letter event on its session.