OAuth 2.1 client for Model Context Protocol servers (spec 2025-06-18).
Flow — on HTTP 401 from an MCP server, we:
WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728),
fall back to ${origin}/.well-known/oauth-protected-resource;authorization_servers[0];.well-known/oauth-authorization-server (RFC 8414) or
.well-known/openid-configuration for endpoints + capabilities;client_id;http://127.0.0.1:<ephemeral>/mcp-callback) — HEADLESS: start-authorization!
binds the one-shot listener and RETURNS the URL for whoever is
authorizing (often on a different device than the daemon), and
finish-authorization! lands the code either from that listener or from
a redirect URL pasted back. Nothing blocks waiting for a human;~/.vis/mcp-tokens/<server>.edn;com.blockether.vis.internal.oauth/make-file-refresher.The returned bearer-fn is a 0/1-arg function: 0-arg yields the current
bearer token; 1-arg (with the token the server just rejected) forces a
refresh. It never opens a browser and never waits — with nothing to refresh
it throws :mcp/oauth-required, which callers turn into sign in.
OAuth 2.1 client for Model Context Protocol servers (spec `2025-06-18`).
Flow — on HTTP 401 from an MCP server, we:
1. read `WWW-Authenticate: Bearer resource_metadata="..."` (RFC 9728),
fall back to `${origin}/.well-known/oauth-protected-resource`;
2. GET the resource-metadata JSON → pick an `authorization_servers[0]`;
3. GET its `.well-known/oauth-authorization-server` (RFC 8414) or
`.well-known/openid-configuration` for endpoints + capabilities;
4. dynamic-client-register (RFC 7591) if the AS supports it, or use
the caller-supplied `client_id`;
5. run PKCE S256 authorization-code with a loopback redirect
(`http://127.0.0.1:<ephemeral>/mcp-callback`) — HEADLESS: `start-authorization!`
binds the one-shot listener and RETURNS the URL for whoever is
authorizing (often on a different device than the daemon), and
`finish-authorization!` lands the code either from that listener or from
a redirect URL pasted back. Nothing blocks waiting for a human;
6. exchange code → access + refresh tokens; persist to
`~/.vis/mcp-tokens/<server>.edn`;
7. on later expiry / 401, refresh the token single-flight through
`com.blockether.vis.internal.oauth/make-file-refresher`.
The returned `bearer-fn` is a 0/1-arg function: 0-arg yields the current
bearer token; 1-arg (with the token the server just rejected) forces a
refresh. It never opens a browser and never waits — with nothing to refresh
it throws `:mcp/oauth-required`, which callers turn into `sign in`.(cancel-authorization! flow-id)Forget an abandoned flow and release its loopback listener now.
Forget an abandoned flow and release its loopback listener now.
(complete-authorization! flow-id input)Finish flow flow-id with what the user pasted back: the redirect URL their
browser landed on, or a bare authorization code.
Finish flow `flow-id` with what the user pasted back: the redirect URL their browser landed on, or a bare authorization code.
(forget! server-name)Drop persisted tokens for server-name (e.g. on a gateway sign-out, or when a
401 recurs after refresh).
Drop persisted tokens for `server-name` (e.g. on a gateway sign-out, or when a 401 recurs after refresh).
(make-bearer-fn server-name server-url www-auth-atom)Build a 0/1-arg fn returning the current Bearer token string for server-name.
0-arg yields the persisted token; 1-arg (the token the server just rejected)
forces a single-flight refresh. server-url is the MCP endpoint and
www-auth-atom is the atom the HTTP transport keeps pointed at the latest
WWW-Authenticate header, so a refusal can carry the LIVE 401 details.
It never runs an interactive authorization. Every caller sits on a path a
human is waiting on - a tool call, a reconcile tick, the health loop - and the
browser dance blocked each of them for up to five minutes on a server nobody
had signed into yet, which looked exactly like a hung daemon. With nothing to
refresh it throws :mcp/oauth-required immediately; authorizing is the
explicit headless flow below, which hands back a URL instead of waiting for
one.
Build a 0/1-arg fn returning the current Bearer token string for `server-name`. 0-arg yields the persisted token; 1-arg (the token the server just rejected) forces a single-flight refresh. `server-url` is the MCP endpoint and `www-auth-atom` is the atom the HTTP transport keeps pointed at the latest `WWW-Authenticate` header, so a refusal can carry the LIVE 401 details. It never runs an interactive authorization. Every caller sits on a path a human is waiting on - a tool call, a reconcile tick, the health loop - and the browser dance blocked each of them for up to five minutes on a server nobody had signed into yet, which looked exactly like a hung daemon. With nothing to refresh it throws `:mcp/oauth-required` immediately; authorizing is the explicit headless flow below, which hands back a URL instead of waiting for one.
(poll-authorization! flow-id)Read a flow's verdict without blocking: pending, ok, or error. This is how
a client learns that the loopback listener already finished the flow for it.
Read a flow's verdict without blocking: `pending`, `ok`, or `error`. This is how a client learns that the loopback listener already finished the flow for it.
(start-authorization! server-name server-url {:keys [www-auth auth-hint]})Begin a HEADLESS OAuth flow for server-name at server-url and return its
public view {flow_id, server, kind, url, redirect_uri, expires_at_ms, status} — string-keyed, like every MCP surface.
The caller shows url; the user authorizes in their own browser. NO browser
is opened here — the user may be nowhere near this machine. The flow completes
by itself when that browser can reach the loopback listener on this host (the
local TUI case), otherwise the client posts the redirect URL back through
complete-authorization!.
Begin a HEADLESS OAuth flow for `server-name` at `server-url` and return its
public view `{flow_id, server, kind, url, redirect_uri, expires_at_ms,
status}` — string-keyed, like every MCP surface.
The caller shows `url`; the user authorizes in their own browser. NO browser
is opened here — the user may be nowhere near this machine. The flow completes
by itself when that browser can reach the loopback listener on this host (the
local TUI case), otherwise the client posts the redirect URL back through
`complete-authorization!`.(token-status server-name)Non-secret view of the persisted OAuth tokens for server-name. Never returns
the token itself: a client may render it, and a client is never trusted with
credentials.
Non-secret view of the persisted OAuth tokens for `server-name`. Never returns the token itself: a client may render it, and a client is never trusted with credentials.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |