Pluma
Docs index
docs/mcp/oauth.md View markdown →

MCP OAuth

MCP clients (ChatGPT, Claude, Claude Code, Cursor, VS Code, Codex and others) connect with OAuth 2.1: they open a Pluma screen, you pick the site and approve, and the client is connected. No copying tokens. This page is the technical reference; the guide for people is Step 3 · Connect the MCP.

It implements MCP authorization: protected resource metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client registration (RFC 7591), and authorization code with required PKCE S256.

Discovery

A request to /mcp without a token responds 401 with:

WWW-Authenticate: Bearer resource_metadata="https://pluma.so/.well-known/oauth-protected-resource/mcp"

GET /.well-known/oauth-protected-resource

GET /.well-known/oauth-protected-resource/mcp

{ "resource": "https://pluma.so/mcp", "authorization_servers": ["https://pluma.so"], "bearer_methods_supported": ["header"] }

GET /.well-known/oauth-authorization-server

{
  "issuer": "https://pluma.so",
  "authorization_endpoint": "https://pluma.so/oauth/authorize",
  "token_endpoint": "https://pluma.so/oauth/token",
  "registration_endpoint": "https://pluma.so/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

Client registration

POST /oauth/register

{ "client_name": "Claude Code", "redirect_uris": ["http://localhost:53682/callback"] }
  • Public clients: no client_secret (token_endpoint_auth_method: none). PKCE provides the security.
  • Each redirect_uri must be https://, or http:// to localhost/127.0.0.1 (native apps).
  • Loopback addresses (http://127.0.0.1, http://localhost) match on any port, as in RFC 8252: native clients pick a free port each time. Scheme, host and path still have to match. Everything else must match exactly.
  • Without client_name, the client is called "MCP client".
  • Responds 201 with the client_id.

Authorization

GET /oauth/authorize

Parameters: response_type=code, client_id, redirect_uri (must match a registered one), code_challenge and code_challenge_method=S256, state, and optionally resource. Pluma accepts https://pluma.so/mcp or the bare origin https://pluma.so (ChatGPT sends the origin); any other value is rejected.

Pluma asks you to sign in (if you haven't), shows you which client is asking for access and which redirect_uri it will return to, and lets you pick the site. Anyone on the site can connect a client: the agent can do the same as their role (see What the agent can do).

POST /oauth/authorize

This is the button on the screen. On approve, it returns to redirect_uri?code=…&state=…; on cancel, ?error=access_denied&state=…. The code works once and for 10 minutes.

Token

POST /oauth/token

Form-encoded: grant_type=authorization_code, code, redirect_uri, client_id, code_verifier.

{ "access_token": "pluma_agt_…", "token_type": "Bearer", "scope": "read_published read_drafts write publish" }
  • The token belongs to a new agent on the chosen site, named after the client. It shows up under Agents and you revoke it there.
  • scope is the permissions given by the role of whoever approved (the example is from an editor). See What the agent can do.
  • It doesn't expire on its own: it lives until you revoke it. There is no refresh_token.
  • Reusing a code revokes the agent that code created (protection against stolen codes).

Token errors use the OAuth format: {"error": "invalid_grant", "error_description": "…"}.