Plane docs
Guides

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" }.

ClaimValue
iss"plane"
subThe connection id (con_…)
audThe connection URL exactly as registered
iat / expIssued at, and iat + 300
jtiUnique per request
plane.purpose"mcp" or "hook"
plane.org / plane.envThe organization and environment slugs the call came from
plane.connectionThe connection name
plane.sessionIdPresent when the call belongs to a session
plane.agentId, plane.agentVersionThe version that issued the call
plane.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

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.

On this page