Imp.MCP.OAuth (Imp v0.5.0)

Copy Markdown View Source

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

authorization_header(store, credential, server_url)

@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.

await(pending, timeout \\ 300_000)

@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.

begin(store, server_url, opts \\ [])

@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 to 0, 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; call complete/2 with 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_secret may be nil for a public client) and needs :client_issuer; {:cimd, url} names a Client ID Metadata Document. See ExMCP.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.

cancel(pending)

@spec cancel(Imp.MCP.OAuth.Pending.t()) :: :ok

Abandons a pending authorization and closes its loopback listener.

complete(pending, callback_params)

@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.

default_reference(server_url)

@spec default_reference(String.t()) :: String.t()

The reference begin/3 uses for a server URL when none is given.

forget(store, credential)

@spec forget(Imp.MCP.OAuth.Store.t(), String.t()) :: :ok | {:error, term()}

Removes a stored credential. Returns :ok whether or not one existed.

store(opts)

@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.

stored?(store, credential)

@spec stored?(Imp.MCP.OAuth.Store.t(), String.t()) :: boolean()

True when a credential file exists for this reference.