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

> Proxy Streamable HTTP and SSE MCP servers with user-bound OAuth authorization.

The gateway exposes registered MCP servers at `/mcp/{server}`. Any suffix and
query are appended to the registered server URL, which supports MCP servers
that split messages or resources below one base endpoint.

## Authentication and authorization

Send a rolter virtual key as `Authorization: Bearer ...`. MCP calls require a
database-backed key minted by a user. Rolter resolves the server inside that
key's organization, selects the newest live OAuth session owned by the same
user, and verifies that its scopes cover the server's `required_scopes`.

The virtual key is stripped before forwarding and replaced with the session's
OAuth bearer. A missing owner, cross-organization server, expired or revoked
session, or insufficient scope fails before any downstream connection.

### Connection status and trying a tool

**Test connection** on a server's card (or
`POST /api/v1/mcp-servers/{id}/test`) runs the MCP handshake and tells you which
of these it was: **reachable** (with the protocol version the server negotiated
and its name), **unreachable** (nothing answered), **handshake failed** (it
answered, but not as an MCP server does), **auth rejected** (it answered
`401` or `403`), or that rolter's egress policy refused the address. The card
keeps the time it last answered and the time and reason of its last failure, even
after a later success.

**Try it** runs one tool from the server's list with arguments you supply
(`POST /api/v1/mcp-servers/{id}/tools/call`) and shows the raw JSON-RPC request
and reply. A tool that ran and reported an error is shown as **tool errored**,
separately from the server not answering. For a server behind OAuth both use
your own signed-in session, so connect first; both need permission to update the
server. The call goes from the control plane, not through the gateway, so it is
in the audit log (the tool and its outcome, not the arguments) and not in the MCP
logs.

### When an upstream asks for a wider scope

If the MCP server refuses a call because the user's token lacks a scope
(`WWW-Authenticate: Bearer error="insufficient_scope"`) and the server uses
OAuth, rolter answers `403` with the code `mcp_insufficient_scope`, the scopes
the server asked for in `error.required_scopes`, and a standard
`WWW-Authenticate` challenge naming them. The call is recorded in the MCP log as
`auth_denied`. Nothing is widened automatically: the key's owner has to consent
again, by calling `POST /api/v1/mcp-servers/{id}/oauth/authorize` with
`step_up_scopes` set to those scopes. The new consent keeps what they already
granted and adds the scopes, then the next call works. Until then every such
call fails the same way. A server with a static credential has no consent to
repeat, so its `403` is passed through unchanged.

In the dashboard, the server's card on **MCP → MCP Catalog** names the scopes the server declares that your connection lacks, with a **Reconnect with more access** button that sends that `step_up_scopes` request. The detail of an `auth_denied` call in **MCP Logs** offers the same to the key's owner. The log records the refusal and not the scope the upstream asked for, so when the connection already holds everything the server declares, the owner names the scope to add.

### Which scopes a consent asks for

Unless the request lists `scopes` itself, a consent asks for, in order of
preference: the `scope` the server names in its `WWW-Authenticate` challenge,
the server's configured default scopes, then the `scopes_supported` its metadata
advertises. The server's required scopes are always added to the first and last
of those.

## The consent callback

The live OAuth session above comes from a consent the user gives once. The
authorization server sends their browser back to `GET /auth/mcp/callback` on
the control plane, which answers by what the request accepts:

- a browser (`Accept: text/html`) gets a `303` to the dashboard's Auth Sessions
  screen: `consent=completed&session=<id>&server=<id>` on success, or
  `consent=failed&reason=<code>` (plus `server=<id>` once known) on a failure;
- any other caller gets JSON: `grant_id`, `session_id`, `server_id`, `scopes`,
  `expires_at` and `has_refresh_token` on success, or the usual error body.

The redirect carries identifiers only, never a code, a token or the
authorization server's own error text. The failure codes and what each one
means are listed under
[Where consent ends](/security/mcp-oauth#where-consent-ends).

## Transport behavior

`GET`, `POST`, and `DELETE` are forwarded for Streamable HTTP and legacy SSE
servers. Request body, query parameters, MCP protocol/session headers, response
status, response headers, and streaming body are preserved. Hop-by-hop and
client authorization headers are removed.

### When the server cannot be reached

A server that refuses the connection, resets it or does not answer within its
request timeout gives `502` with code `mcp_upstream_error` and the message
`MCP upstream request failed`. The message never names the server's address,
which often carries its token; the cause is in the gateway log, with the server
slug and the request id the response carries (in `x-request-id` unless the
deployment configures another header), and the MCP tool-call log records it as
a `timeout` or `transport_error`.

### Accepted values

A registered server's `transport`, and an organization's
`default_transport`, must be one of:

| Value             | Proxied over this endpoint                                                                    |
| ----------------- | --------------------------------------------------------------------------------------------- |
| `streamable_http` | yes — the default                                                                             |
| `sse`             | yes, legacy                                                                                   |
| `websocket`       | no; accepted in the registry, rejected with `mcp_transport_unsupported` on this HTTP endpoint |

`stdio` is **not accepted**. It names a local subprocess, which a hosted
control plane cannot dial, so registering one now fails validation rather than
storing a server nothing could reach. Any stdio server registered before this
was disabled in place, keeping its OAuth grants and sessions intact; re-point
it at an HTTP endpoint to bring it back.

One list is authoritative — `rolter_core::MCP_TRANSPORTS` — and the database
check constraints, the API validation and the dashboard dropdown all match it.

`websocket` stays accepted although this gateway never proxies it: dropping it
would invalidate registry rows already stored under it. The same list is what
the tool-call log ingest route accepts, so `websocket` is **ingest-only** — only
an external producer can submit a log row with it; every row the gateway itself
writes is `streamable_http` or `sse`.

## Manage the registry

The dashboard's MCP Catalog reads the organization registry directly. An admin
can register a custom HTTP endpoint, declare its tool names and OAuth scopes,
or install a reviewed definition from MCP Library. Turning a server off removes
it from the next gateway snapshot; its grants and sessions remain stored until
the server is deleted.

### Tool discovery

A registered server's tool list comes from the server itself. rolter asks it
with `tools/list` in the background after you install it, after you set its
credential, and after a user completes OAuth consent; **Discover now** on the
card (or `POST /api/v1/mcp-servers/{id}/discover`) asks again. The list a
catalog entry ships with is only the fallback until the server has answered.
Discovery, the connection status check and **Try it** follow the server's
transport: a `streamable_http` server is asked over `POST`, and a legacy `sse`
server over its own handshake (open the event stream, wait for the `endpoint`
event, `POST` there and read the replies off the stream). The endpoint the server
names must be on the address you registered; one on another host fails the
handshake.

A server that is down never blocks the install: the card says when it was last
discovered, or that it has not been discovered yet and why. A tool the server
stops returning is kept and marked stale rather than removed, so a Tool Group
that names it keeps working until you decide otherwise. A server behind OAuth
is asked with the signed-in user's own session, so it waits until that user has
connected.

Tool Groups bundle exact `server/tool` references into named policy manifests,
and the proxy enforces them. A server that at least one enabled group names
serves only the tools those groups list: a `tools/call` for any other tool is
refused with `403` and the code `mcp_tool_denied` before anything is forwarded,
and the refusal is recorded in the MCP log as an `auth_denied` row naming the
tool. A server no group names is not narrowed by groups. By default the groups an
organization enables apply to every caller in it. A group can also be assigned to
teams, projects or single virtual keys (**Configure** on the group, or
`PUT /api/v1/mcp/tool-groups/{id}/assignments` with
`{"assignments":[{"kind":"team","id":"…"}]}`, where `kind` is `team`,
`project` or `virtual_key`; `GET /api/v1/orgs/{org_id}/mcp/tool-group-assignments`
lists them). An assignment only ever narrows: a caller whose key, project or team
is named by the assignments of any enabled group is held to the union of the
groups assigned to those three, so on a server the organization's groups govern
it may call only what its own groups list there, and nothing when none of them
names the server. A caller none of the assignments name keeps the
organization-wide set, exactly as before, so a deployment that never assigns a
group behaves as it did. A group you disable keeps its assignments: the keys,
projects and teams assigned to it stay narrowed and get none of its tools on the
servers it names, even when it is the only group that names one. They do not fall
back to the organization's groups or to the whole server; everyone else is
unaffected. Deleting a group removes its
assignments with it, and a team, project or key that was assigned only to that
group returns to the organization-wide set. A new group can be created with its
`assignments` in the same request. The dashboard edits team and project assignments;
virtual-key assignments are set through the API and kept as they are when a group
is saved there. Batches are refused as a whole when any call in
them is refused, and while a group governs a server a request body that cannot
be read as JSON-RPC is refused with `400` (`mcp_request_unreadable`) rather than
forwarded unchecked. A body that repeats a key in a message or in its `params`
(two `name` entries, or `name` and `NAME`, since some servers match keys without
regard to case) is refused with `403` `mcp_tool_denied` whatever the
failure policy says, and is logged like any other refused call, because parsers disagree about which copy wins and the
tool judged could then differ from the tool that runs. A `tools/list` reply through the proxy is narrowed by the same rules, so a client
is shown only the tools it may call, whatever id the request carried. That covers
the reply to the `POST` that asked and the event stream a legacy `sse` server
sends its replies on; a paginated listing is filtered page by page and a page can
come back shorter than the server's page size. Disabling or
deleting a group lifts its restriction on the
next snapshot.

MCP Settings holds organization defaults, and the proxy applies them on every
request once they are saved:

- **Timeouts and retries.** `connect_timeout_ms`, `request_timeout_ms` and
  `max_retries` apply to a server that has no override of its own. Precedence,
  highest first: the server's override, these settings, then the deployment's
  `[timeouts]` default. An organization that never saved settings keeps the
  deployment default. A retry only ever repeats a connection that was never
  made; a call that reached the server is not repeated.
- **Undeclared tools.** Once an organization saves its MCP settings with
  `allow_unlisted_tools` off, a server that declares tools (discovered or
  typed) serves only those, and a call naming a tool outside the manifest is
  refused with `403` `mcp_tool_denied`. An organization that never saved its
  settings allows unlisted tools if it existed before manifests were
  enforced, so an upgrade does not start refusing calls; an organization created
  since then enforces manifests until it saves settings with `allow_unlisted_tools`
  on, which `GET /api/v1/orgs/{org_id}/mcp/settings` reports before anything is
  saved. A server that declares no tools has nothing to check against.
- **Failure policy.** `default_failure_mode` decides what happens to a request
  the proxy cannot read as JSON-RPC, and so cannot check against the rules
  above: `fail_closed` (the default) refuses it with `400`, `fail_open` forwards
  it. An upstream that does not answer is always a `502`; a gateway cannot fail
  open on a server that is down.
- **Transport.** A server row always names its transport, so the organization's
  `default_transport` is what a server registered without one gets, at
  registration.
