# Sign-in for developers

How an AI app signs in: standard OAuth 2.1, found by discovery, with PKCE and dynamic registration.

A compliant MCP client needs nothing special. This page is for writing one, or finding why one fails.

## The flow

1. **AI app → GoRunner** Calls the MCP server with no token, and gets 401 with where to lookPOST /mcp/build
2. **AI app → GoRunner** Reads the server’s metadata, then the sign-in server’sGET /.well-known/oauth-protected-resource/mcp/build
3. **AI app → GoRunner** Registers itself: a public client, no secretPOST /oauth/register
4. **AI app → Browser** Opens the sign-in page with a PKCE challengeGET /oauth/authorize
5. **Browser → GoRunner** The person signs in and presses Allow access
6. **GoRunner → Browser** Sends the browser back to the app’s callbackcode, state, iss
7. **AI app → GoRunner** Trades the code and its verifier for tokensPOST /oauth/token
8. **AI app → GoRunner** Calls the MCP server with the access tokenAuthorization: Bearer gra_…

## Discovery

A call without a token answers 401. Its header points at the metadata, and names the scopes the server’s tools use:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="GoRunner",
  resource_metadata="…/.well-known/oauth-protected-resource/mcp/build",
  scope="workflow:read connection:read run:read workflow:write …"
```

- **Protected resource (RFC 9728)**: `/.well-known/oauth-protected-resource`, then the server’s path: `/mcp/build`, `/mcp/tools` or a workspace’s own address
- **Authorization server (RFC 8414)**: `/.well-known/oauth-authorization-server`

For `/mcp/build`, `scope` lists the six permissions of **Draft, run and publish**. For `/mcp/tools`, it lists `workflow:read`, `run:read` and `workflow:run`. Both documents are at your installation’s address, `https://api.gorunner.io` on Cloud, which is also the issuer.

## Registration

Registration is open and makes a public client, with no secret.

```bash
curl -s -X POST "https://api.gorunner.io/oauth/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name": "My agent",
       "redirect_uris": ["http://127.0.0.1/callback"]}'
```

```text
{
  "client_id": "grc_69866495cb3c0a69d24c77a5aeb3068620b922cd",
  "client_id_issued_at": 1761412345,
  "client_name": "My agent",
  "redirect_uris": ["http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

- **Callbacks**: 1 to 10. One that isn’t allowed is dropped, and the answer lists the ones kept. If none is left, registration fails.
- **Same app again**: The same name, callbacks, logo and site get the same client back: one made in the last 12 hours, or one that already has a connection.
- **Limit**: 20 new registrations a day from one address that never lead to a connection.
- **Logo and site**: A `logo_uri` or `client_uri` you send comes back in the answer, if it is a web address.

## Callbacks

[Since v2026.10.22](https://docs.gorunner.io/changelog/v2026.10.22)

- **https**: Any host.
- **http**: Only `localhost`, `127.0.0.1` or `[::1]`. The port may change between sign-ins; the path may not.
- **An app’s own**: Cursor’s `cursor://anysphere.cursor-mcp/oauth/callback`, and Raycast’s `raycast://mcp-oauth-callback` or `com.raycast:/oauth`. Any other app uses a reverse-DNS form, such as `com.example.app:/callback`. A self-hosted installation allows more exact app callbacks with `OAUTH_EXTRA_REDIRECT_URIS`: whole addresses, no query.
- **Never**: A query on an app’s callback, a fragment, or a user name in the address.

## Authorize and token

- **PKCE**: S256 only. `plain` is refused.
- **Scopes**: Any of the ten permissions, and `offline_access`. None asked for means read only. `openid`, `profile` and `email` are ignored. The person’s choice and role narrow them.
- `resource`: `/mcp/build`, `/mcp/tools`, or an address under one. Left out, the token works on both. Anything else is `invalid_target`.
- `iss`: On every redirect back, success or error. Since v2026.10.22.
- **The token request**: `application/x-www-form-urlencoded`, with `grant_type=authorization_code`, `code`, `code_verifier` and `client_id`. A `redirect_uri`, if sent, must match the authorize request.

## Tokens

| Credential | Lasts | Notes |
| --- | --- | --- |
| Authorization code | 60 seconds | Once. |
| Access token `gra_…` | 1 hour | Works only on the MCP server it was made for. The API refuses it with `401`. |
| Refresh token `grr_…` | 60 days | A new one with every use. The old one works 30 seconds more; using it after that ends the whole connection. |
| The connection | 180 days | Then the person signs in again. Disconnecting ends it at once. |

A registered client always gets a refresh token, with or without `offline_access`. Since v2026.10.22.

To disconnect, post `token` as `application/x-www-form-urlencoded` to `/oauth/revoke`. Either token ends the whole connection. The answer is `200` with no body, whatever you send, so it can't be used to test a token.

## When sign-in is refused

These answers are OAuth's own shape, `{"error": "…", "error_description": "…"}`, with `error` a string. The last two rows are in the API's shape instead.

| Where | Status | `error` | Why |
| --- | --- | --- | --- |
| `/oauth/token` | 400 | `invalid_request` | Not form-encoded, or `grant_type`, `code`, `code_verifier`, `client_id` or `refresh_token` is missing. |
| `/oauth/token` | 400 | `unsupported_grant_type` | Anything but `authorization_code` and `refresh_token`. |
| `/oauth/token` | 400 | `invalid_grant` | The code is unknown, expired or used; it was issued to another client; its `redirect_uri` or `code_verifier` doesn’t match. Or the refresh token is unknown or expired, or was used after its 30 seconds, or the person disconnected the app. |
| `/oauth/register` | 400 | `invalid_redirect_uri`, `invalid_client_metadata` | No callback that can be kept, more than 10, a client secret asked for, another grant, a body that isn’t JSON, or one over 16 KB. |
| `/oauth/register` | 429 | `invalid_request` | Over the limit of 20 unused registrations a day. |
| All three | 500 | `server_error` | Nothing was issued. Try again. |
| Discovery | 404 | `invalid_request` | No such protected resource. |
| `/oauth/token`, `/oauth/register`, `/oauth/revoke` | 429 | `temporarily_unavailable` | Over the limit below, with `Retry-After`. |
| `/oauth/token`, `/oauth/register`, `/oauth/revoke` | 405 | `invalid_request` | A wrong method. `Allow` says `POST`. |
| `/oauth/authorize`, discovery | 429, 405 | An object: `{"error": {"code": "RATE_LIMITED", "message": "…"}}` | Over the limit below, with `Retry-After`, or a wrong method. |
| All of them | 503 | An object: `{"error": {"code": "BUSY", "message": "…"}}` | The server holds as much request data as it can. Retry after `Retry-After`. |

A refresh token that answers `invalid_grant` will never work again. Stop retrying it, and start the sign-in from the beginning.

At `/oauth/authorize`, a bad `client_id` or `redirect_uri` shows the person a page with `400`, and nothing goes back to the app. After those two are known good, an error goes back to the callback as `error`, `error_description`, `state` and `iss`: `unsupported_response_type`, `invalid_request`, `invalid_target`, `invalid_scope`, `server_error`, or `access_denied` when the person declines. To an app’s own callback (`cursor://…`), it is a page with a button instead of an automatic redirect, and only for the exact callbacks listed above.

**Can a client running in a browser sign in?**

Yes. Discovery, registration, token and revoke answer `Access-Control-Allow-Origin: *`, never with credentials. The sign-in page itself is a normal page the browser opens. A self-hosted installation turns this off with `OAUTH_PUBLIC_CORS=off`.

The MCP servers themselves don’t allow other origins: a page can sign in, but its calls to `/mcp/build` have to go through a server of its own.

**How does it work in a product that embeds GoRunner?**

The same way, at the account’s own address: `/mcp/embed/<account>/build` or `/tools`. The person signs in through the product, not GoRunner. See [Let customers sign in AI apps](https://docs.gorunner.io/embed/ai-app-sign-in).

## Addresses

- `/mcp/build`: The build server: [Build tools](https://docs.gorunner.io/mcp/build-tools)
- `/mcp/tools`: Your published workflows as tools
- `/mcp/build/w/<workspace ID>`: One workspace’s own address. API tokens use it.
- `/mcp/tools/w/<workspace ID>`: One workspace’s tools. A credential for another workspace gets `403`.
- `/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke`: The sign-in server

One address may make 120 sign-in requests a minute, discovery included. A self-hosted installation changes it with `OAUTH_RATE_LIMIT`.

## How the servers answer

- **Sessions**: None. Each request stands alone and no `Mcp-Session-Id` is sent, so every request needs its token.
- `POST`: A request answers `200` with one JSON object, never a stream. A notification answers `202` with no body. `Accept` must allow both `application/json` and `text/event-stream`, or it is `400`. `Content-Type` must be `application/json`, or it is `415`.
- `GET`, `DELETE`: `405` with a working token, because there is no stream to open and no session to end. `401` without one, like every method.
- `401`: No token, or an expired or revoked one. It carries the `WWW-Authenticate` header above, with `error="invalid_token"` when a token was sent. Refresh, or sign in again.
- `MCP-Protocol-Version`: A version the server doesn’t know is `400`. Left out, the call still works.
- **A method the server doesn’t have**: A JSON-RPC error, `-32601`, on `200`. A tool that fails is `isError: true` in a `200`.

A `401` on `GET` is normal for a client without a working token. If they repeat with no sign-in after them, that client has lost its connection and needs someone to connect it again.

Related: [Choose what an AI app may do](https://docs.gorunner.io/mcp/permissions) · [Connect with an API token](https://docs.gorunner.io/mcp/api-token) · [Authentication](https://docs.gorunner.io/api/authentication) · [v2026.10.22](https://docs.gorunner.io/changelog/v2026.10.22)

Source: https://docs.gorunner.io/mcp/oauth
