Liking cljdoc? Tell your friends :D

Authorization

An MCP server over HTTP is an OAuth 2.1 resource server: it publishes protected resource metadata (RFC 9728), requires a bearer token on every request (RFC 6750), and answers a missing or bad token with a WWW-Authenticate challenge that names the metadata, so a client can find the authorization server. presa does this in presa.auth, following snodo_oauth; it is not an authorization server.

The resource server

(require '[clojure.data.json :as json]
         '[presa.auth :as auth]
         '[presa.auth.jwt :as jwt]
         '[presa.client :as client]
         '[presa.server :as server]
         '[presa.transport.http :as http])

;; a key pair standing in for the authorization server's signing key
(def signing-keys (.generateKeyPair (doto (java.security.KeyPairGenerator/getInstance "RSA") (.initialize 2048))))

(def srv
  (server/server
   {:name "secure" :version "1"
    ;; per component: tools, prompts (by name), resources (by URI), templates
    :required-scopes {[:tool "admin"] ["mcp:admin"]}
    :tools [{:name "whoami" :description "Who is calling" :handler (fn [_ ctx] (get-in ctx [:auth :subject]))}
            {:name "admin" :description "Admins only" :handler (fn [_ _] "done")}]}))

(def port (let [probe (http/serve! srv)] ((:stop! probe)) (:port probe)))
(def resource (str "http://127.0.0.1:" port "/mcp"))

(def config
  (auth/resource-server
   {:resource resource
    :verify (jwt/verifier {:audience resource :keys {"k1" (.getPublic signing-keys)}})
    :authorization-servers ["https://auth.example.com"]
    :scopes-supported ["mcp:read" "mcp:admin"]
    :required-scopes ["mcp:read"]}))

(def running (http/serve! srv {:port port :auth config}))

The metadata document is served without a token at its well-known path, derived from the resource:

(auth/metadata-path resource)
;; => "/.well-known/oauth-protected-resource/mcp"

(get (json/read-str (slurp (str "http://127.0.0.1:" port "/.well-known/oauth-protected-resource/mcp")))
     "authorization_servers")
;; => ["https://auth.example.com"]

Every request to the endpoint needs a token:

RequestStatusChallenge
no Authorization: Bearer401Bearer scope="...", resource_metadata="..."
a token the verifier refuses401error="invalid_token"
a token without :required-scopes403error="insufficient_scope", scope="..."
a malformed header400error="invalid_request"

Verifying tokens

:verify is a function of the token string that returns {:subject :client-id :scopes :claims}, or {:error kind :description s}. presa.auth.jwt/verifier checks JWS access tokens: RS256 and ES256 against a JWKS URL (:jwks-url, cached by kid) or static :keys, HS256 with a :secret; then exp and nbf (with :leeway-s), :issuer and :audience. Bring your own function for opaque tokens or introspection.

The caller in handlers

Handlers see the verified identity as (:auth context), never the raw token. :required-scopes on the server hides a component from callers without its scopes and refuses calls to it with -32003, inside a 200 since the request itself was authenticated. The same scopes apply to completing a prompt's or template's arguments and to subscribing to a resource: a caller hears resources/updated only for what it may read. :default-scopes gives the scopes of components without an entry (default none). requestState and tasks are scoped to the subject, so one caller cannot resume another's, and an initialize-era HTTP session belongs to the subject that opened it.

presa.auth.jwt/verifier requires exp in the tokens it accepts, as RFC 9068 does for access tokens; :require-exp? false drops that.

(def token (jwt/sign {"sub" "ada" "aud" resource "scope" "mcp:read"
                      "exp" (+ 600 (quot (System/currentTimeMillis) 1000))}
                     {:alg "RS256" :kid "k1" :key (.getPrivate signing-keys)}))

(def c (client/connect (http/url (:url running) {:headers {"authorization" (str "Bearer " token)}})))

(client/text (client/call-tool c "whoami"))
;; => "ada"

(map #(get % "name") (client/list-tools c))
;; => ["whoami"]

(:code (ex-data (try (client/call-tool c "admin") (catch Exception e e))))
;; => -32003

(client/close c)
((:stop! running))

http/handler's :cancellation :subject lets a posted notifications/cancelled stop a request of the same subject.

The client side

presa.auth.client/provider is a token provider for http/url. On a 401 (or a 403 insufficient_scope) it finds the server's protected resource metadata and its authorization server's metadata, identifies the client (a client ID metadata document, pre-registered credentials, or dynamic registration), runs the authorization code flow with PKCE and the resource indicator, checks the response's iss, exchanges the code, and the request is sent again with the token. A later 403 that asks for more scope runs it again for the union of scopes; a token from another authorization server is never reused.

The browser step is yours: an authorizer gets the authorization URL and returns the URL the authorization server redirected to. loopback-authorizer listens on 127.0.0.1 and calls your function to open a browser; (:stop! authorizer) closes the listener:

(require '[presa.auth.client :as oauth])

(def authorizer (oauth/loopback-authorizer
                 (fn [url] (.exec (Runtime/getRuntime) (into-array ["open" url])))))

(def provider (oauth/provider {:resource "https://mcp.example.com/mcp"
                               :authorizer authorizer
                               :client-name "my-agent"}))

(client/connect (http/url "https://mcp.example.com/mcp" {:token-provider provider}))

:grant :client-credentials with :client-id and :client-secret (or :private-key-pem for private_key_jwt) gets a token without a user.

When the authorization server lists ES256 in dpop_signing_alg_values_supported, the provider binds its tokens to a key with DPoP (RFC 9449): an ES256 key pair per provider, a proof in the DPoP header of each token request, and on each request to the server Authorization: DPoP with a fresh proof that carries the token's hash. A use_dpop_nonce answer, from the authorization server or the MCP server, is retried once with the nonce it gave. :dpop false turns this off. presa.auth.dpop makes the proofs. DPoP is client-side only: presa.auth accepts bearer tokens and does not check DPoP proofs.

Two more grants need no user. :grant :jwt-bearer with :assertion (RFC 7523) trades a JWT for a token, as workload identity federation does; the assertion is a string or a function of {:issuer :resource :token-endpoint}. No client is registered, and a refused grant is final for that provider. id-jag is such a function for enterprise-managed authorization (SEP-990): it exchanges the user's ID token at the identity provider for an ID-JAG (RFC 8693) addressed to the authorization server the flow found.

(oauth/provider {:resource "https://mcp.example.com/mcp"
                 :grant :jwt-bearer
                 :client-id "mcp-client" :client-secret "..."
                 :assertion (oauth/id-jag {:token-endpoint "https://idp.example.com/token"
                                           :id-token id-token
                                           :client-id "idp-client"})})

docs/oauth-client.md is the reference. The conformance runner's scored authorization scenarios (25 at 2026-07-28, 14 at 2025-11-25) and its DPoP, workload identity, enterprise-managed and client credentials extension scenarios pass.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close