# OAuth for MCP clients

MCP clients sign in to the [MCP server](../apps/mcp.md) with OAuth 2.1: the authorisation code grant with PKCE, dynamic client registration, and refresh tokens that rotate on every use. Clients are public: none gets a secret. A member approves each client on the dashboard's consent screen.

These endpoints answer errors in OAuth's own format, `{ "error", "error_description" }` with status 400 (401 for `invalid_client`), not the API's error body, because that is what OAuth clients read.

## Discovery

| Endpoint | Returns |
| --- | --- |
| `GET /.well-known/oauth-protected-resource/mcp` (also without `/mcp`) | Protected resource metadata (RFC 9728): `resource` is `https://api.flatstack.dev/mcp`, `authorization_servers`, `scopes_supported`. |
| `GET /.well-known/oauth-authorization-server` | Authorisation server metadata (RFC 8414): the endpoints below, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none"]`. |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| `POST /oauth/register` | Dynamic client registration (RFC 7591). JSON with `redirect_uris` (1 to 10) and optionally `client_name`, `client_uri`, `software_id`, `software_version`, `grant_types` (`authorization_code`, `refresh_token`). Answers `201` with `client_id`; `token_endpoint_auth_method` is always `none`. Redirect URIs must be https, http on `localhost`, `127.0.0.1` or `[::1]` (any port matches at sign-in), or an app's own scheme such as `cursor://`; `javascript:`, `data:`, `file:` and similar are refused with `invalid_redirect_uri`. |
| `GET /oauth/authorize` | `response_type=code`, `client_id`, `redirect_uri`, `code_challenge` with `code_challenge_method=S256`, and optionally `scope` (space separated; all of them if left out), `state` and `resource` (must be the MCP endpoint). Sends the browser to the dashboard's consent screen. An unknown client or unregistered redirect URI sends it to the dashboard to explain; any other problem goes back to the client's redirect URI with `error`, `state` and `iss`. |
| `POST /oauth/token` | Form-encoded (JSON also works). `grant_type=authorization_code` with `code`, `code_verifier`, `client_id`, `redirect_uri` and optionally `resource`; or `grant_type=refresh_token` with `refresh_token`, `client_id` and optionally a narrower `scope`. Returns `{ access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }`. |
| `POST /oauth/revoke` | Token revocation (RFC 7009): `token` and `client_id`. A refresh token disconnects the client; an access token ends itself. Always `200`. |

Codes last 60 seconds and work once. Access tokens (`fs_oat_…`) last an hour and work only at `/mcp`. Refresh tokens (`fs_ort_…`) rotate on every use; presenting one that has already been used revokes the whole connection, since it means the token was copied. A connection nobody refreshes for 90 days lapses. Only hashes of codes and tokens are stored.

The scopes are the token scopes: `projects:read`, `projects:write`, `deploy`, `env:read`, `env:write`, `logs:read`, `data:read`, `data:write`. A client gets the ones the member ticked and their role allows, and never more than the role allows at the time of each call.

## Consent and connected apps

The dashboard uses these; they need a signed-in member, not a token.

| Endpoint | What it does |
| --- | --- |
| `GET /v1/oauth/requests/:requestId` | The pending request: `{ requestId, client: { id, name, uri }, redirectsTo, scopes: [{ scope, label, allowed }], expiresAt }`. `allowed` is false for scopes the member's role doesn't have. Requests last 10 minutes; an expired or answered one is `404 not_found`. |
| `POST /v1/oauth/requests/:requestId/decision` | `{ "decision": "approve" \| "deny", "scopes"? }`. Returns `{ redirectTo }`, the client's redirect URI with `code` (or `error=access_denied`), `state` and `iss`. Approving with no scope left is `400 validation_failed`. |
| `GET /v1/oauth/grants` | Connected apps: `{ grants: [{ id, client, memberId, scopes, createdAt, lastUsedAt, expiresAt }], mcpUrl }`, where `mcpUrl` is the address to connect new ones to. Members see their own; owners and admins see everyone's. |
| `DELETE /v1/oauth/grants/:grantId` | Disconnects an app and ends its tokens. `204`. Members disconnect their own; owners and admins anyone's. |

Approving and denying are recorded in the audit log as `mcp.approved` and `mcp.denied`, disconnecting as `mcp.disconnected`. Changes a client makes through MCP tools are recorded by the routes they call, with the client's access token as the actor.