Documentation source
MCP OAuth (any client)
Connect any OAuth 2.1-aware MCP host to an Amble tenant. The flow is generic — Claude Cowork is the launch integration but the same endpoints work for any compliant client.
Amble's MCP server at `/api/mcp/server` accepts two credential kinds side by side:
- **API keys (`sk_…`)** — for headless / server-to-server callers (CI scripts, n8n, agent runners). One key per tenant; permission set is fixed at key creation.
- **OAuth 2.1 access tokens (`at_…`)** — for browser-based MCP hosts (Claude Cowork, Claude.ai web/desktop/mobile, ChatGPT custom connectors, third-party agent frameworks). Per-user, per-tenant, with consent.
Both work on the same endpoint with no client-side knowledge of which is in use.
## OAuth flow at a glance
1. Client fetches `/.well-known/oauth-authorization-server` (RFC 8414) to discover endpoints.
2. Client either pre-registers (admin UI, confidential client) or self-registers via `POST /oauth/register` (RFC 7591 Dynamic Client Registration — public client, PKCE-only).
3. Client redirects the user to `/oauth/authorize?...&code_challenge=...&code_challenge_method=S256` (RFC 7636 PKCE S256 only).
4. Amble logs the user in (any Supabase Auth provider — Google, email/password, magic link), then shows a consent screen. User picks which **tenant** the connector should access and approves.
5. Amble redirects back to the client's `redirect_uri` with `code=...&state=...&iss=...` (RFC 9207 issuer identification on the redirect).
6. Client `POST /oauth/token` with `grant_type=authorization_code`, the code, the PKCE verifier, client credentials (or none for public clients). Amble returns `{ access_token, refresh_token?, token_type: "Bearer", expires_in: 3600, scope }`.
7. Client calls `/api/mcp/server` with `Authorization: Bearer at_...`. The token is bound to the consenting user's permissions in the chosen tenant.
8. When the access token expires, the client uses `POST /oauth/token` with `grant_type=refresh_token` (RFC 6749 §6). Refresh tokens rotate on every use; reuse detection revokes the entire chain (RFC 6749 §10.4).
9. Disconnect → `POST /oauth/revoke` (RFC 7009).
## Connecting to more than one tenant (OAuth clients)
There are two MCP endpoint shapes:
| Endpoint | Resource URI | Who uses it |
|---|---|---|
| **Global** | `https://app.sprinter.ai/api/mcp/server` | API-key / programmatic callers (Claude Code, n8n, CI). The canonical default. |
| **Tenant-scoped** | `https://app.sprinter.ai/api/mcp/t/{tenantSlug}/server` | OAuth clients (Claude.ai web/desktop, ChatGPT connectors) that want to connect to **more than one** Amble tenant. |
Both run through the same MCP server and the same auth pipeline — the only difference is the resource/audience the token is bound to.
**Why the tenant-scoped URL exists.** RFC 8707 audience + most OAuth MCP hosts key a connection by `(resource, issuer)`. Adding the global `/api/mcp/server` URL twice (once per tenant) makes the host dedupe the connector, so the second tenant's token never sticks. Each tenant-scoped URL is a **distinct resource**, so the host treats each as a separate connector with its own independent token.
**Tenant is forced from the resource at consent.** When you authorize a tenant-scoped connector, Amble forces the issued token's tenant to the tenant named in the URL — server-side, after membership and client-binding checks. The consent screen shows a read-only "Connecting to **{Tenant}**" instead of a tenant dropdown. You cannot bind the token to a different tenant than the one in the connector URL.
> **One connector per tenant.** To connect Claude.ai to three tenants, add three connectors, each pointing at that tenant's `…/api/mcp/t/{tenantSlug}/server` URL. The global URL still works for a single-tenant OAuth connection, but use the tenant-scoped URL whenever a client needs more than one.
> **Slug rename re-auth.** The connector URL embeds the tenant *slug*. Renaming a tenant changes its slug, which invalidates tokens issued for the old slug — you re-authorize with the new URL. This matches how the platform's existing `/t/{slug}/...` URLs already behave on rename.
API-key callers are unaffected — `sk_` keys are already bound to a single tenant at key creation, so one key per tenant against the global URL has always worked.
## What scopes does the client request?
OAuth scopes are the same vocabulary as API keys plus `offline_access` (request a refresh token). At consent time, the user sees each requested scope with a human-readable label and can revoke any subset.
| Scope | Grants |
|---|---|
| `tools:execute` | Run any AI tool the consenting user is permitted to use — read **and** write. Resolves to the user's full permission set, bounded by their role. The broad "act through tools as me" scope, and part of the default consent set. |
| `skills:read` | List + read skill definitions |
| `views:read` | List + read saved views |
| `entities:read` / `entities:write` | Read or modify records |
| `documents:read` / `documents:write` | Read or modify documents |
| `chat:create` | Start chat sessions |
| `*` | Every permission the consenting user has (admin-created confidential clients only — not advertised to DCR'd browser hosts) |
| `offline_access` | Issue a refresh token so the client can stay connected |
> **`tools:execute` is write-capable.** Each tool gates on a specific role
> permission, so "execute tools" resolves to the user's own permission set
> rather than a fixed list — a connected client can do through tools exactly
> what the user can do in the UI, and nothing they lack the role for. Clients
> wanting a narrower grant (read-only, or records-only) request the granular
> scopes (`entities:read`, `documents:write`, …) instead of `tools:execute`.
## Permission inheritance
Each access token carries a **snapshot** of the intersection of:
- The permissions granted by the OAuth scopes the user approved
- The user's tenant role permissions at the moment of consent (or refresh)
This means: a `viewer` who approves `tools:execute` gets a token that can read but not write — the intersection with their role keeps them read-only. An admin who approves `tools:execute` gets full read/write through every exposed tool. Role downgrades propagate at the next refresh (≤ 30 days) or instantly via revoke. Access tokens themselves live one hour, so the worst-case staleness window is 1 hour.
## Best practices for client implementers
- **PKCE S256 is mandatory** — Amble rejects `code_challenge_method=plain`. Use 43-128 character base64url verifiers.
- **Validate the redirect** — Amble only accepts an exact-match `redirect_uri` against the value registered for the client. Register the exact final URI, not a prefix.
- **Use `state`** — Amble echoes `state` on every redirect (success, denial, post-validation error). Required by RFC 6749 §10.12 for CSRF protection on the client side.
- **Check `iss` on the redirect** — Amble sets `iss=<base>` per RFC 9207. Multi-AS clients should verify it.
- **Honor `Cache-Control: no-store`** — token responses must never be cached.
- **Treat tokens opaquely** — they're random 32-byte strings hashed at rest. Don't try to decode.
- **Plan for refresh rotation** — every refresh returns a new `refresh_token`. Replace the stored one; the old one is single-use and reuse trips reuse-detection.
## Hardening on the Amble side
- **Rate-limited DCR** — `/oauth/register` is capped at 10/hour per IP (RFC 7591 §5 deployment guidance).
- **Rate-limited token endpoint** — `/oauth/token` is capped at 60/min per IP (brute-force protection on confidential-client secrets).
- **Refresh-token rotation is atomic** — concurrent refreshes never both succeed; one wins and the other trips chain revocation (RFC 6749 §10.4).
- **Auth-code binding-as-filter** — wrong-client / wrong-redirect token requests do NOT burn the legitimate code (the consume is conditioned on client_id + redirect_uri).
- **Confidential clients at revoke** — RFC 7009 §2.1 client authentication enforced; revocation is bound to the token's issuing client.
## Per-host setup
**OAuth hosts (no API key):**
- [**Claude Cowork**](/docs/integrations/claude-cowork) — Cowork settings → connectors → custom; URL = `https://app.sprinter.ai/api/mcp/server` for a single tenant, or `https://app.sprinter.ai/api/mcp/t/{tenantSlug}/server` to connect more than one tenant (one connector per tenant).
- **Claude.ai (web / desktop) & ChatGPT custom connectors** — Settings → Connectors → add custom; paste the tenant-scoped URL. The consent screen pre-binds the token to the tenant in the URL.
- **Custom OAuth client** — any OAuth 2.1 + PKCE client speaking the discovery + DCR or admin-registered confidential flow works. Use the tenant-scoped URL when the client must hold tokens for more than one tenant. Open an issue or ask Tyler if you need help registering an admin client.
**API-key hosts (`sk_…`):** create a key at **Admin → API Keys** (scope `tools:execute` for read+write) and target the tenant-scoped URL — the endpoint asserts the key's tenant matches the slug and 401s on a mismatch.
- **Claude Code (CLI):**
```bash
claude mcp add --transport http mcp-{tenantSlug} \
https://app.sprinter.ai/api/mcp/t/{tenantSlug}/server \
--header "Authorization: Bearer sk_YOUR_API_KEY"
```
- **Claude Desktop / Cursor / VS Code / Windsurf** — add to the client's MCP config file:
```json
{
"mcpServers": {
"mcp-{tenantSlug}": {
"type": "http",
"url": "https://app.sprinter.ai/api/mcp/t/{tenantSlug}/server",
"headers": { "Authorization": "Bearer sk_YOUR_API_KEY" }
}
}
}
```
- **n8n / CI / curl** — POST JSON-RPC to the tenant-scoped URL with the `Authorization: Bearer sk_…` header.
> The Admin → API Keys page renders these snippets pre-filled with the tenant you're viewing — copy directly from there.
## Reference
- ADR-0026 (OAuth 2.1 authorization server for MCP)
- ADR-0063 (tenant-scoped MCP resources for multi-tenant OAuth connectors)
- Discovery: `GET /.well-known/oauth-authorization-server`, `GET /.well-known/oauth-protected-resource`
- RFCs: 6749 / 7591 / 7636 / 7009 / 8414 / 9207 / 9728
- MCP authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization (2025-11-25)