Reference

Embedding postkeep

postkeep is also a Rust library: your own binary can run the server with additions, such as pages, its own way of deciding who an MCP caller is, tenants, tools, usage limits, or several processes on one database. With nothing added it serves exactly what postkeep serve serves: crates/server/tests/pins.rs checks that, and extension_contract.rs beside it checks that every extension point is called.

Before you start

The crates are not on crates.io, and their API can change in any 0.x release. Depend on them by git revision, all on the same one:

toml
[dependencies]
postkeep = { git = "https://gitlab.com/postkeep/postkeep.git", rev = "<commit>" }
postkeep-auth = { git = "https://gitlab.com/postkeep/postkeep.git", rev = "<commit>" }
postkeep-store = { git = "https://gitlab.com/postkeep/postkeep.git", rev = "<commit>" }

The extension points take axum 0.8 routers and rmcp 3.5 tool types, and the crates need Rust 1.94. cargo doc --open -p postkeep documents every method; crates/server/tests and crates/auth/tests hold worked examples.

A minimal embedding

rust
use std::sync::Arc;

use axum::{Router, routing::get};
use clap::Parser;
use postkeep::cli::ServeArgs;
use postkeep_auth::{CheckFuture, NewMailbox, Refusal, UsageHook};
use postkeep_store::Store;

#[derive(Parser)]
struct Args {
    #[command(flatten)]
    serve: ServeArgs,
}

/// Lets each tenant connect at most three mailboxes.
struct ThreeMailboxes(Store);

impl UsageHook for ThreeMailboxes {
    fn before_connect<'a>(&'a self, mailbox: NewMailbox<'a>) -> CheckFuture<'a> {
        Box::pin(async move {
            match self.0.for_tenant(mailbox.tenant).accounts_in_tenant().await {
                Ok(accounts) if accounts.len() < 3 => Ok(()),
                Ok(_) => Err(Refusal::new("This plan connects up to three mailboxes.")),
                Err(_) => Err(Refusal::new("Try again in a minute.")),
            }
        })
    }
}

#[tokio::main]
async fn main() -> Result<(), postkeep::Error> {
    let args = Args::parse();
    let store = postkeep::open_store(&args.serve).await?;
    postkeep::ServerBuilder::new(args.serve)
        .store(store.clone())
        .usage_hook(Arc::new(ThreeMailboxes(store)))
        .route(Router::new().route("/status", get(|| async { "ok" })))
        .serve()
        .await
}

ServeArgs reads the same POSTKEEP_* variables and flags as postkeep serve (configuration). open_store opens the database as the server would, and store hands it to the server, so your code shares its pool (Store::pool). Your tables can live in the same database; postkeep’s are not a stable interface, so use Store’s methods for them.

serve runs until SIGINT or SIGTERM, then drains and stops like the binary (health and operations). It sets up logging from POSTKEEP_LOG and fails if a global tracing subscriber is already set: add your crates to POSTKEEP_LOG (info,my_app=debug) instead, and run one server per process.

Extension points

ServerBuilder has these methods, and app::Extensions the same ones for tests that build the router with app::router_with:

Method Without it Use it for
route Nothing extra Your own pages and endpoints, behind the same request limits. Handlers can extract axum::Extension<AuthState>. A path the server already serves panics at start
default_pages(false) / and /account are served Leaving out the landing and account pages; /authorize, OAuth, /mcp, /healthz and /readyz stay
principal_resolver This server’s access tokens, each for one mailbox Your own keys or tenants, and several mailboxes per caller (selects_mailboxes)
sign_in_hook The mailbox sign-in page /authorize for the resources the hook claims: your own sign-in or consent page, finished with AuthState::approve_sign_in or deny_sign_in
continuation_hook AuthState::start_upstream_sign_in fails Microsoft or Google sign-ins that your page starts to add a mailbox to a tenant
tool_extension No extra tools or checks Tools and instructions for the callers you pick, and a check before every tool call
usage_hook No limits Limits on new mailboxes per tenant, tool calls, sends and outbox ceilings
watch_ownership This process watches every mailbox and runs every job Several processes on one database. owns, forward and runs must answer at once, from memory, and forward drops what it gets unless you implement it
per_request_resources(true) One resource, at the public URL Each host in POSTKEEP_ALLOWED_HOSTS (which takes *.example.com) its own protected resource, with strict audience checks
resource_prefix No prefix Each name under a prefix, such as /w/<name>/mcp, its own resource; turns on per-request resources
readiness_check /readyz checks the database and migrations /readyz also waits for your check, such as a warm-up

Tenants and migrations

A principal resolver answers with an Authenticated: the principal (who is calling, an opaque string), its tenant, the mailbox tools use by default (account_id) and all it may use (mailboxes). Build one with Authenticated::for_account and its with_* methods. With selects_mailboxes, tools take a mailbox argument naming one of those.

An address can be connected once per tenant. Store::for_tenant scopes address lookups and new accounts (lookups by ID are not scoped). The server alone only uses the tenant default, and SQLite keeps addresses unique across the database, so tenants sharing one need PostgreSQL.

With POSTKEEP_DATABASE_MIGRATE_ON_START=false the server refuses to start while a migration is pending: run postkeep migrate, or postkeep::migrate from your binary, first. Keep your own migrations out of postkeep’s _sqlx_migrations table, whose migrator refuses versions it doesn’t know; with sqlx, give yours its own table (Migrator::dangerous_set_table_name).

Rules for hooks

  • Call AuthState::start_upstream_sign_in only for a same-origin POST from a person you signed in who may change that tenant, with the tenant taken from their session, never from the request.
  • Accept approve_sign_in and deny_sign_in only as same-origin form posts. A pending request’s ID is a secret: it is all it takes to approve or deny the request.
  • Tokens from a sign-in hook act for a principal, never for a mailbox; the default resolver refuses them, and yours reads them with access_grant.
  • Callers see a Refusal’s message, a Resolution::Refused text (the 401’s error_description, so never the token) and a readiness reason (the body of a public 503).
  • UsageHook::before_connect and the insert after it are not atomic: two sign-ins at the same moment can both pass.

The threat model lists what the server enforces around each hook.