Browser-authorized OAuth credentials for remote HTTP MCP servers.
A host that runs Imp programs — a long-running application, a desktop
client, a script — can declare an MCP server whose Authorization header it
does not know yet. A
person authorizes once in a browser on the machine that runs the host, the
resulting grant is written to disk encrypted, and a short-lived
Authorization header is materialized only when a connection is built.
Refreshing happens without the person.
ExMCP provides the protocol pieces: the hardened metadata fetch, client
registration, PKCE, callback validation, token exchange and refresh. Imp
walks them in order for a person in a browser (see begin/3) and owns where
the grant lives, what protects it, and when a header is produced.
Using it
store = Imp.MCP.OAuth.store(directory: "~/.imp/mcp", secret: secret)
{:ok, pending} =
Imp.MCP.OAuth.begin(store, "https://mcp2.readwise.io/mcp", credential: "readwise")
# Open pending.authorization_url in a browser on this machine.
{:ok, "readwise"} = Imp.MCP.OAuth.await(pending)begin/3 listens on 127.0.0.1 for the redirect, so await/2 blocks until
the person finishes in the browser. A host that already owns an HTTP route
for the redirect passes redirect_uri: instead, keeps the pending value
server-side, and calls complete/2 with the callback's query parameters. A
host doing that dispatches each callback to the right pending value by
pending.state, which is the state parameter in the authorization URL.
After that, the server descriptor names the credential rather than a token:
%{
"name" => "readwise",
"type" => "http",
"url" => "https://mcp2.readwise.io/mcp",
"auth" => %{"type" => "oauth", "credential" => "readwise"}
}Imp.MCP.connect(servers, credential_store: store, ...) resolves that to a header
at connect time. See Imp.MCP.Connections.
A credential belongs to one server
A grant is bound to the exact resource URL it was authorized for.
authorization_header/3 takes that URL and refuses with
{:error, {:mcp_oauth_credential_binding_mismatch, credential}} when it does
not match the stored one, so a descriptor cannot point a token minted for one
server at a different server by naming its credential. The comparison is an
exact string comparison: re-authorize when a server's URL changes.
When the grant is refreshed
The stored record keeps the expiry the authorization server stated.
authorization_header/3 refreshes when that expiry is within a minute.
expires_in is optional in RFC 6749, and some servers send it as a string.
A string that parses is used as seconds. When there is no usable expiry at
all, the lifetime is unknown, and an unknown lifetime is not treated as a
long one: a fresh access token is minted on every call while a refresh token
is available, so the same possibly-dead token is not handed out twice. With
no refresh token and no expiry, the stored token is returned as it is and a
rejection surfaces at the server.
A refresh the authorization server answers with invalid_grant — a revoked
or already-rotated refresh token — is reported as
{:error, {:mcp_oauth_reauthorization_required, credential}}, which a host
can tell apart from a transport failure worth retrying.
What protects the stored grant, and what does not
Each credential is one file under the directory the host names, written with
owner-only permissions. The whole record — access token, refresh token, any
client secret, the token endpoint, the client id — is encrypted with
AES-256-GCM. The key is derived with HKDF-SHA256 from the secret the host
passes to store/1; the credential reference is authenticated as additional
data, so a file copied or renamed to another reference is refused instead of
decoded. A file whose bytes were changed is refused as a whole; there is no
partial decode.
That protects a copy of the file taken by someone who does not have the host's secret: a stray backup, a synced directory, a disk image.
It does not protect against anyone who can read the host's secret or its memory, or run code as the host's user — they can materialize the same header the host can. It is not a substitute for the file permissions, and it does not expire a stolen refresh token. The host is responsible for keeping its secret out of its repository and out of its logs.
Where a token can still appear
This module never writes a token to a log, never puts one in the server
descriptor, and hides its key material and the pending transaction from
inspect/1.
Once handed over, the header lives in the ExMCP.Client process's transport
state, as a static "headers" entry does. Imp's Inspect implementation for
ExMCP.Client and ExMCP.Transport.HTTP prints every header value as
[REDACTED], so the client's crash report and inspect(:sys.get_state(pid))
do not show it. Anything that prints the state without that implementation
does: inspect(state, structs: false), Erlang's ~p, a log handler that
formats the raw report, and anyone who can read the host's memory.
Concurrency
Concurrent callers in one VM are serialized per credential, so a rotating
refresh token is redeemed once and the others read the token it produced.
That lock is :global, so it covers the connected Erlang cluster and nothing
else. Two separate host operating-system processes pointed at the same
directory are not serialized against each other: give each host its own
credential directory, or expect one of them to have to re-authorize when a
rotating refresh token is redeemed twice.
Summary
Functions
Materializes a short-lived Authorization header for one server.
Waits for the loopback redirect and completes the flow.
Begins authorization for one MCP server URL.
Abandons a pending authorization and closes its loopback listener.
Completes a flow from the callback's query parameters.
The reference begin/3 uses for a server URL when none is given.
Removes a stored credential. Returns :ok whether or not one existed.
Describes where credentials live and what protects them.
True when a credential file exists for this reference.
Functions
@spec authorization_header(Imp.MCP.OAuth.Store.t(), String.t(), String.t()) :: {:ok, {String.t(), String.t()}} | {:error, term()}
Materializes a short-lived Authorization header for one server.
server_url is the server the header is for. It must equal the URL the
grant was authorized for, or this refuses with
{:mcp_oauth_credential_binding_mismatch, credential} — a credential is not
a bearer token a host can point anywhere.
Refreshes first when the stored access token is within
60 seconds of expiry, or whenever the authorization
server stated no usable expiry, using the stored refresh token and without
the person. The refreshed grant is written back before the header is
returned. Returns {"Authorization", "Bearer " <> token}.
Concurrent callers in this VM are serialized per credential so a rotating refresh token is redeemed once.
@spec await(Imp.MCP.OAuth.Pending.t(), timeout()) :: {:ok, String.t()} | {:error, term()}
Waits for the loopback redirect and completes the flow.
Returns the credential reference the grant was stored under. Only valid for a
pending value that owns a loopback listener; a host that passed
:redirect_uri calls complete/2 itself.
@spec begin(Imp.MCP.OAuth.Store.t(), String.t(), keyword()) :: {:ok, Imp.MCP.OAuth.Pending.t()} | {:error, term()}
Begins authorization for one MCP server URL.
Returns an Imp.MCP.OAuth.Pending whose :authorization_url the host opens
in a browser on this machine. By default a loopback listener is opened on
127.0.0.1 with an ephemeral port and await/2 completes the flow when the
browser is redirected back. The listener answers only the redirect carrying
this flow's state; anything else that reaches the port gets a 404 and the
listener keeps waiting, so a stray local request cannot consume it.
The grant is bound to server_url. authorization_header/3 refuses to
produce a header for any other URL.
Options:
:credential— the reference this grant is stored under. Defaults to a stable reference derived from the server URL.:redirect_port— a fixed loopback port. Defaults to0, an ephemeral port chosen by the operating system. Use a fixed port when the authorization server only accepts pre-registered redirect URIs.:redirect_uri— the host owns the redirect instead. No loopback listener is opened; callcomplete/2with the callback parameters.:scopes— scopes to request. Defaults to what the resource advertises.:client_registration— how the client is identified to the authorization server. Defaults to:auto: dynamic registration when the server offers it.{:pre_registered, client_id, client_secret}uses a client registered ahead of time (client_secretmay benilfor a public client) and needs:client_issuer;{:cimd, url}names a Client ID Metadata Document. SeeExMCP.Authorization.RegistrationPolicy.:client_issuer— the issuer of the authorization server a pre-registered client was registered with. The flow refuses to begin when the server names a different one, so the client's secret only goes where it was issued.
@spec cancel(Imp.MCP.OAuth.Pending.t()) :: :ok
Abandons a pending authorization and closes its loopback listener.
@spec complete(Imp.MCP.OAuth.Pending.t(), map()) :: {:ok, String.t()} | {:error, term()}
Completes a flow from the callback's query parameters.
callback_params is the decoded query string of the redirect — "code" and
"state", or "error" when the person declined. Returns the credential
reference the grant was stored under.
The reference begin/3 uses for a server URL when none is given.
@spec forget(Imp.MCP.OAuth.Store.t(), String.t()) :: :ok | {:error, term()}
Removes a stored credential. Returns :ok whether or not one existed.
@spec store(keyword()) :: Imp.MCP.OAuth.Store.t()
Describes where credentials live and what protects them.
Options:
:directory(required) — the directory the host owns. Created with owner-only permissions if absent.~is expanded.:secret(required) — at least 32 bytes of host secret. Use random bytes the host stores outside its repository, not a passphrase; this is a key derivation, not a password hash.
@spec stored?(Imp.MCP.OAuth.Store.t(), String.t()) :: boolean()
True when a credential file exists for this reference.