> ## Documentation Index
> Fetch the complete documentation index at: https://bifrost-backport-outbound-fetchers.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Connections, States & Lifecycles

> How Bifrost holds a connection to each MCP server type, every connection state, and what a client's lifecycle looks like end to end.

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](./auth/overview).

***

<Info>Configurable connection mode via `needs_session_stickiness`, and the `token_exchange` auth type, are available in **Bifrost v2.0.0 and above**.</Info>

## 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](./auth/overview).
2. **How the connection is held** — **sticky** (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:

| Auth type | Connection type | Connection mode |
| - | - | - |
| `none`, `headers`, `oauth` | `http` | **Choosable** via [`needs_session_stickiness`](./connecting-to-servers#session-stickiness-http-only) — sticky if `true`, per-call if `false`/omitted (default) |
| `none`, `headers`, `oauth` | `sse`, `stdio` | **Always sticky** — an SSE session is inherently bound to its open stream, and STDIO needs a persistent subprocess. Setting `needs_session_stickiness: false` on either is rejected at creation. |
| `per_user_headers`, `per_user_oauth`, `token_exchange` | any | **Always per-call**, regardless of `needs_session_stickiness` (the field is ignored for these auth types) — each caller's own credential is resolved fresh per request; there is no single shared connection to keep alive |

So `needs_session_stickiness` only ever has an effect on `http` connections using a server-level auth type. Everything else is fixed by construction.

<Note>
  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](#per-user-lifecycle-per_user_oauth-per_user_headers-token_exchange) below.
</Note>

***

## Connection states

| State | Meaning | Self-heals? | Gates tool execution? | Applies to |
| - | - | - | - | - |
| `healthy` | Bifrost's own periodic connection check (ping/`list_tools` for sticky, `list_tools` for per-call) most recently succeeded. | — | No | Sticky and per-call (server-level) |
| `unstable` | The periodic check most recently failed with a transient-classified error. Purely informational — tool calls are still attempted normally regardless. Reflects only Bifrost's own health checks, never the outcome of real tool calls. | Yes — next successful check | **No** | Sticky and per-call (server-level) |
| `needs_reauth` | **Server-level:** the connection credential itself died with no way to silently recover (e.g. an OAuth refresh token rejected upstream) — a hard gate, tool calls are refused outright rather than attempted against a known-dead credential, and the periodic checker goes quiet on this client until a human reauthorizes. **Per-user** (`per_user_oauth`, `per_user_headers`, `token_exchange`): a **response-only projection**, computed at list-time, never stored in the runtime manager. It means the *retained admin discovery credential* needs repair — end-user credentials and tool calls keep working the whole time; only the periodic tool-list refresh pauses. Overlays onto both `healthy` and `unstable` runtime readings (it's a more actionable signal than either), but never onto `disabled` or `pending_verification` — those already carry a more specific, authoritative meaning of their own. | **No** — human action required either way | Server-level: **yes**. Per-user: no (only the discovery refresh pauses) | All except `none` |
| `pending_verification` | Declared (typically via `config.json`) but the one-time auth/verification flow hasn't been completed by an admin yet. | No — needs the one-time verification | Yes, implicitly (nothing to call yet) | All auth types |
| `disabled` | An admin intentionally turned the client off. Configuration is preserved; connection and background workers are shut down. Authoritative — never silently overridden by a check result or the `needs_reauth` projection. | No — needs a manual re-enable | Yes | Sticky and per-call (server-level) |
| `error` | A data-consistency fallback used only when a client is registered in the config store but missing from the runtime manager entirely — a deeper anomaly than anything in the normal lifecycle, and never assigned by the connect/health-check machinery itself. | — | Yes | All (rare) |
| `degraded` | A **read-time cluster aggregate**, never a single node's own local state: multiple instances of a distributed deployment each currently hold a different self-reported state for the same client (e.g. one instance sees `healthy` while another currently sees `unstable`). Only meaningful for states that can genuinely vary per instance (`healthy`, `unstable`, `pending_verification`); `needs_reauth`/`disabled` are config-sourced facts expected to already agree everywhere, so disagreement there is a propagation problem, not something this value covers. Never appears in a single-instance deployment. | Depends on the underlying disagreement resolving | — | Cluster deployments only |

<Note>
  `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.
</Note>

### 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.

```json theme={null}
"state": "unstable",
"last_failure": {
  "stage": "list_tools",
  "message": "context deadline exceeded",
  "at": "2026-09-03T10:41:12Z",
  "since": "2026-09-03T10:32:04Z"
}
```

| Field | Meaning |
| - | - |
| `stage` | Which step of Bifrost's own connection handling failed: `connect` (dial, initialize, connect gate, or the initial `list_tools` a new connection must pass), `ping` and `list_tools` (the periodic check over a sticky connection), `tool_discovery` (the periodic check's ephemeral connect-discover-close cycle for per-call auth types), `transport_lost` (a live SSE stream dropped), or `credential` (the credential the connection depends on was rejected upstream or rotated by an admin). |
| `message` | The error that step failed with, whitespace-collapsed and capped at 512 characters. |
| `at` | The most recent failed attempt. While a server is `unstable` the check retries every 10 seconds, so this keeps moving for as long as the outage lasts. |
| `since` | The first failed attempt of the current unhealthy run. Answers "how long has this been going on". |

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`)

```mermaid theme={null}
flowchart LR
    A[Create] --> B[Connect]
    B -->|success| C[healthy]
    B -->|failure, transient| D[unstable]
    C -->|periodic check fails, transient| D
    D -->|periodic check succeeds| C
    D -->|credential dead, no silent recovery| E[needs_reauth]
    C -->|credential dead| E
    E -->|admin reauthorizes| B
```

* **Connect** happens once at `AddClient` (boot, or client creation) and again on every reconnect. Reconnects are [make-before-break](./gateway#reconnection-behavior): 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`](./auth/oauth#reauthorization) (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)

```mermaid theme={null}
flowchart LR
    A[Create] --> B[Synchronous first discovery]
    B -->|success| C[healthy, 0+ tools]
    B -->|failure| D[unstable]
    C -->|periodic re-discovery ticks| C
    D -->|periodic re-discovery succeeds| C
    D -->|periodic re-discovery fails| D
```

* 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:

```mermaid theme={null}
flowchart TB
    subgraph Admin["Admin discovery credential (retained once)"]
        A1[Bootstrap verify: admin submits sample credential] --> A2[Credential retained]
        A2 --> A3[AddClient / periodic checker: refreshes the server's advertised tool list]
        A3 -->|credential dies| A4[needs_reauth projection]
        A4 -->|admin repairs| A2
    end
    subgraph User["End-user credential (per caller, lazy)"]
        U1[Caller's first tool call] -->|no credential yet| U2[mcp_auth_required + auth URL]
        U2 --> U3[User completes their own auth]
        U3 --> U4[Credential stored, keyed to identity]
        U4 --> U5[Every later call: resolved transparently]
    end
```

**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 `reauthorize` → `complete-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](./auth/overview#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.

<Warning>
  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.
</Warning>

***

## Recovery endpoints by combination

| Endpoint | Sticky server-level | Per-call server-level | Per-user |
| - | - | - | - |
| `POST /api/mcp/client/{id}/reconnect` | ✅ Re-dials the persistent connection | ❌ `400` — nothing to reconnect | ❌ `400` — nothing to reconnect |
| `POST /api/mcp/client/{id}/reauthorize` | ✅ Re-establishes a dead `oauth` credential | ✅ Same, for `oauth` | ✅ Re-establishes the *admin discovery* credential for `per_user_oauth` only |
| `POST /api/mcp/client/{id}/verify-headers` | — (`headers` has no expiring credential) | — | ✅ `per_user_headers` bootstrap + repair |
| `POST /api/mcp/client/{id}/verify-exchange` | — | — | ✅ `token_exchange` bootstrap + repair |
| `POST /api/mcp/client/{id}/initiate-verification` | ✅ `oauth` clients still in `pending_verification` (config.json bootstrap) | ✅ Same | ✅ `per_user_oauth` bootstrap (first step of the two-step OAuth dance) |
| `POST /api/mcp/client/{oauth_config_id}/complete-oauth` | ✅ Completes the bootstrap or reauthorize OAuth dance | ✅ Same | ✅ Completes `per_user_oauth` bootstrap or admin-credential repair |
| `PUT /api/mcp/client/{id}` (disable/enable, non-credential edits) | ✅ | ✅ | ✅ |

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

* [Session Stickiness](./connecting-to-servers#session-stickiness-http-only) — how to set `needs_session_stickiness`
* [MCP Authentication](./auth/overview) — which auth type to pick
* [MCP Gateway](./overview#bifrost-as-an-mcp-gateway) — reconnection behavior, dynamic tool discovery, health monitoring


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.