---
title: "Connect a client"
description: "Two transports, one server."
url: "https://nextmail.sh/docs/mcp/connect"
area: "MCP server"
category: "MCP server"
---

# Connect a client

Two transports, one server.

## Endpoints

Streamable HTTP is the one to build against. SSE is there for clients that have not moved yet.

| Transport | URL |
| --- | --- |
| Streamable HTTP | https://api.nextmail.sh/mcp |
| SSE | https://api.nextmail.sh/sse |

## How the grant works

1. Point the client at the Streamable HTTP URL.
2. It reads the authorization-server metadata and registers itself.
3. You are sent here to sign in and are shown what is asking and where your access would be sent.
4. You choose what it gets: one workspace, a set of permissions, every address you can reach or only some domains and addresses, and how long the approval lasts.
5. Approving issues the client an access token held to exactly that.

> OAuth, and only OAuth. A Better Auth session cookie in the Authorization header does not work. It was how this connected before the grant existed, and the endpoints no longer accept it.

Account → Connected apps lists every app holding a token and what each may reach, changes that, and removes one. Terminals signed in with the command line tool are under Account → Command line instead. Removing an app deletes every token it holds and the approval behind it, so the next attempt has to ask again. A client connected before approvals existed holds none, so its next call is refused and it asks you again.

## Tokens, refresh and revocation

Approving sends the person back to the client with a code, which the client exchanges at the token endpoint, with its PKCE verifier, for an access token and a refresh token. The access token lasts an hour, and `expires_in` in the answer says so. The discovery document names every endpoint, so a client never has to hard-code one.

The token endpoint takes a form, JSON or multipart body and reads each field once. A request that sends a field such as `grant_type`, `code` or `refresh_token` more than once with different values, or as anything but a single string, such as a JSON array or a number, is refused with `400` `invalid_request` before anything is checked. So is a body with no `Content-Type`, or one of any other type. A field sent twice with the same value is read once.

| Endpoint | Path |
| --- | --- |
| Discovery | `GET /.well-known/oauth-authorization-server`, also with `/mcp` or `/sse` after it |
| Registration | `POST /auth/mcp/register` |
| Authorization | `GET /auth/mcp/authorize` |
| Token | `POST /auth/mcp/token` |
| Revocation | `POST /auth/mcp/revoke` |

A refresh token works once. A successful refresh returns a new access token and a new refresh token, so save the new refresh token before anything else. The refresh token it replaced stops working at that moment. The access token it replaced keeps working until it expires or until the next refresh, so a call already on its way still succeeds. The same client presenting the replaced refresh token again within 30 seconds gets the same new pair back, with `expires_in` counting down, so two processes that share one sign-in and refresh at once both end up holding one pair.

An app registered with a client secret sends it on a refresh too, as `client_secret` in the body or in a Basic `Authorization` header, and is refused with `401` `invalid_client` without it. Each app can refresh 60 times an hour for each person. The next refresh is refused with `429` `too_many_requests` and a `Retry-After` header, and the refresh token works again once that time has passed.

> Presenting a used refresh token more than 30 seconds after it was replaced is read as a stolen token. The request is refused with `invalid_grant`, and every token the app received from that one sign-in goes at once, the first pair and every refresh since, together with any verification the app had completed. The approval stays, and so do the app’s other sign-ins with that person, so the app only has to sign in again. A client whose refresh answer was lost can retry with the same refresh token within the 30 seconds, and should sign in again after that.

`POST /auth/mcp/revoke` is how a client signs itself out. It takes a form or JSON body with `token`, which may be the access token or the refresh token, and `client_id`. `token_type_hint` is accepted and not needed, because both kinds are looked up. A refresh token that was already replaced still counts for 7 days, so signing out with an old copy still ends the app’s access. The answer is `200` with `{}` even for a token it does not know, so a retry is always safe. A token issued to a different `client_id` is refused with `401` `invalid_client`, and nothing is revoked. The discovery document advertises it as `revocation_endpoint`.

**Revoke**

```
curl -X POST "https://api.nextmail.sh/auth/mcp/revoke" \
  -d token="$REFRESH_TOKEN" \
  -d client_id="$CLIENT_ID"

{}
```

> Revoking signs the app out completely, whichever of its tokens you present. Every token it holds for that person, the approval behind them and any verification it had completed go at once, exactly as if the person had removed it in Account → Connected apps, so the next sign-in asks for approval again.

The same access token is asked for a verification code before a few sensitive changes: on the REST API, such as deleting a domain or changing a webhook, and through the tools that make the same changes, such as `createRule` and `removeDomain`. One verified code covers both for 60 minutes. A client that cannot ask the person for a code, such as one that only speaks MCP, is covered the same way when the person chooses Allow changes for 60 minutes on it in Account → Connected apps.

- [Verification codes](https://nextmail.sh/docs/api/authentication.md): Which operations and tools ask a token for a code, and how to verify one.
