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]
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. Details of what is logged: section 9.

--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. Nothing about it is hidden: it exists, we use it ourselves, and 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 section 7.

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}Fleet-internal install bootstrap: a machine installing its first saphan binary has no token yet (Install §2)
GET /dashboard, POST /dashboard/sparkDeliberately pre-auth today; fronting the dashboard with the OAuth surface is named, planned work. The spark endpoint is the dashboard's one write — it files a note through the same signed capture path the CLI uses, with the actor resolved server-side, never taken from the client.
GET /metricsDeliberately pre-auth, like the dashboard: a Prometheus scrape target carrying only aggregate counts — class names, never slugs, paths, or actor names (§6.1 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), hand-emitted with zero new dependencies. It sits on the open mux beside /dashboard; 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).

Five families, every one 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_versionthe store's current schema-migration version

Label values are class names onlyrule, status, 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, the same posture as /dashboard (§6 table). It exposes no secret values, but it does reveal fleet shape (counts of runs, refusals, gates), so keep it inside your perimeter and scope who can reach it. This is the first slice of the observability surface Enterprise deployment §"Observability and SIEM" describes; the refusal taxonomy and SIEM export land on top of it.

Before you use it, 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 and we use it ourselves. 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.