# Authentication for MCP clients

imsu.io's MCP server at `https://imsu.io/mcp` is protected by an OAuth 2.1 authorization server at `https://imsu.io`. This page is for people who build MCP clients, and for agents that want to know what their client does. If you only want to connect a client, see [MCP server](https://imsu.io/docs/mcp); MCP clients do all of this for you.

What imsu.io supports, in short:

- The MCP authorization spec (revisions 2025-06-18, 2025-11-25 and 2026-07-28): a `401` with `resource_metadata`, Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414).
- The authorization code grant with PKCE (S256 only) and refresh tokens. Public clients only: there are no client secrets.
- Clients identify themselves with a Client ID Metadata Document (an HTTPS URL as `client_id`) or register with Dynamic Client Registration (RFC 7591).
- Resource indicators (RFC 8707): tokens are issued for `https://imsu.io/mcp` only.
- `iss` in the authorization response (RFC 9207).
- No implicit flow, password grant, `plain` PKCE, client secrets, JWT access tokens or OpenID Connect.

Scripts and headless agents don't need any of this: they can send an API key ([REST API](https://imsu.io/docs/api#api-keys)), which `/mcp` accepts too.

## Metadata URLs

| Document | URL |
|---|---|
| Protected Resource Metadata of the MCP server | `https://imsu.io/.well-known/oauth-protected-resource/mcp` |
| The same, for clients that look at the root | `https://imsu.io/.well-known/oauth-protected-resource` |
| Authorization Server Metadata | `https://imsu.io/.well-known/oauth-authorization-server` |

They need no credential, may be read from any origin, and are cacheable for an hour (`Cache-Control: public, max-age=3600`). There is no `openid-configuration`.

## The flow, request by request

### 1. Call the MCP server without a token

```http
POST /mcp HTTP/1.1
Host: imsu.io
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}
```

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://imsu.io/.well-known/oauth-protected-resource/mcp", scope="monitors:read monitors:write"
Content-Type: application/problem+json
```

The body is described in [MCP server](https://imsu.io/docs/mcp#authentication).

### 2. Read the protected resource metadata

```http
GET /.well-known/oauth-protected-resource/mcp HTTP/1.1
Host: imsu.io
```

```json
{
  "resource": "https://imsu.io/mcp",
  "authorization_servers": ["https://imsu.io"],
  "scopes_supported": ["monitors:read", "monitors:write"],
  "bearer_methods_supported": ["header"],
  "resource_name": "imsu.io",
  "resource_documentation": "https://imsu.io/docs/mcp"
}
```

### 3. Read the authorization server metadata

```http
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: imsu.io
```

```json
{
  "issuer": "https://imsu.io",
  "authorization_endpoint": "https://imsu.io/oauth/authorize",
  "token_endpoint": "https://imsu.io/oauth/token",
  "registration_endpoint": "https://imsu.io/oauth/register",
  "revocation_endpoint": "https://imsu.io/oauth/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "revocation_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["monitors:read", "monitors:write"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true,
  "service_documentation": "https://imsu.io/docs/authentication"
}
```

### 4. Get a client id

Use the URL of your [client metadata document](#client-id-metadata-documents) as `client_id`, or register. Registering a native app that listens on a loopback port:

```http
POST /oauth/register HTTP/1.1
Host: imsu.io
Content-Type: application/json

{"client_name":"My agent","redirect_uris":["http://127.0.0.1:33418/callback"]}
```

```http
HTTP/1.1 201 Created
Cache-Control: no-store
Content-Type: application/json; charset=utf-8
```

```json
{
  "client_id": "imsu_client_6nYmKDC0DnLQnAtPUhFKCM",
  "client_id_issued_at": 1794337800,
  "client_name": "My agent",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

### 5. Make a PKCE pair

The `code_verifier` is 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`, e.g. 32 random bytes in base64url. The `code_challenge` is `BASE64URL(SHA-256(code_verifier))` without padding. The pair from RFC 7636:

```text
code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
```

### 6. Send the user's browser to the authorization endpoint

```text
https://imsu.io/oauth/authorize?response_type=code&client_id=imsu_client_6nYmKDC0DnLQnAtPUhFKCM&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=monitors%3Aread%20monitors%3Awrite&state=af0ifjsldkj&resource=https%3A%2F%2Fimsu.io%2Fmcp
```

| Parameter | |
|---|---|
| `response_type` | Required: `code` |
| `client_id` | Required: a registered id or a metadata document URL |
| `redirect_uri` | Required: one of the client's redirect URIs ([how they match](#redirect-uris)) |
| `code_challenge` | Required: the S256 challenge |
| `code_challenge_method` | Required: `S256` |
| `scope` | Optional: `monitors:read`, `monitors:write` or both, space-separated ([scopes](#scopes)) |
| `state` | Optional; sent back unchanged. PKCE already ties the answer to the request |
| `resource` | Optional; if sent, `https://imsu.io/mcp` |

Each parameter may be sent once (`resource` may repeat with the same value). imsu.io checks the request and sends the browser to its consent page, inside a sealed handle that is valid for 10 minutes:

```http
HTTP/1.1 302 Found
Location: https://imsu.io/authorize?request=CfDJ8EMIfNIR89tLlY866dBoCic4aI0Fq2yp95Dzogt9H3O…
```

If the request is wrong, see [errors at the authorization endpoint](#errors-at-the-authorization-endpoint).

### 7. The user signs in and allows access

If the user isn't signed in, imsu.io asks them to sign in (or sign up) first and then brings them back. The consent page names the app, says where the browser will return to, and lists what the app may do:

- See your monitors and their status (`monitors:read`, always included)
- Create, change, pause and delete monitors (`monitors:write`, if asked for; the user may untick it)

Apps registered with Dynamic Client Registration are marked "imsu.io hasn't verified this app", because anyone can register an app under any name. Apps known by their metadata URL show that URL's host instead. No logos are shown.

When the user clicks **Allow**, the browser goes back to the client with a single-use code:

```text
http://127.0.0.1:33418/callback?code=imsu_ac_MoogF2zjfJ7JQKXxgg3YtY97erq1xcrkmUw02JEchmj&state=af0ifjsldkj&iss=https%3A%2F%2Fimsu.io
```

Check that `iss` is `https://imsu.io`. When the user clicks **Cancel**, the client gets `error=access_denied` with `state` and `iss`.

### 8. Exchange the code for tokens

The code works once, within 60 seconds.

```http
POST /oauth/token HTTP/1.1
Host: imsu.io
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=imsu_ac_MoogF2zjfJ7JQKXxgg3YtY97erq1xcrkmUw02JEchmj&client_id=imsu_client_6nYmKDC0DnLQnAtPUhFKCM&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&resource=https%3A%2F%2Fimsu.io%2Fmcp
```

```http
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json; charset=utf-8
```

```json
{
  "access_token": "imsu_at_H4zN5w3m47PM075l0Hpaj8thgf5yjNtKndxdfJtEORw",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "imsu_rt_amp3sWE4h6uIq3OZUHRrHA2CLHnLN9doHtRIbiyrCJH",
  "scope": "monitors:read monitors:write"
}
```

`redirect_uri` may be left out, as OAuth 2.1 allows; if you send it, it must be the one from step 6. `scope` says what the user allowed, which may be less than you asked for.

### 9. Call the MCP server with the access token

```http
POST /mcp HTTP/1.1
Host: imsu.io
Authorization: Bearer imsu_at_H4zN5w3m47PM075l0Hpaj8thgf5yjNtKndxdfJtEORw
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}
```

```http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache,no-store
```

```text
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{}},"serverInfo":{"name":"imsu.io","title":"imsu.io","version":"1.0.0"},"instructions":"imsu.io checks websites (http), TCP ports (port) and cron jobs (heartbeat) on a schedule … Ask the user before deleting."},"id":1,"jsonrpc":"2.0"}
```

The answer may come as an event stream of one event whose `data:` line holds the JSON-RPC message, or as plain JSON; accept both. There is no `Mcp-Session-Id`: the server keeps no session, so send the access token and `MCP-Protocol-Version` with every later request.

The token works only in the `Authorization` header and only at `https://imsu.io/mcp`; the REST API at `/api/v1` refuses it with `401`.

### 10. Refresh the access token

Access tokens last 1 hour. Before or after one expires, trade the refresh token for new tokens:

```http
POST /oauth/token HTTP/1.1
Host: imsu.io
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=imsu_rt_amp3sWE4h6uIq3OZUHRrHA2CLHnLN9doHtRIbiyrCJH&client_id=imsu_client_6nYmKDC0DnLQnAtPUhFKCM
```

```json
{
  "access_token": "imsu_at_IFFtxW7u0V8QwjJdVILXm7c3SCQeoYA74NpajGgEeIJ",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "imsu_rt_o1owcrDfMAIclUkzXPdP5sANxYwIa8GfxrCK3oR65fM",
  "scope": "monitors:read monitors:write"
}
```

Keep the new refresh token and forget the old one: see [refresh token rotation](#refresh-token-rotation). Optional parameters: `scope`, to narrow the new access token (the refresh token keeps what was granted), and `resource` (`https://imsu.io/mcp`).

### 11. Revoke tokens when the user disconnects

```http
POST /oauth/revoke HTTP/1.1
Host: imsu.io
Content-Type: application/x-www-form-urlencoded

token=imsu_rt_o1owcrDfMAIclUkzXPdP5sANxYwIa8GfxrCK3oR65fM&client_id=imsu_client_6nYmKDC0DnLQnAtPUhFKCM&token_type_hint=refresh_token
```

```http
HTTP/1.1 200 OK
Cache-Control: no-store
```

Revoking a refresh token ends the whole connection, with every token issued for it; revoking an access token ends only that token. Any well-formed request gets an empty `200`, also for a token that is unknown, already revoked or another client's (then nothing is revoked). A request without `token` or `client_id` gets `400 invalid_request`.

## Client ID Metadata Documents

A client can use an HTTPS URL as its `client_id`, pointing at a JSON document it publishes. imsu.io then needs no registration, and the consent page shows the URL's host, so users can tell who the app is.

```json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Example Agent",
  "client_uri": "https://app.example.com",
  "redirect_uris": ["https://app.example.com/oauth/callback", "http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

- The URL must be HTTPS with a path, without a fragment, user name, password or `.`/`..` segments, and at most 2048 characters.
- imsu.io fetches it with `Accept: application/json`, from public addresses only, without following redirects, within 5 seconds. The document must be a JSON object of at most 5 KB.
- `client_id` must equal the URL exactly; `redirect_uris` must hold 1 to 10 valid [redirect URIs](#redirect-uris); `token_endpoint_auth_method` must be absent or `none`. `client_name` defaults to the URL's host.
- The document is cached as its `Cache-Control: max-age` says, at least 5 minutes and at most 24 hours. When the cached copy has expired and can't be fetched again, the authorization fails (`client_metadata_unavailable`); a stale copy is never used.
- Hosts are shown in their ASCII form (`xn--…` for internationalized names), so a look-alike name can't pass for another.

## Dynamic Client Registration

`POST https://imsu.io/oauth/register` takes a JSON body of at most 16 KB and registers a public client ([example](#4-get-a-client-id)).

- `redirect_uris` (required): 1 to 10 [redirect URIs](#redirect-uris).
- `client_name`: trimmed, without control characters, at most 100 characters; "Unnamed app" if missing.
- `client_uri`: kept only if it is HTTPS. `logo_uri` is ignored: imsu.io shows no logos.
- `grant_types` and `response_types` may only list `authorization_code`, `refresh_token` and `code`.
- `token_endpoint_auth_method` is always registered as `none`; no secret is issued.
- Registered apps are marked unverified on the consent page. Registrations that are never used to connect are deleted after 7 days.
- Each address may register 10 clients an hour; then `429` with `Retry-After`.

Errors are `400` with `{ "error", "error_description" }`: `invalid_redirect_uri` for a redirect URI that isn't allowed, `invalid_client_metadata` for anything else.

```json
{
  "error": "invalid_redirect_uri",
  "error_description": "An http redirect URI is only allowed on a loopback host (127.0.0.1, [::1] or localhost). Use https."
}
```

## Redirect URIs

A redirect URI is an absolute URI without a fragment, and one of:

- HTTPS, e.g. `https://app.example.com/oauth/callback`;
- HTTP on a loopback host: `http://127.0.0.1`, `http://[::1]` or `http://localhost`;
- an app's own scheme, e.g. `cursor://anysphere.cursor-mcp/oauth/callback` or `com.example.app:/callback`.

Schemes such as `javascript:`, `data:`, `file:`, `ws:` and `ftp:` are refused. The `redirect_uri` of an authorization request must match a registered one exactly, except that on a loopback host any port matches, because native apps listen on a port they pick at run time. Scheme, host, path and query must still match.

## PKCE

PKCE is required, with `code_challenge_method=S256`; `plain` is refused. The token request must send the `code_verifier` whose S256 hash is the `code_challenge` of the authorization request ([example](#5-make-a-pkce-pair)).

## Resource

The only resource is `https://imsu.io/mcp`. `resource` is optional in the authorization and token requests; if sent, it must be that value, or the answer is `invalid_target`. Every token is issued for it, and other resources refuse it.

## Scopes

| Scope | Allows |
|---|---|
| `monitors:read` | List and read monitors and the plan's limits |
| `monitors:write` | Create, change, pause, resume and delete monitors; includes `monitors:read` |

Scopes imsu.io doesn't know (`openid`, `offline_access`, …) are ignored, and asking for none of these two, or leaving `scope` out, asks for both. The user may allow reading only. Such a connection gets a `403 insufficient_scope` challenge when it calls a tool that changes monitors; ask again with both scopes to step up ([details](https://imsu.io/docs/mcp#insufficient-scope)).

## Token lifetimes

| | Format | Lifetime |
|---|---|---|
| Authorization code | `imsu_ac_…` | 60 seconds, once |
| Access token | `imsu_at_…` | 1 hour (`expires_in: 3600`) |
| Refresh token | `imsu_rt_…` | 30 days, replaced on every use |
| Consent request | sealed `request` handle | 10 minutes to sign in and allow |
| Connection (grant) | | Until the user disconnects it, the app revokes it, or a reused code or refresh token ends it |

Tokens are opaque: don't parse them. imsu.io stores only their SHA-256 hashes. Connecting the same app again for the same user and account replaces the old connection and its tokens.

## Refresh token rotation

Every refresh returns a new refresh token, and the old one stops working. If an old refresh token is presented again, imsu.io assumes it was stolen and ends the connection with all its tokens:

```json
{
  "error": "invalid_grant",
  "error_description": "The refresh token was already used, so the access it gave was revoked. Connect the app again."
}
```

The same happens when an authorization code is used twice. Store the newest refresh token before using the new access token, and don't refresh from two places at once.

## Disconnecting

The user sees every connected app at [https://imsu.io/settings](https://imsu.io/settings), under Connected apps, with what it may do and what it changed recently, and can disconnect it there. Disconnecting, or the app revoking its refresh token, ends all of the connection's tokens at once: the next request gets `401`. While the account is suspended, or once the user is no longer a member of it, its tokens stop working too.

## Errors at the authorization endpoint

While imsu.io can't trust where to send the browser (the client or its redirect URI is unknown or doesn't match), it shows its own error page instead: `https://imsu.io/authorize?error=<code>`.

| Code | Meaning |
|---|---|
| `invalid_request` | `client_id` or `redirect_uri` is missing or sent twice |
| `unknown_client` | No registered client has this `client_id` |
| `client_metadata_unavailable` | The client metadata document couldn't be fetched, or isn't valid |
| `invalid_redirect_uri` | `redirect_uri` isn't one of the client's |

Once client and redirect URI are known, other errors go back to a native app's redirect URI (loopback HTTP or its own scheme) with `error`, `error_description`, `state` and `iss`; an HTTPS redirect URI gets the error page instead, so imsu.io can't be used to send people to any site:

```text
http://127.0.0.1:33418/callback?error=invalid_request&error_description=code_challenge_method%20must%20be%20S256;%20plain%20is%20not%20supported.&state=af0ifjsldkj&iss=https%3A%2F%2Fimsu.io
```

| `error` | Meaning |
|---|---|
| `invalid_request` | PKCE is missing or malformed, the method isn't `S256`, `response_type` is missing, or a parameter was sent twice |
| `unsupported_response_type` | `response_type` isn't `code` |
| `invalid_target` | `resource` isn't `https://imsu.io/mcp` |
| `access_denied` | The user clicked Cancel |

## Errors at the token endpoint

The token endpoint takes form-encoded bodies of at most 16 KB and answers errors as `{ "error", "error_description" }` with `Cache-Control: no-store`:

| Status and `error` | Meaning |
|---|---|
| `400 invalid_request` | A parameter is missing or sent twice, the body isn't form-encoded or is too large, or the `code_verifier` is malformed |
| `400 unsupported_grant_type` | `grant_type` isn't `authorization_code` or `refresh_token` |
| `401 invalid_client` | `client_id` is unknown |
| `400 invalid_grant` | The code or refresh token is unknown, expired, revoked, already used, or another client's; the `code_verifier` doesn't match; `redirect_uri` differs from the authorization request's |
| `400 invalid_target` | `resource` isn't the token's resource |
| `400 invalid_scope` | A refresh asked for a scope the user didn't grant |

## Rate limits

Per client address: 60 requests a minute to each of `/oauth/authorize`, `/oauth/token` and `/oauth/revoke`, and 10 registrations an hour at `/oauth/register`. Over a limit, the answer is `429` with `Retry-After` in seconds. The MCP server's own limit is per credential ([details](https://imsu.io/docs/mcp#rate-limits)).

## Troubleshooting

| Problem | Likely cause | Fix |
|---|---|---|
| The browser shows "imsu.io doesn't know this app" | The `client_id` was never registered, or the registration was deleted after 7 days unused | Register again, or use a client metadata document |
| "This app asked to return to an address it didn't register" | `redirect_uri` doesn't match a registered one | Send a registered redirect URI; on loopback only the port may differ |
| "imsu.io couldn't read this app's details" | The metadata document is unreachable, too large, slow, or invalid | Serve valid JSON of at most 5 KB from a public address, without redirects |
| "This sign-in request has expired" | More than 10 minutes passed between the authorization request and Allow | Start the authorization again |
| `invalid_grant` when exchanging the code | The code expired (60 seconds), was already used, or the `code_verifier` doesn't match | Exchange the code right away, once, with the verifier of the same request |
| `invalid_grant` "already used, so the access it gave was revoked" | A refresh token or code was used twice | Connect again, and keep only the newest refresh token |
| `401` from `/mcp` with `error="invalid_token"` | The access token expired or was revoked | Refresh it; if that fails, connect again |
| `401` "Token not accepted here" from `/api/v1` | OAuth tokens only work at `/mcp` | Use an API key for the REST API |
| `403 insufficient_scope` from `/mcp` | The user allowed reading only | Ask for `monitors:read monitors:write` again |
| `429` | Too many requests from one address or credential | Wait `Retry-After` seconds |
