MCP authentication

The MCP server requires OAuth 2.1 with PKCE — no API keys, no static tokens. It reuses your existing DK SaaS portal account; there’s no separate signup for MCP access.

Discovery

Standard OAuth/MCP discovery documents are published at:

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

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

  • /.well-known/jwks.json

  • /.well-known/openid-configuration

Issuer and protected resource are both under dishonkadoh.com:

{
  "issuer": "https://dishonkadoh.com",
  "authorization_endpoint": "https://dishonkadoh.com/oauth/authorize",
  "token_endpoint": "https://dishonkadoh.com/oauth/token",
  "jwks_uri": "https://dishonkadoh.com/.well-known/jwks.json",
  "scopes_supported": ["mcp"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

Only the mcp scope exists. Only S256 PKCE is supported. Only public clients (token_endpoint_auth_method: "none") are supported — there’s no client-secret flow.

1. Register a client (optional, dynamic)

POST /api/oauth/register implements RFC 7591 Dynamic Client Registration for public PKCE clients:

{
  "client_name": "My Agent",
  "redirect_uris": ["https://myapp.example.com/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Redirect URIs must be HTTPS (localhost/127.0.0.1 exempted for local dev) and must not contain a fragment. Returns a client_id — no secret, since only "none" auth is supported.

2. Authorization + login handoff

Placeholder diagram: mcp-oauth-handoff Placeholder image — replace with a real diagram.

GET /oauth/authorize starts the standard authorization-code + PKCE dance. What makes this server’s flow distinctive: it doesn’t have its own login form. An unauthenticated request is redirected to the DK SaaS portal’s own login (app.dishonkadoh.com/login, overridable via OAUTH_LOGIN_URL), tagged with an OAuth transaction ID.

After the person logs into the portal, the portal calls back to GET /oauth/handoff?oauth_transaction=...&handoff_code=.... This handler exchanges the portal’s one-time handoff_code for the person’s identity by calling back into the Odoo portal API:

POST {OAUTH_PORTAL_API_URL}/api/oauth/handoff/consume
X-OAuth-Handoff-Secret: <shared secret>

{"code": "<handoff_code>"}

— which is the same dk_customer_portal_api oauth.py controller documented under Authentication (portal API). A successful exchange creates an OAuth session tied to the original transaction, and the person is shown a consent screen for the mcp scope before an authorization code is issued back to the requesting client.

3. Exchange the code for tokens

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code>
&redirect_uri=<redirect_uri>
&client_id=<client_id>
&code_verifier=<pkce_verifier>
{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "<opaque_token>",
  "scope": "mcp"
}

Authorization codes are single-use — a second redemption attempt (e.g. a retried request racing the first) gets invalid_grant. Refresh tokens use grant_type=refresh_token the same way against the same endpoint.

Token details

Access tokens are JWTs (typ: "at+jwt", RS256), containing:

Claim

Value

iss

https://dishonkadoh.com

aud

https://dishonkadoh.com/api/mcp

sub

the portal user’s ID

scope

"mcp"

client_id

the registered client

email, name

if available from the portal account

exp

15 minutes from issuance (OAUTH_ACCESS_TOKEN_TTL, default 900s)

Refresh tokens are opaque, default TTL 30 days (OAUTH_REFRESH_TOKEN_TTL, default 2,592,000s). Both authorization codes and OAuth sessions/transactions are stored in Upstash Redis.

The MCP server itself (api/mcp/index.mjs) verifies incoming tokens against the published JWKS, checking iss, aud, and that scope contains mcp — a validly-signed token for the wrong audience or without the mcp scope is still rejected with 401 invalid_token.

Revoking access

POST /oauth/revoke accepts a token (access or refresh) and invalidates it — standard RFC 7009 token revocation.