> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# MCP OAuth Grants and Auth Sessions

> How Rolter records user consent for MCP servers, what a session holds, why revoking a grant revokes its sessions, and who can see which rows.

When an MCP server needs to act on a user's behalf — read their repositories, file their tickets — that user completes an OAuth flow once. Rolter records the result in two places: a **grant**, which is the consent itself, and one or more **sessions**, which are the tokens minted under that consent. The dashboard exposes both under **MCP Gateway → OAuth Grants** and **MCP Gateway → Auth Sessions**.

## No token material leaves the control plane

Access and refresh tokens are sealed with the deployment key encryption key and stored alongside the session row. Only credential-bearing internal code can open them. The control plane projects the newest live, scope-valid access token per user/server through its separately token-guarded internal snapshot channel; no public API response, and therefore no dashboard screen, ever carries a token value.

What you see instead is metadata: when the session was created, when it expires, when it was last used, and a `has_refresh_token` flag rendered as a **Renewable** badge. That flag is enough to answer "will this keep working tomorrow?" without the token ever being serialised out of the control plane.

<Warning>
  If a screen or an integration ever appears to show you an MCP access token, that is a bug worth reporting — the API surface has no field to carry one.
</Warning>

## A grant is the unit of consent

A grant ties one user to one MCP server, with the scopes they agreed to. Sessions hang off the grant: a session can expire, be refreshed, or be revoked on its own without disturbing the consent behind it.

That asymmetry is deliberate, and it decides which action you want:

<CardGroup cols={2}>
  <Card title="Revoke a session" icon="key">
    Kills one set of tokens. The consent stands, so a fresh session can be minted for the same user and server without asking them again. Use this to boot a leaked or stale token.
  </Card>

  <Card title="Revoke a grant" icon="shield-off">
    Withdraws the consent **and** revokes every session under it, in a single database transaction. The user must go through the OAuth flow again before that server can act for them. Use this when access should end, not merely restart.
  </Card>
</CardGroup>

Because the cascade is the point, the dashboard names it before you commit: the confirmation on a grant tells you how many live sessions will be revoked along with it. Withdrawn consent can never leave a live token behind — the grant and its sessions are revoked together or not at all.

Revoked grants are not deleted. The row stays with its `revoked_at` and `revoked_by` fields set, so the audit trail of who consented, and who withdrew it, survives.

## Filtering and revoking in bulk

Both screens filter by server and by owner, and a **Live only** toggle hides revoked grants and expired or revoked sessions. When a leaver's access has to end, pick their address in the owner filter and use **Revoke all shown**. The action appears once a server or an owner is picked, so one click cannot take the whole org. Its confirmation names the owner (or server) and the count, and on a grant it states how many live sessions go with them. Rows are revoked one by one: if some fail, the rest still go, and the dialog says how many did not.

## Who sees which rows

Both listings are scoped by ownership, and the dashboard says so on the page rather than implying the table is the whole org:

<Steps>
  <Step title="Org admins">
    See every grant and every session in the org, and may revoke any of them.
  </Step>
  <Step title="Members and viewers">
    See only the grants and sessions they own, and may revoke only those.
  </Step>
  <Step title="Everyone else">
    Gets a `403`. Every listing is joined through the org, so a caller without a membership cannot read another tenant's rows even by guessing an id.
  </Step>
</Steps>

Both screens surface that `403` as a plain message rather than an empty table, so "nothing here" is never confused with "not allowed".

## Granting consent

Before a user can consent, an org admin registers the OAuth client Rolter
presents to the server's authorization server. The dashboard does this on
**MCP Gateway → MCP Catalog**: open **Configure** on the server and fill in the
**OAuth client** section.

<Steps>
  <Step title="Register the client">
    The client ID is the only field that is always required. Most MCP servers
    publish where their authorization server lives, and Rolter discovers the
    authorization and token URLs from that; enter them yourself only for a
    server that publishes nothing. When you do, both travel together — one of
    them is not a fallback — and both must be `https`, with `http` accepted
    only on loopback so a local stub still works.

    **Endpoint discovery** picks the mode. **Discover automatically** treats the
    two URLs as an optional fallback; **Configure manually** never probes the
    server and makes both URLs required. Re-saving the form keeps whichever
    mode the server already has.

  </Step>
  <Step title="Pin the issuer (optional)">
    **Issuer** is the authorization server's issuer identifier. Pinning it
    makes RFC 9207 validation strict: a callback whose `iss` does not match is
    refused. It matters most for a hand-configured server — with no discovery
    metadata and no pinned issuer, an authorization response that carries `iss`
    has nothing authentic to be checked against and is refused.
  </Step>
  <Step title="Check what discovery found">
    Under automatic discovery, **Discovered endpoints** shows the issuer,
    authorization URL and token URL the last successful discovery resolved,
    when it ran, and whether the server sends `iss` in its callbacks. It is
    read-only and empty until someone first connects, because only an
    interactive **Connect** probes the server. Changing the server URL, the
    issuer or the mode clears it, and the form warns you before you save.
  </Step>
  <Step title="Copy the redirect URI">
    The **Redirect URI** field is filled in for you from the deployment's own
    public URL. Register that exact callback with the authorization server;
    Rolter never takes it from the request, so a spoofed `Host` header cannot
    send an authorization code somewhere else.
  </Step>
  <Step title="Set the client secret">
    The secret is write-only. A badge says whether one is stored, the field is
    never pre-filled, and leaving it blank keeps the stored secret unchanged —
    so saving the form for an unrelated reason cannot wipe a credential you
    were not rotating. **Clear stored secret** removes it and downgrades the
    client to a public one.
  </Step>
  <Step title="Connect">
    **Connect** on the server card starts consent and opens the authorization
    server in a new tab. Finish the approval there. That tab then lands on
    **Auth Sessions**, which says whether the connection worked, and the card
    you started from shows **Connected until** the session's expiry without a
    reload. Only servers set to **OAuth consent** offer the action. The action stays
    disabled until a client ID is registered — and, for a server set to manual
    discovery, until both endpoints are too — because until then there is
    nowhere to send the user.
  </Step>
</Steps>

<Note>
  Consent belongs to a person. Connect signs in as *you*, so the grant is owned
  by the account you are signed in with. A user may connect a server in an
  organization they hold a role in, and a signed-in **superadmin** may connect
  any server as themselves, even one in an organization they hold no role in. The
  grant is theirs: they see it, and revoke it, like any owner. The admin token
  and open mode (no `ROLTER_ADMIN_TOKEN`) are not a person, so a consent started
  with either is refused with a `400` that says so. The grant would belong to
  nobody, and no one could see or revoke it. Sign in as an account to connect.
</Note>

## Where consent ends

When the authorization server sends you back, the tab lands on **Auth
Sessions**. On success it names the server and highlights the new session. On a
failure it says what went wrong in the dashboard's own words and links back to
the MCP Catalog. The authorization server's own error text is never shown: a
response whose issuer did not check out may not be displayed at all, and the
rest is summarised rather than echoed onto the page.

| Reason                  | What happened                                                                      | What to do                                          |
| ----------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------- |
| `access_denied`         | The authorization server says consent was declined                                 | Connect again and approve the requested access      |
| `authorization_failed`  | It answered with some other error                                                  | Check the server's OAuth client, then connect again |
| `state_invalid`         | The approval link expired, was already used, or arrived incomplete                 | Connect again to get a new link                     |
| `issuer_mismatch`       | The response did not come from the authorization server Rolter sent you to         | Pin or correct the issuer, then connect again       |
| `token_exchange_failed` | Consent was given, but the token endpoint refused the code or could not be reached | Check the client ID and secret                      |
| `not_configured`        | The control plane has no `ROLTER_KEK`, or no OAuth client resolves for the server  | Ask an operator to fix the deployment               |
| `internal_error`        | The control plane failed while storing the consent                                 | Try again; the control plane log names the cause    |

The control plane logs every failure with the full detail and the server it
concerns, so the reason on screen is a starting point rather than all there is.

An OAuth consent server's card states where you stand: **Not connected**,
**Connected until** a time, or **Session expired** (the action then reads
**Reconnect**). That is a live session of yours, meaning an active grant of
yours with a session under it that is neither revoked nor expired. An org admin
sees their own sessions only, even though Auth Sessions lists everyone's. A card
says nothing when your role may not read the session listings. A server set to
OAuth consent with no client registered shows an `oauth, no client` badge and
the reason Connect is unavailable.

The same registration over the API, for a server whose authorization server is
discoverable:

```bash
curl -X PUT https://control.example.com/api/v1/mcp-servers/$SERVER_ID/oauth-client \
  -H "Authorization: Bearer $ROLTER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "client_id": "Iv1.abc123",
        "client_secret": "…",
        "default_scopes": ["repo", "read:org"]
      }'
```

And for one that publishes no metadata, with the endpoints as the fallback:

```bash
curl -X PUT https://control.example.com/api/v1/mcp-servers/$SERVER_ID/oauth-client \
  -H "Authorization: Bearer $ROLTER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "authorize_url": "https://github.com/login/oauth/authorize",
        "token_url": "https://github.com/login/oauth/access_token",
        "issuer": "https://github.com",
        "discovery": "manual",
        "client_id": "Iv1.abc123",
        "client_secret": "…",
        "default_scopes": ["repo", "read:org"]
      }'
```

`discovery` is `auto` by default, which tries discovery on every consent and
falls back to whatever you configured. Setting it to `manual` skips the probe
altogether, which is worth doing for a server you know publishes nothing.

Reading the same endpoint back returns what the last successful discovery
resolved, so a misconfigured authorization server is visible without digging
through logs:

| Field                                              | Meaning                                                                |
| -------------------------------------------------- | ---------------------------------------------------------------------- |
| `discovered_issuer`                                | the issuer the metadata named                                          |
| `discovered_authorize_url`, `discovered_token_url` | the endpoints it named                                                 |
| `discovered_iss_supported`                         | whether it advertised `authorization_response_iss_parameter_supported` |
| `discovered_at`                                    | when the cache was last filled, or absent if never                     |

`discovered_iss_supported` is the one worth knowing about: when it is `true`, a
callback that arrives **without** an `iss` parameter is rejected rather than
accepted, per RFC 9207 §2.4. If consent is failing on a server that used to
work, this field tells you which rule is being applied.

<Note>
  The server row and the server list spell these `oauth_discovered_*`; this
  endpoint drops the prefix. Same values, two names.
</Note>

This endpoint is admin-only in both directions — even reading it, because the
row names a third party the organization has chosen to trust. The secret is
sealed with the deployment key on write and is never readable back; omitting
`client_secret` keeps the stored one, and sending an empty secret downgrades the
client to a public one.

Only a request that actually **stores** a secret needs `ROLTER_KEK`. Correcting a
`token_url`, rotating the issuer, changing `default_scopes` or clearing the
secret all work on a deployment without it, so losing the key never leaves a
misconfigured client uncorrectable.

## Discovery, audience binding and issuer checks

Rolter's MCP client follows the current MCP authorization specification
(revision `draft`, tracked as of 2026-09-08). Three of its requirements change
what you see when something goes wrong:

<AccordionGroup>
  <Accordion title="The authorization server is discovered, not typed">
    Rolter asks the MCP server where its authorization server is — from the
    `WWW-Authenticate` challenge on an unauthenticated request, or from the
    `/.well-known/oauth-protected-resource` document — and validates that the
    document is really about that server before using it. What it resolves is
    remembered, so renewals never re-probe.

    Changing the server's `url`, its pinned `issuer`, or its `discovery` mode
    forgets it again, since the remembered endpoints belong to the
    authorization server the old value named. The next consent re-discovers;
    a renewal in between fails rather than reaching for the previous server.
    An edit that touches none of those three fields — rotating the client
    secret, say — leaves what was already discovered in place.

  </Accordion>

  <Accordion title="Every token is bound to one server">
    Both the authorization request and the token request carry a `resource`
    parameter naming the MCP server the token is for, so the token cannot be
    replayed at a different server. The value is the server's URL in canonical
    form: no fragment, no trailing slash. It is shown back to you on the OAuth
    client response as `resource`.

    An authorization server that does not understand the parameter ignores it;
    one that does will bind the token. Rolter sends it either way, as the
    specification requires.

  </Accordion>

  <Accordion title="The response has to come back from where it was sent">
    If the authorization server returns an `iss` parameter, Rolter compares it
    to the issuer it recorded when it sent you there — as an exact string, with
    no normalisation. A mismatch fails the consent and the authorization code is
    discarded rather than exchanged, and the server's own error text is not
    shown, because a response that failed this check is not one to act on.

    A hand-configured server with no `issuer` set has nothing to compare
    against. If its authorization server returns an `iss`, consent fails with a
    message asking you to pin `issuer` — that is deliberate, since accepting an
    unverifiable issuer would be a check in name only.

  </Accordion>
</AccordionGroup>

Connect calls `POST /api/v1/mcp-servers/{id}/oauth/authorize`, which answers
with the authorization URL rather than a redirect — the caller is a `fetch`
from the dashboard, which cannot usefully follow a cross-origin `302`. After
the user approves, the browser returns to `/auth/mcp/callback`, Rolter writes
the grant and its first session together, and the browser is sent on to
**Auth Sessions** (see [Where consent ends](#where-consent-ends)). A replayed
callback finds nothing to consume and fails.

## Sessions renew themselves

Rolter renews a session about five minutes before it expires, so a user
consents once rather than every hour. Refresh-token rotation is handled: if
the authorization server hands back a new refresh token, that is what gets
stored.

**Auth Sessions** also offers a renew action on each row for when you do not
want to wait for the sweeper — after widening scopes upstream, say. Only a
session that stored a refresh token can be renewed, so the action is disabled
on the rest rather than failing.

If the authorization server _refuses_ the refresh with `invalid_grant` or
`invalid_scope`, Rolter revokes the session instead of retrying — that answer
means consent is gone, and the user simply consents again. A renew that fails
in the dashboard usually means exactly this: the session is already gone, and
the fix is to Connect again rather than to retry. Network blips and
`5xx` responses are left for the next sweep.

On your own rows, a session that is expired, revoked or has no refresh token
shows a **Reconnect** action. It starts consent for that server exactly as
Connect on the MCP Catalog does, in a new tab, so you do not have to leave
Auth Sessions to find the way back. Other users' rows keep revoke only: a
consent is only ever given by the person it belongs to.

Which status a failed renew answers tells you whose problem it is.
`POST /api/v1/mcp/sessions/{id}/refresh` (and the exchange below) answer `400`
when the authorization server refused, `502` with code
`authorization_server_unavailable` when it could not be reached or did not
answer, and `500` when Rolter's own storage failed. The `502` and the `500`
carry one fixed sentence: the token endpoint's address and the underlying error
are in the control plane's log, and the session is left alone to be retried.

A service that needs to call a downstream system on the user's behalf can
exchange a live session for a narrower one with
`POST /api/v1/mcp/sessions/{id}/exchange`. The new session descends from the
same grant, can be revoked on its own, and can never carry more scopes than
the consent it came from.

## Calling a server through Rolter

Use your normal user-owned virtual key against the gateway path. The server
slug is scoped to the key's organization:

```bash
curl -N https://gateway.example.com/mcp/github \
  -H "Authorization: Bearer $ROLTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Before contacting the server, Rolter verifies that the key has a user owner,
the server belongs to the same organization, the owner has a live consent and
session, and the session covers every scope configured on the server. The
virtual key is replaced with the downstream OAuth bearer token. Streamable
HTTP and SSE responses stay streamed; stdio and WebSocket transports are not
served by this HTTP path.

Org admins configure the baseline with `PATCH /api/v1/mcp-servers/{id}` and a
`{"required_scopes": [...]}` body. Tightening the list immediately invalidates
sessions that do not cover it; no grant or session needs to be deleted merely
to change policy.

## Audit

Every revocation is written to the audit log as `mcp_oauth_grant.revoke` or `mcp_oauth_session.revoke`, with the acting principal, the org, and the affected user. See [Audit Logs](/security/rbac) for how audit entries are scoped and queried.
