OAuth for MCP clients
MCP clients sign in to the MCP server 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.
As Markdown: api/oauth.md