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:
[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
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_inonly for a same-originPOSTfrom a person you signed in who may change that tenant, with the tenant taken from their session, never from the request. - Accept
approve_sign_inanddeny_sign_inonly 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, aResolution::Refusedtext (the401’serror_description, so never the token) and a readiness reason (the body of a public503). UsageHook::before_connectand 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.