MCP OAuth 2.1
Overview
TxNod’s MCP server at https://mcp.txnod.com/api/mcp/transport is a fully-conformant OAuth 2.1 protected resource per MCP Authorization spec rev 2025-11-25 . Compliant MCP clients (Claude.ai web, Cursor 1.x, ChatGPT Connectors, …) auto-discover the authorization server, register themselves, run a browser-based authorization-code flow, and call MCP tools with the resulting access token. PATs (Personal Access Tokens) remain available for STDIO clients (Claude Desktop) and headless / CI use — see the PAT vs OAuth comparison below.
Discovery flow walkthrough
The full discovery chain is three round-trips, all driven by the client.
Step 1. The client makes any MCP tool call without an Authorization header. The server responds 401 Unauthorized with a header pointing at the resource metadata (cite MCP §3.8 ):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="txnod-mcp", resource_metadata="https://mcp.txnod.com/.well-known/oauth-protected-resource"Step 2. The client follows the resource_metadata URL and reads the protected-resource metadata JSON (RFC 9728 ):
{
"resource": "https://mcp.txnod.com/api/mcp/transport",
"authorization_servers": ["https://txnod.com"],
"scopes_supported": [
"projects:read",
"invoices:read",
"transactions:read",
"webhooks:read"
],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://docs.txnod.com/guides/mcp-claude-code",
"resource_signing_alg_values_supported": [],
"resource_name": "txnod MCP server"
}Step 3. The client appends /.well-known/oauth-authorization-server to the AS URL and reads the RFC 8414 metadata:
{
"issuer": "https://txnod.com",
"authorization_endpoint": "https://txnod.com/oauth/authorize",
"token_endpoint": "https://txnod.com/oauth/token",
"registration_endpoint": "https://txnod.com/oauth/register",
"revocation_endpoint": "https://txnod.com/oauth/revoke",
"scopes_supported": [
"projects:read",
"invoices:read",
"transactions:read",
"webhooks:read"
],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"service_documentation": "https://docs.txnod.com/guides/mcp-oauth",
"client_id_metadata_document_supported": true,
"resource_documents_supported": [
"https://mcp.txnod.com/.well-known/oauth-protected-resource"
]
}The two *-supported flags matter: code_challenge_methods_supported: ["S256"] says PKCE is mandatory and plain is rejected; client_id_metadata_document_supported: true says CIMD-style registration is honored alongside DCR.
Registering your client (CIMD preferred, DCR fallback)
TxNod accepts two registration paths, both per RFC 7591 family:
CIMD (preferred — Client ID Metadata Document). Host a JSON document at any HTTPS URL containing:
{
"client_id": "https://your-app.example/cid.json",
"client_name": "Your App",
"redirect_uris": ["https://your-app.example/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}Pass the URL itself as client_id to /oauth/authorize. TxNod fetches and caches the document with SSRF guards in place — private, loopback, and link-local IPs are blocked at resolution time, and the document body is bounded.
DCR (fallback — Dynamic Client Registration). POST https://txnod.com/oauth/register with the client metadata JSON:
POST /oauth/register HTTP/1.1
Host: txnod.com
Content-Type: application/json
{
"client_name": "Your App",
"redirect_uris": ["http://127.0.0.1:43219/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}Reply:
HTTP/1.1 201 Created
Content-Type: application/json
{
"client_id": "01HZZZZZZZZZZZZZZZZZZZZZZZ",
"client_id_issued_at": 1714435200,
"client_secret_expires_at": 0,
"client_name": "Your App",
"redirect_uris": ["http://127.0.0.1:43219/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}client_id is a 26-character ULID. Confidential clients (those that supply a client_secret) get a non-rotating secret returned alongside; client_secret_expires_at: 0 means “never expires” per RFC 7591 §3.2.1 .
Rate limit. /oauth/register accepts 10 requests per hour per source IP. Beyond that the endpoint returns 429 Too Many Requests with a Retry-After header.
Redirect-URI validation. Each entry in redirect_uris must be either a loopback URI (http://127.0.0.1:<port>/..., http://localhost:<port>/..., or http://[::1]:<port>/...) or an HTTPS URL — no wildcards, no path prefixes, no plain HTTP outside loopback. http://evil.com/cb is rejected with 400 invalid_redirect_uri.
PKCE + S256 authorization code flow
Every authorization code exchange MUST use PKCE with S256 (cite OAuth 2.1 §7.5 and RFC 7636 ).
Generate the verifier. Pick a 43-128 character base64url string. Use the RFC 7636 example:
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkCompute the challenge. code_challenge = base64url(sha256(code_verifier)). For the verifier above:
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMIn Node:
import { createHash } from 'node:crypto';
const code_challenge = createHash('sha256').update(code_verifier).digest('base64url');Send the authorization request.
GET https://txnod.com/oauth/authorize
?response_type=code
&client_id=<id>
&redirect_uri=<uri>
&scope=invoices:read
&state=<opaque>
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fmcp.txnod.com%2Fapi%2Fmcp%2FtransportUser signs in and approves. TxNod requires the user to authenticate via email-OTP or Google OAuth and then renders a consent screen showing the requested scopes. Unknown emails are structurally rejected — TxNod does not auto-create accounts from an OAuth flow; only previously-invited operators can complete the flow (TxNod is invite-only sign-in by design).
On approve, TxNod 302-redirects to the registered redirect URI:
HTTP/1.1 302 Found
Location: <redirect_uri>?code=oac_<32-char-body>&state=<opaque>On deny, the redirect carries error=access_denied&state=<opaque> per OAuth 2.1 §4.1.2.1 .
Send the token request.
POST /oauth/token HTTP/1.1
Host: txnod.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=oac_<32-char-body>
&redirect_uri=<same>
&client_id=<id>
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&resource=https%3A%2F%2Fmcp.txnod.com%2Fapi%2Fmcp%2FtransportReply:
{
"access_token": "txnod_oat_<32-char-body>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "txnod_ort_<32-char-body>",
"scope": "invoices:read"
}Call MCP tools.
POST /api/mcp/transport HTTP/1.1
Host: mcp.txnod.com
Authorization: Bearer txnod_oat_<32-char-body>
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}Resource parameter (RFC 8707) — required
Every /oauth/authorize and /oauth/token request must include the resource parameter pinned to the canonical MCP transport URI:
resource=https://mcp.txnod.com/api/mcp/transportThis is the audience-binding requirement from RFC 8707 . Mismatch between the resource value at /oauth/authorize and /oauth/token (or between either and the canonical URI) returns 400 invalid_target. The MCP transport rejects any access token whose stored resource column does not equal the canonical URI verbatim — even if the token signature is otherwise valid. This blocks token-substitution attacks where a token issued for one audience is replayed against another.
Token TTLs
- Access token TTL = 3600 s (1 hour). Hardcoded — not tunable in v1. Industry-standard (Google, Microsoft, Auth0, Hydra), aligned with RFC 9700 §2.2.1 .
- Refresh token TTL = 30 days from issuance, rolling on rotation. Each successful refresh issues a new refresh token whose
expires_at = now + 30 days(NOT inherited from the consumed token). Active clients (one or more uses per 30 days) live indefinitely; abandoned tokens auto-expire.
Rotation is mandatory per OAuth 2.1 §6.1.2 : every refresh-token use mints a new refresh token; the old one is marked consumed in the same transaction.
Replay of a consumed refresh token cascade-revokes the entire lineage — every access and refresh token descended from the original issuance is revoked synchronously, and an oauth.refresh.replay_detected audit row is written. The next MCP call from any token in that lineage returns 401 invalid_token. This is the standard refresh-token theft response.
Per-client revocation via /account/connected-apps
The dashboard’s /account/connected-apps page lists every OAuth client the operator has approved. Each row carries a Revoke button:
- Click revoke → cascade-revokes all access and refresh tokens for that (user, client) pair within ≤ 5 s.
- The next MCP tool call from that client returns
401 invalid_token. - The consent record is also revoked, so the next
/oauth/authorizefrom the same client renders a fresh consent screen instead of being auto-approved.
Programmatic revocation is also available via RFC 7009 :
POST /oauth/revoke HTTP/1.1
Host: txnod.com
Content-Type: application/x-www-form-urlencoded
token=<token>
&token_type_hint=access_token
&client_id=<id>Idempotent: TxNod always returns 200 OK with an empty body, even when the token is unknown or already revoked, per RFC 7009 §2.2 . This is intentional — leaking whether a token existed would reveal information to an attacker probing tokens. Revoking an access token does not cascade to its parent refresh token (the client may legitimately want to refresh and continue); revoking a refresh token cascade-revokes every access token issued from its lineage.
Scope challenge (HTTP 403 + WWW-Authenticate insufficient_scope)
When the bearer token’s scopes do not cover the requested tool, the MCP transport returns 403 Forbidden:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="webhooks:read", resource_metadata="https://mcp.txnod.com/.well-known/oauth-protected-resource", error_description="Tool get_webhook_events requires scope webhooks:read"
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Tool requires additional scope.","data":{"error_code":"insufficient_scope"}}}The JSON-RPC frame in the body still carries -32600 / insufficient_scope for backward compat. MCP clients that understand step-up auth (per MCP §“Scope Challenge Handling” ) re-run the OAuth flow with the additional scope and retry.
PAT vs OAuth — when to use which
| Scenario | Use PAT | Use OAuth |
|---|---|---|
| Claude Desktop (STDIO transport) | Always — the only credential mode the wrapper accepts | N/A — STDIO is excluded by MCP spec §“Protocol Requirements” |
| Claude Code, Cursor, ChatGPT Connectors (HTTP transport) | Optional — paste-style “advanced” path remains supported | Default — auto-discovers, prompts browser, zero manual copy |
| Headless CI / scripts | Always — OAuth flow needs a browser | N/A |
| Per-client revocation UX | /developer/tokens (manual revoke per token) | /account/connected-apps (one-click revoke per client; cascades to all access + refresh tokens) |
| Audit-trail granularity | One row per token | One row per (user, client) consent + per token + per call |
| Rate-limit pool | Shared mcp_tool_per_token (60/min, keyed by pat:<token_id>) | Same mcp_tool_per_token (60/min, keyed by oauth:<token_id>) |
Sandbox tool access (sandbox_simulate_*, sandbox_clock_advance, sandbox_reset, sandbox_delete) | Yes, with the sandbox:simulate scope on the PAT | No — sandbox:simulate is not in the OAuth scope set (which is structurally fixed at the 4 reads above). Use a PAT for any agent that drives sandbox tools. |
| OAuth scope set | n/a (PAT-specific scopes) | Fixed at projects:read / invoices:read / transactions:read / webhooks:read — write scopes (:write, webhooks:manage, admin:all, sandbox:simulate) are PAT-only by design |
See also
- Connect Claude Code — HTTP transport via OAuth or PAT.
- Connect Cursor — same HTTP transport.
- Connect Claude Desktop — STDIO via
@txnod/mcp-stdiowrapper (PAT-only). - Personal Access Tokens — for the PAT path.