OAuth Authorization Server (MCP)
PrimeCal runs a self-contained OAuth 2.1 authorization server purpose-built
for the MCP connector use case: public clients only, PKCE mandatory, RFC 7591 dynamic registration. It
exists so any spec-compliant MCP host can add PrimeCal without a developer
manually minting an ag_sk_... key.
See Agent API for the full request/response reference and curl walkthrough, and Connecting via OAuth for the end-user-facing flow. This page describes how the authorization server behaves, for developers building or operating an MCP host.
Why Public Clients Only, PKCE Mandatory
No client_secret is ever issued, and client registration always reports
token_endpoint_auth_method: "none". MCP hosts are typically desktop apps, browser
extensions, or other clients that cannot keep a confidential secret, so PKCE is the
OAuth 2.1-recommended substitute at the token endpoint. Only the S256 challenge method
is accepted, both on the authorization request and in the discovery metadata's
code_challenge_methods_supported; plain is rejected.
Grant Flow, Step by Step
POST /api/oauth/register— public. Everyredirect_urisentry must usehttps://, or be a loopback address (http://127.0.0.1orhttp://localhost) for native and desktop clients (RFC 8252). The response carries a UUIDclient_idand no secret.GET /api/oauth/authorize— public, spec-mandated entry point. Theclient_idmust be known, and theredirect_urimust match one of the client's registered URIs exactly, with no wildcards, so the endpoint cannot be used as an open redirect. It then issues a302to PrimeCal's consent page at/oauth/consentwith the same query string appended. This endpoint never renders UI or checks who the caller is; the consent page does both.GET /oauth/consent(the PrimeCal web app) — uses the user's normal sign-in. If the user is signed out, they sign in first, and the pending authorization request survives that detour. Once signed in, the page calls the two endpoints below.GET /api/oauth/consent-details— requires a signed-in session and the agent feature on the account's plan. Re-validatesclient_idandredirect_uri, then returns the client's display details plus the permission groups the signed-in user is allowed to grant. A group the user cannot grant (full access, for example, is reserved for administrators) is never listed.- The user picks a permission group and approves, which calls
POST /api/oauth/consent(also requires a signed-in session). On approval:- The server checks again that the user may grant the chosen group. The list in step 4 is a convenience, not the security boundary.
- An agent is provisioned for the client. The first approval creates it; approving the same client again reuses that agent instead of creating a duplicate, reactivating it after a revoke and updating its permission group if that changed.
- An authorization code is issued. It expires 60 seconds after issue, can be used
once, and is bound to the client, the user, the agent, the exact
redirect_uriand the PKCEcode_challenge. - The browser is handed back to the client's own
redirect_uriwith the code appended, or witherror=access_deniedif access is not granted.
POST /api/oauth/token,grant_type=authorization_code— public. The server checks that the client matches, the code has not been used, the code has not expired, theredirect_urimatches exactly, and PKCE holds:base64url(sha256(code_verifier))must equal thecode_challenge. The code is marked as used before anything is returned, so a retry with the correct verifier fails once the code has been used. On success the response contains an access token and a refresh token.POST /api/oauth/token,grant_type=refresh_token— refresh tokens rotate. The presented token is checked, and if it is valid and has not been used it is revoked and a fresh access and refresh token pair is issued. If a refresh token that was already rotated is presented again (which suggests it was stolen), every refresh token issued for that client and agent is revoked, and the caller receivesinvalid_grant, which forces a full re-authorization from step 2.
Errors from steps 6-7 use the RFC 6749 section 5.2 shape, { "error": ..., "error_description": ... },
not PrimeCal's usual { "success": false, "error": { "code", "message" } } envelope.
Using the Access Token
Clients send the access token as a Bearer token in the Authorization header. When a
request is rejected with 401, the response carries this challenge:
WWW-Authenticate: Bearer realm="PrimeCal MCP API", resource_metadata="<origin>/.well-known/oauth-protected-resource"
so spec-compliant MCP clients (RFC 9728) can discover the OAuth flow from a bare 401
instead of failing with no next step. <origin> is taken from the x-forwarded-proto and
x-forwarded-host headers when they are present, and from the request itself otherwise, so
the challenge is correct behind a reverse proxy.
Discovery Metadata
The discovery endpoints are public and unauthenticated:
GET /.well-known/oauth-protected-resource(+/*pathcatch-all) →{ resource, authorization_servers, bearer_methods_supported, resource_documentation }GET /.well-known/oauth-authorization-server(+/*pathcatch-all) →{ issuer, authorization_endpoint, token_endpoint, registration_endpoint, response_types_supported, grant_types_supported, code_challenge_methods_supported, token_endpoint_auth_methods_supported, scopes_supported }
Both build their origin from the request, honouring the forwarded headers described above and
falling back to api.primecal.eu if no host header is present at all. The /*path
catch-all variants exist because some MCP clients probe suffixed discovery paths (e.g.
/.well-known/oauth-authorization-server/api/mcp) per RFC 8414 §3.1.