Saphan StudioDocs
MCP server

Transports, lifecycle and exposure

The verb surface, the bind address, the start gate, the access log, tunnels, and what is pre-auth by construction.

The verb surface

saphan server start [--listen host:port] [--stdio] [--access-log path|off] [--tunnel] [--detach] [--config path]
saphan server status
saphan server reload      # SIGHUP: re-reads CONFIG only — state is always fresh from the record
saphan server stop        # SIGTERM, graceful

Bind address: loopback by default, widening is a deliberate act

Default bind is 127.0.0.1:7654 — loopback, never wider. The listen address resolves through the precedence ladder — --listen flag → SAPHAN_SERVER_LISTEN env → server.listen config key → built-in default — and the refusal policy binds every tier alike: a host-less value (":7654") from any source would bind every interface, so it is refused, class listen-ambiguous, with a paste-ready remedy naming the offending tier. Configuration cannot widen the bind any more quietly than the flag can. server status reports the effective address a fresh start would bind, with its provenance tier.

Start gate: no identity, no listener

Before the listener accepts a single connection, the server checks that the runtime identity registry is provisioned — at least one actor row, at least one binding row, and a loadable trust-root pin. A missing registry refuses the start by name (registry: runtime registry not provisioned) with the remediation spelled out (saphan identity import). This is a deploy-fault check run once, at start, not per request.

The access log is audit material

--access-log append-opens its path at start; an unwritable path refuses the start, class access-log-unwritable — a server that cannot append to its audit log must not start. off disables it deliberately. Under --stdio the default log stream is stderr, because stdout is the MCP wire.

--detach: never a fork-and-lie

--detach re-execs the binary detached (new session, log redirected) and returns only after a bounded readiness handshake — the child confirmed alive and holding the pidfile within two seconds, or a named failure with the log tail. The pidfile is always written by the foreground child, never by the detaching parent. --detach --stdio is refused (class detach-stdio-conflict): stdio's whole point is that the invoking terminal owns the wire for the life of the process, which a detached process can never provide.

--tunnel: reaching the server from outside, for evaluation

⚠ What this is and is not. --tunnel is a convenience for reaching a running server from outside the machine without changing how it binds — for a demo, an evaluation, or a developer working against a colleague's instance. It is not the way a production deployment is exposed. It is documented here with its limits rather than left as a flag someone discovers.

What it does. It launches cloudflared as a supervised child process fronting the loopback listener with a quick tunnel.

The server itself never opens a wider bind and never terminates TLS — cloudflared owns TLS, DNS and network exposure entirely, and every route it forwards still sits behind the bearer middleware exactly as on loopback. A cloudflared binary missing from PATH refuses the start by name, never a silent no-op front door. --tunnel --stdio is refused (class tunnel-needs-listener). One deliberate interaction: the self-issued authorization server refuses to operate over a forwarded hop — see Authentication.

Pre-auth by construction: the short list

Everything on the HTTP mux is behind the bearer middleware except routes that cannot require a token by their nature:

RouteWhy pre-auth
GET /.well-known/oauth-protected-resource (+ /mcp twin)RFC 9728 discovery — the client needs it to obtain a token
GET /.well-known/oauth-authorization-serverRFC 8414 discovery
GET /authorize, POST /tokenThe authorization flow itself
GET /icon.svg, faviconsCosmetics — served pre-auth so a connector card can render its icon
GET /get/install.sh, /get/SHA256SUMS, /get/{platform}/{bin}Install bootstrap: a machine installing its first saphan binary has no token yet (Deployment)
GET /console/login, POST /console/login, POST /console/logoutThe console's own sign-in. Every other /console route requires a console session (not a bearer token) — see the console
POST /console/spark⚠ Not behind the console session. It writes nothing: every request is refused with 409, because the console never signs
GET /metricsDeliberately pre-auth: a Prometheus scrape target carrying only aggregate counts — class names, never slugs, paths, or actor names (see below). No token can be required of a scraper, and there are no secret values to protect; treat network reachability as the control.

The /get/* bootstrap checks its {platform}/{bin} wildcards against closed known sets before they ever reach a filesystem path — traversal-safe by construction, not by cleaning — and the install script's self-referencing URL is spliced only from a Host header matching a strict shape, since that string lands inside a shell assignment about to be piped to sh.

The metrics endpoint

GET /metrics on the serving daemon is a classic Prometheus text-exposition surface (Content-Type: text/plain; version=0.0.4). It sits on the open mux; a non-GET method answers 405, and a failed source query answers 500 with the error in the body — never a partial 200 page (a half-scrape that reads as a healthy small fleet is the failure this surface refuses).

Every family is a gauge — the record's rows update in place (a run moves running → ok), so per-status counts can fall; a counter would misreport a projection of current state:

FamilyLabelsWhat it projects
saphan_runsstatus, backendrecorded runs, by status and backend
saphan_refusalsrulerecorded refusals, by rule — first-class, because on this architecture a refusal is a security signal, not noise
saphan_gatesgate, decisionrecorded gate decisions, by gate and decision
saphan_owner_block_elementsstateowner-block elements, by state
saphan_store_schema_version—the store's current schema-migration version
saphan_delivery_measured_runs · saphan_delivery_unmeasured_runs · saphan_delivery_contradictory_runs—how many run records carry a delivery measurement, how many do not, and how many contradict themselves — the population the delivery figures below cover
saphan_delivery_files_changed · _lines_added · _lines_deleted · _binary_files · _renames · _commits—delivery figures summed over the measured runs only; an unmeasured run is never counted as zero

Label values are class names only — rule, status, backend, gate, decision, state — never stream slugs, paths, or actor names. Cardinality is bounded and there is nothing to leak: secret values are structurally absent (Secrets), and the projection is aggregate counts, not rows. Values are escaped per the exposition format (\ → \\, " → \").

Scrape it like any Prometheus target:

scrape_configs:
  - job_name: saphan
    static_configs:
      - targets: ['<serving-daemon-host>:<port>']

or check it by hand: curl -s http://<serving-daemon-host>:<port>/metrics.

Security posture. The endpoint is unauthenticated by design — an open-mux projection (see the pre-auth table above). It exposes no secret values, but it does reveal fleet shape (counts of runs, refusals, gates, delivery volume), so keep it inside your perimeter and scope who can reach it.

Before you use --tunnel, four things it does not give you

  • No named tunnel, no account, no DNS you control. A quick tunnel's address is issued for that run and is gone when the process ends. ⇒ It is not an address to put in a document, a client configuration, or a firewall rule.
  • No audit of who reached you. Exposure is owned entirely by the tunnel provider: it owns TLS, DNS and the network path. Every route still sits behind the same bearer check as on loopback, so authorisation is unchanged — but the reachability is somebody else's.
  • No production posture. ⛔ Do not front a production installation this way. A deployment that must be reachable belongs behind your own proxy, with your own certificate and your own access policy — see the deployment section.
  • One deliberate refusal you will meet: the product's own authorization server refuses to operate over a forwarded hop. That is not a bug to work around; minting first-party credentials through somebody else's front door is exactly the shape it declines.

Why it is documented at all, given the above: because it exists in the binary. A flag that ships and is not written down is a flag someone finds at the worst moment — and the honest version of "internal convenience" is a page saying so, not silence.