Skip to main content

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.

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

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:

ValueProxied over this endpoint
streamable_httpyes — the default
sseyes, legacy
websocketno; 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.