Skip to main content
This page is the canonical reference for connection mode (sticky vs. per-call) and connection state across every MCP auth type. For the auth-type decision itself (which credential shape to use), see MCP Authentication.
Configurable connection mode via needs_session_stickiness, and the token_exchange auth type, are available in Bifrost v2.0.0 and above.

Two independent axes

Every MCP client sits somewhere on two independent axes:
  1. Who authenticates — server-level (none, headers, oauth) vs. per-user (per_user_headers, per_user_oauth, token_exchange). Covered in MCP Authentication.
  2. How the connection is heldsticky (one persistent upstream connection, reused for every tool call) vs. per-call (a fresh connection dialed per tool call, closed immediately after). This page’s focus.
The two axes aren’t fully independent — which connection modes are even available depends on auth type and connection type: So needs_session_stickiness only ever has an effect on http connections using a server-level auth type. Everything else is fixed by construction.
Two entirely separate credential-resolution paths exist for per-user auth types, and they’re easy to conflate: the admin discovery credential (retained once, used only for periodically refreshing the server’s tool list) and the end-user’s own credential (resolved per request, used only for the actual tool call). See Per-user lifecycle below.

Connection states

pending_tools and a bare connected/disconnected naming existed in older versions of this doc set — the current state names are exactly the seven above. If you see connected, disconnected, connecting, or pending_tools referenced anywhere else in these docs, that’s stale and maps to healthy, unstable, (nothing — was never a real state), and (removed — an empty healthy tool map already communicates the same thing) respectively.

Why a server is in a state

When Bifrost’s own connection handling has failed for a server, the state carries its explanation. GET /api/mcp/clients returns it as last_failure, and the UI shows the same record behind the state badge (click it) and at the top of the server sheet. The field is present only after the serving instance has attempted a connect or check that failed; it is absent while healthy, and absent when a state changed without such an attempt.
A few properties worth knowing:
  • It is the serving instance’s own record. It describes what this Bifrost instance last ran into, never the outcome of real tool calls made through the server.
  • It clears on the first passing check. A healthy server never carries a stale reason; the record is gone the moment the state flips back.
  • It can lag a credential-driven state. A needs_reauth that comes from a credential row dying in the store (a rejected refresh, a rotation) shows no reason until the server’s next scheduled check actually hits the dead credential, at which point the provider’s rejection is recorded. Shared-OAuth servers hit it on their next reconnect; per-user servers on their next tool-discovery check.
  • A single failed check is enough. There is no consecutive-failure counter across checks. Each check already retries with backoff internally (three retries per operation), and that budget is what absorbs an ordinary blip. A check that exhausts it marks the server unstable and records why.
  • Reasons are logged once per transition. The state-change log line at INFO carries the stage and message. Individual failed checks during an outage log at DEBUG only, so a long outage does not flood the log.
Distributed deployments. Each instance reports its own state and last_failure through the shared node-state heartbeat. When instances disagree, state is degraded and node_states maps each instance ID to its own { "state", "last_failure" }. The same map is also attached when every instance agrees on unstable, because their reasons can still differ (one pod missing a binary, another timing out). The UI folds that map by state and reason, so three instances failing the same way read as one line with a count; the per-instance detail stays available in the API.

Lifecycle: sticky server-level (needs_session_stickiness: true, or sse/stdio)

  • Connect happens once at AddClient (boot, or client creation) and again on every reconnect. Reconnects are make-before-break: the old connection keeps serving until the new one is ready, so token rotation never causes downtime.
  • unstable is purely informational — a network blip doesn’t stop tool calls, and the client self-heals on the next successful check with no human involvement.
  • needs_reauth is a hard gate specifically for server-level clients: the periodic checker stops retrying entirely (retrying against a known-dead credential is pointless), and tool calls are refused outright rather than attempted. Recovery is POST /api/mcp/client/{id}/reauthorize (also covers oauth; headers/none credentials don’t expire the same way, so this state is effectively OAuth-only in practice for server-level clients).
  • Every discovered tool list — from the initial connect and from every periodic refresh — persists to the DB, gated on the tool list actually having changed (no write on an unchanged tick), so a restart doesn’t lose anything a running instance had already discovered.

Lifecycle: per-call server-level (needs_session_stickiness: false/omitted, http only)

  • No persistent connection ever exists — every tool call dials fresh and closes immediately after. There’s nothing to reconnect (POST /reconnect returns 400 for this mode), and needs_reauth doesn’t apply here (there’s no single connection credential to die — headers/none/oauth per-call clients cycle through a fresh credential resolution on every call).
  • AddClient runs a synchronous first discovery pass if no tools are already known (e.g. from a prior successful discovery persisted to the DB) — without this, a client would sit at healthy/0-tools until the periodic checker’s slow first tick, which can be minutes away for an already-healthy client.
  • The periodic checker’s ongoing ticks are the only thing revisiting this client afterward — same content-hash gating as the sticky case, so an unchanged tool list doesn’t cause a write.

Lifecycle: per-user (per_user_oauth, per_user_headers, token_exchange)

Per-user clients have two entirely separate credential paths, which is the single most important thing to understand about them: Admin discovery credential — exists purely to keep the advertised tool list current, independent of any individual user:
  1. Bootstrap. An admin submits a sample credential once (POST /verify-headers, POST /verify-exchange, or the complete-oauth callback for per_user_oauth). This is the one and only time a live admin session is required. As a side effect, that credential is retained server-side.
  2. Ongoing refresh. AddClient’s own synchronous first-discovery pass and the periodic checker’s ongoing ticks both reuse the retained credential (never a fresh admin login) to keep the tool list current — same content-hash-gated persistence as server-level clients.
  3. Repair. If the retained credential itself dies (OAuth refresh fails, or a header schema change flips it to stale), it projects as needs_reauth in the client list — but only when the underlying runtime reading is healthy or unstable; a client sitting in pending_verification or disabled keeps that state instead. Repair is auth-type-specific: POST /verify-headers (headers), POST /verify-exchange (token exchange), or reauthorizecomplete-oauth (per_user_oauth).
End-user credential — resolved per caller, on the actual tool-call path, with no relationship to the admin credential above:
  1. A caller’s request carries an identity (VK, signed-in user, or session ID — see Identity modes).
  2. Bifrost looks up that identity’s own stored credential for this MCP client. Found and active → the call proceeds transparently. Missing or stale → an mcp_auth_required payload with an auth URL is returned instead of executing the tool.
  3. The caller completes their own auth (OAuth consent, or submitting header values) once; every later call resolves transparently from then on.
Because these two paths are independent, a per-user client’s tool list can go stale (admin credential dead) while every end-user’s actual tool calls keep working perfectly, or vice versa — a specific user can be locked out (their own credential expired) while the tool list stays perfectly current for everyone else. Don’t assume needs_reauth on a per-user client means anyone is blocked; check which credential it actually refers to.

Recovery endpoints by combination

A reconnect/reauthorize/close call that doesn’t apply to a client’s current connection mode returns a consistent error (client uses per-call connections; there is no persistent connection to reconnect/close) rather than one that implies something is specifically wrong with per-user auth — the same message applies to any per-call client, shared or per-user.

Next Steps