> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect an AI app to a hub

> Use the contact OAuth connector door to connect Claude, ChatGPT, or another MCP client to a hub member's access.

A hub member can connect an AI app by approving a consent screen on the hub's own login. The app then receives a short-lived token scoped to that hub and its configured MCP server. This is the **contact OAuth connector door**: an OAuth authorization-code flow for public AI clients that use the Model Context Protocol (MCP).

The feature is behind a server-side flag and is off by default. An operator must enable and configure it before clients can connect. The token identifies the contact and hub for the MCP server; it is not a hub login and cannot be used to call the member.dev API.

## How discovery works

The MCP server publishes protected-resource metadata that tells the AI app which authorization server protects it. The authorization server publishes its OAuth endpoints and public signing keys.

The MCP server's RFC 9728 (OAuth protected-resource metadata) document should identify the canonical MCP URL and connector issuer:

```json theme={null}
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://api.example.com/oauth/connector"],
  "scopes_supported": ["mcp:access"],
  "bearer_methods_supported": ["header"]
}
```

An unauthenticated MCP request must return `401` with a `WWW-Authenticate: Bearer` challenge containing `resource_metadata="<URL of this document>"`. The MCP server publishes this document; the backend does not serve it. The `resource` must match the operator's configured canonical URL exactly.

The client then reads authorization-server metadata and, when it needs to validate an access token, the JWKS (JSON Web Key Set, the public keys used to verify signed tokens):

```bash theme={null}
curl -sS https://api.example.com/.well-known/oauth-authorization-server/oauth/connector
curl -sS https://api.example.com/oauth/connector/jwks.json
```

The metadata path is the well-known prefix followed by the issuer path. The issuer is `https://api.example.com/oauth/connector`. These protocol routes are at the API root and do not have `/api/v1` equivalents. Do not use the root `/.well-known/openid-configuration`: that describes the separate legacy contact OIDC system.

> **Alpha caveat — discovery:** real Claude and ChatGPT request captures are still pending. In particular, the exact discovery URLs each client probes, and whether either probes the root OpenID Connect path, are not confirmed.

## Register the AI client

Dynamic client registration (DCR) gives the AI app a public client identity. Registration does not grant access to a hub. Clients use no client secret.

```bash theme={null}
curl -sS -X POST https://api.example.com/oauth/connector/register \
  -H 'Content-Type: application/json' \
  -d '{
    "client_name": "Example AI app",
    "redirect_uris": ["https://client.example.com/oauth/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none"
  }'
```

The response is `201` and includes a case-sensitive `client_id`. There is no client secret or registration access token, and registrations cannot be edited; register again if the callback changes. An unused registration expires after 24 hours. After its first approved consent, it no longer expires.

Callbacks must be exact allowlist entries. There are no wildcards, prefix matches, port variations, or custom schemes. The code's default entries are `https://claude.ai/api/mcp/auth_callback` and `https://chatgpt.com/connector_platform_oauth_redirect`; an operator can replace the full allowlist. ChatGPT also documents a callback shaped like `https://chatgpt.com/connector/oauth/{callback_id}`, but each actual URL must be added individually. Loopback callbacks are refused in production, even if listed.

If `grant_types` is omitted, registration uses the operator's configured default. The documented default in the code is authorization-code only; clients receive refresh tokens only if their registration includes `refresh_token`.

> **Alpha caveat — callbacks and registration:** the default callbacks come from vendor documentation, not recorded client requests. Exact callback URLs used by Claude and ChatGPT, and whether they request `refresh_token` during registration, are pending real captures. Confirm each connector's actual callback with the operator before relying on these defaults.

## Send the member through authorization and consent

The member starts in the AI app, signs in on the hub, and approves sharing access with that app.

The AI app creates a Proof Key for Code Exchange (PKCE) verifier and its S256 (SHA-256) challenge, plus an unpredictable `state` value. It opens the member's browser at the authorization endpoint with the client ID, exact registered callback, challenge, state, and configured MCP resource:

```bash theme={null}
curl -sS -G https://api.example.com/oauth/connector/authorize \
  --data-urlencode 'response_type=code' \
  --data-urlencode 'client_id=<client_id>' \
  --data-urlencode 'redirect_uri=https://client.example.com/oauth/callback' \
  --data-urlencode 'code_challenge=<BASE64URL_SHA256_OF_CODE_VERIFIER>' \
  --data-urlencode 'code_challenge_method=S256' \
  --data-urlencode 'state=<unpredictable-client-state>' \
  --data-urlencode 'resource=https://mcp.example.com/mcp' \
  --data-urlencode 'scope=mcp:access'
```

This is a browser navigation, not a server-to-server `curl` in a production client. The verifier stays with the AI app; the challenge goes to authorize. The verifier must be 43–128 characters from `A-Z a-z 0-9 - . _ ~`. `state` is required and is returned unchanged. The callback must exactly match the registration. The resource string must exactly match a configured canonical URL or alias; the service does not normalize trailing slashes. Aliases resolve to the canonical URL for tokens.

The authorize request redirects the browser to the hub's own `/connect-client` page. The hub's backend-for-frontend (BFF) binds the request to that browser, then asks the member to sign in on the hub if needed and show explicit consent. The browser does not call the connector's internal prepare endpoint directly. Once signed in, the hub BFF calls `POST /api/oauth/requests/prepare` with the request handle (`rid`), the member's contact bearer, and its hub-edge proof (the secret and browser-bound headers that authenticate the hub BFF request). After the member approves or denies, the BFF calls `POST /oauth/connector/consent`. The consent endpoint returns JSON containing `redirect_url`; the hub BFF sends the browser there with a `303`. On approval, that URL points to the registered callback with `code`, the original `state`, and `iss`; on denial, it carries `error=access_denied` with `state` and `iss`.

These are server-to-server BFF calls; they are not calls the AI client makes. The hub BFF sends the current browser nonce and canonical origin with both requests:

```bash theme={null}
curl -sS -X POST https://api.example.com/api/oauth/requests/prepare \
  -H 'Content-Type: application/vnd.api+json' \
  -H 'X-MIO-Hub-Edge-Secret: <hub-edge-secret>' \
  -H 'X-MIO-Connector-Nonce: <43-character-base64url-nonce>' \
  -H 'X-MIO-Connector-Origin: https://hub.example.com' \
  -H 'Authorization: Bearer <contact-access-token>' \
  -d '{"data":{"type":"oauth_connector_requests","attributes":{"rid":"<rid-from-hub-url>"}}}'
```

For a signed-out browser, omit `Authorization`; prepare binds the browser and returns `login_required`. After sign-in, prepare returns `consent_required` and the latest CSRF (cross-site request forgery) token. The BFF then posts the member's decision:

```bash theme={null}
curl -sS -X POST https://api.example.com/oauth/connector/consent \
  -H 'Content-Type: application/json' \
  -H 'X-MIO-Hub-Edge-Secret: <hub-edge-secret>' \
  -H 'X-MIO-Connector-Nonce: <same-browser-nonce>' \
  -H 'X-MIO-Connector-Origin: https://hub.example.com' \
  -H 'Authorization: Bearer <contact-access-token>' \
  -d '{"rid":"<rid-from-hub-url>","csrf_token":"<latest-prepare-csrf-token>","decision":"approve"}'
```

Set `decision` to `deny` when the member declines. The BFF must send the returned `redirect_url` to the browser with a `303` and must not forward the bearer or edge secret to the client callback.

Every connection requires explicit consent, including reconnecting to the same app. `prompt=consent` behaves like the default. `prompt=select_account` is refused with `invalid_request`; `prompt=login` and `prompt=none` are refused with `login_required`. The v1 door has no account chooser and cannot prove a fresh login for this request.

The authorization request lasts 10 minutes. After sign-in, the consent-ready step lasts 5 minutes at most and never past the original 10-minute deadline. If it expires or a bound value changes, the member must restart from the AI app.

## Exchange the code for tokens

The AI app exchanges the one-use authorization code using the same client ID, callback, resource, and PKCE verifier. Send a form body:

```bash theme={null}
curl -sS -X POST https://api.example.com/oauth/connector/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=<client_id>' \
  --data-urlencode 'code=<code-from-callback>' \
  --data-urlencode 'code_verifier=<original-pkce-verifier>' \
  --data-urlencode 'redirect_uri=https://client.example.com/oauth/callback' \
  --data-urlencode 'resource=https://mcp.example.com/mcp'
```

The authorization code expires after 60 seconds and can be used once. The response includes an access token and, only when the client registered `refresh_token`, a refresh token:

```json theme={null}
{
  "access_token": "<JWT>",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "mcp:access",
  "refresh_token": "<opaque-refresh-token>"
}
```

The access token is an RS256-signed JWT for the MCP resource. Its audience is the canonical MCP URL, its `hub_id` is the mapped hub, its `sub` is the contact ID, and its scope is exactly `mcp:access`. It contains no email or profile. The MCP server must verify it with the connector JWKS and apply its own authorization for the contact. Do not treat it as a hub login or forward it to another resource.

There is one effective permission: `mcp:access`. Omitted or empty scope defaults to it. `offline_access` is accepted as an alias, alone or with `mcp:access`, but adds nothing and is never required; the granted scope remains exactly `mcp:access`. Other scopes are rejected.

## Refresh, revoke, and reconnect

Access tokens last 300 seconds. A refresh token has a 7-day idle lifetime, renewed by rotation, and a 30-day absolute family lifetime from code exchange. Consent expires 90 days after approval. The 30-day family cap can end the connection before consent expires. A client without a refresh token must reconnect when its access token expires.

Each refresh returns a new refresh token and spends the old one immediately. There is no grace window. Store the new refresh token before using anything else from that response. Reusing a spent refresh token revokes the whole family. If a client loses a successful refresh response, it must reconnect.

```bash theme={null}
curl -sS -X POST https://api.example.com/oauth/connector/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode 'client_id=<client_id>' \
  --data-urlencode 'refresh_token=<current-refresh-token>' \
  --data-urlencode 'resource=https://mcp.example.com/mcp'
```

An AI client can revoke a token through the OAuth revocation endpoint defined by RFC 7009 (the OAuth token revocation standard). Revoking a refresh token revokes its refresh family. Revoking an access token issued to that client revokes the grant and its families. Unknown, expired, foreign, or malformed tokens also receive the empty `200` response.

```bash theme={null}
curl -sS -X POST https://api.example.com/oauth/connector/revoke \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=<client_id>' \
  --data-urlencode 'token=<refresh-or-access-token>'
```

> **Alpha caveat — client recovery:** real Claude and ChatGPT behavior after a lost refresh response, `invalid_grant`, and `429` is pending capture. The backend's refresh rotation has no grace window, so clients should be prepared to reconnect after `invalid_grant`.

## Let a member review and disconnect grants

A **grant** is the member's approved connection between one client and one MCP resource on a hub. A hub's Connected apps page can list a member's live grants and disconnect one using the member's own contact sign-in bearer. These endpoints stay mounted even when the connector feature flag is off, so members can clean up existing connections. Neither requires active membership in the hub; a banned or departed member can still review and disconnect.

List live grants, newest first. `page[size]` accepts 1–100 and defaults to 20. Use the opaque `meta.page.next_cursor` as `page[after]` for the next page. Optional `filter[hub_id]` (up to 36 characters) and `filter[resource]` (up to 2048 characters) narrow results; the resource filter is an exact canonical MCP URL.

```bash theme={null}
curl -sS 'https://api.example.com/api/v1/contacts/me/oauth-grants?page%5Bsize%5D=20' \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Bearer <contact-access-token>'
```

The JSON:API response exposes client name, hub ID and title, canonical resource URL, scope and description, grant and expiry timestamps, and last token-issued time. `last_issued_at` is not last use because the MCP validates tokens locally. The response never includes credentials, family IDs, or hashes. Escape `client_name`, which is untrusted text. Use `page[after]` only with a cursor returned in `meta.page.next_cursor`.

Disconnect a grant by its ID. A repeat returns `204` without another event. A successful disconnect revokes the grant and refresh families; access tokens already issued can still work at the MCP for up to 360 seconds while they expire.

```bash theme={null}
curl -sS -X DELETE 'https://api.example.com/api/v1/contacts/me/oauth-grants/<grant_id>' \
  -H 'Authorization: Bearer <contact-access-token>'
```

The grant ends when the member disconnects it, consent expires after 90 days, a client revokes an access token under RFC 7009, a code replay is detected and revokes the grant, issuance finds the contact has lost authority, or an operator retires the resource. Reuse of a spent refresh token revokes its refresh family. It also ends when the member loses write-active membership on the hub (including being banned, soft-banned, leaving, or removal), is removed from the team, resets or changes their password, signs out everywhere, or their contact data is erased/deleted. Deleting the hub or team also revokes the affected grants. A legacy data import or its revert can also remove a member or clear credentials and revoke affected grants. Revocation is one-way; restoring membership does not restore a grant. The member must consent again. The service revokes during the change transaction and stops issuing tokens immediately, but locally verified access tokens may remain usable for up to 360 seconds. The 360-second bound is current and has not yet been confirmed with the first partner.

## Errors

OAuth routes use plain OAuth JSON and form bodies as specified; they do not use `application/vnd.api+json`. Before authorize validates the client and callback, errors are HTTP responses; after that point, authorization errors are a `302` to the validated callback with `error`, `error_description`, `state` when valid, and `iss`. The grants endpoints use the normal API error shape. For rate limits, honor `Retry-After`; the service fails closed if its rate-limit store is unavailable.

| Endpoint | Status and code | Meaning |
| - | - | - |
| Registration | `400 invalid_client_metadata` | Invalid body or unsupported metadata, grant types, response types, or authentication method. |
| Registration | `400 invalid_redirect_uri` | Missing, malformed, over-limit, or non-allowlisted callback. |
| Registration | `429 slow_down` | Registration rate limit reached. |
| Registration | `429` or `503 temporarily_unavailable` | Client capacity, database, or rate-limit store is unavailable. |
| Authorization request | `400 invalid_request` | Query is too large or malformed, `client_id` or `redirect_uri` is repeated, or the callback is missing/unregistered. |
| Authorization request | `400 invalid_client` | Client ID is missing, unknown, disabled, or its unused registration expired. |
| Authorization request | `429 slow_down` / `503 temporarily_unavailable` | Rate limit or request/rate-limit store failure before redirect validation. |
| Authorization redirect | `302 invalid_request` | After the client and callback are validated: repeated parameters other than `client_id` or `redirect_uri`, missing/invalid state or PKCE, unsupported response mode, or invalid prompt. |
| Authorization redirect | `unsupported_response_type` | Response type is not `code`. |
| Authorization redirect | `invalid_target` | Resource is missing, unknown, or maps to a hub that is not live. |
| Authorization redirect | `invalid_scope` | Scope is not accepted. |
| Authorization redirect | `login_required` | `prompt=login` or `prompt=none`. |
| Authorization redirect | `server_error` | The hub's canonical address could not be validated. |
| Authorization redirect | `access_denied` | The member denied consent. |
| Token | `400 invalid_request` | Malformed form body or missing required code, verifier, callback, or refresh token. |
| Token | `400 unsupported_grant_type` | Grant type is not `authorization_code` or `refresh_token`. |
| Token | `400 invalid_client` | Client ID is missing, malformed, unknown, or disabled. |
| Token | `400 unauthorized_client` | Client did not register `refresh_token`. |
| Token | `400 invalid_target` / `400 invalid_scope` | Resource is invalid, or refresh scope is unsupported. |
| Token | `400 invalid_grant` | Code or refresh token is invalid, expired, reused, revoked, or no longer authorized. |
| Token | `429 slow_down` / `503 temporarily_unavailable` | Rate limit or temporary store, signing, or commit failure. |
| Revocation | `400 invalid_request` / `400 invalid_client` | Malformed request or client ID. |
| Revocation | `429 slow_down` / `503 temporarily_unavailable` | Rate limit or temporary store/commit failure. |
| Grants list | `400 invalid_cursor` | Invalid `page[after]` cursor; the error identifies `page[after]` in `source.parameter`. |
| Grants list | `422` | `filter[hub_id]` is not a lowercase canonical UUID, `filter[resource]` does not match the exact resource URL grammar, or `page[size]` is outside 1–100. The error identifies the query parameter. |
| Grants list or disconnect | `401` | No bearer token was supplied; the response has no stable error code. |
| Grants list or disconnect | `401 access_token_invalid` / `401 access_token_expired` | The bearer token is invalid or expired. |
| Grants list or disconnect | `401 invalid_token` | The token is valid but its contact no longer exists at that token version. |
| Disconnect | `404 oauth_grant_not_found` | Grant does not exist or belongs to another contact; both cases have the same response. |

The hub BFF also receives these errors while preparing or recording consent: `invalid_request`, `invalid_client`, `invalid_token`, `untrusted_bff`, `session_not_eligible`, `access_denied`, `csrf_mismatch`, `consent_not_ready`, `restart_required`, `reconnect_required`, `request_expired`, `rate_limited` (prepare), `slow_down` (consent), and `temporarily_unavailable`. For these, the hub should sign the member in again for `invalid_token`, restart from the AI app for `restart_required`, `reconnect_required`, or `request_expired`, call prepare again for `csrf_mismatch` or `consent_not_ready`, and wait for `Retry-After` for rate limits. `untrusted_bff` indicates a deployment fault. A BFF HTTP `access_denied` means the member is not eligible to connect on that hub; it differs from the redirect `error=access_denied` when the member presses Deny.

## Alpha status and limits

The connector door is disabled by default with `MIO_FEATURE_CONTACT_OAUTH_CONNECTORS_ENABLED=false`. The operator must enable it and configure the issuer, resources, signing keys, callback policy, and hub-edge secret. When the flag is off, protocol endpoints and the hub prepare endpoint return `404`; grants list and disconnect remain available.

The following behavior remains pending real client captures or resource-server confirmation:

* Exact callback URLs Claude and ChatGPT use, and whether they register refresh tokens.
* Whether clients send `resource` at authorize and token, whether they include a trailing slash, and which scope values they send.
* Which discovery URLs each client probes.
* How clients handle lost refresh responses, `invalid_grant`, and `429`.

There is no introspection endpoint, userinfo endpoint, OpenID Connect, ID token, confidential-client support, native-app callback support, production loopback callback, `prompt=login` support, or client ID metadata document support.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.