ExMCP.ConsentHandler behaviour (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

A behaviour for handling user consent for accessing external resources.

Summary

Types

The result of a consent request.

When a granted consent expires.

Context about the consent request.

The origin of the resource being accessed (e.g., "https://api.example.com").

The user identifier.

Callbacks

Checks if a valid consent already exists.

Requests user consent to access a resource.

Revokes any existing consent for a user and resource.

Types

consent_result()

@type consent_result() ::
  {:ok, expiry()}
  | {:approved, keyword()}
  | {:denied, keyword()}
  | {:error, :denied | :consent_required}

The result of a consent request.

  • {:ok, expiry}: Consent granted until expiry (see expiry/0).
  • {:approved, opts}: Consent granted; opts[:expires_at] is an expiry/0. When omitted, the :consent_ttl from the request context is used.
  • {:denied, opts} / {:error, :denied}: Consent explicitly denied.
  • {:error, :consent_required}: Consent needs to be obtained through another channel (e.g., a web UI).

Any other return value is treated as an error and the request is denied.

expiry()

@type expiry() ::
  DateTime.t()
  | {:ttl, pos_integer()}
  | {:unix, integer()}
  | {:monotonic, integer()}
  | integer()

When a granted consent expires.

Prefer one of the explicit forms — they are unambiguous:

  • DateTime.t() — absolute wall-clock instant, e.g. DateTime.add(DateTime.utc_now(), 3600)
  • {:ttl, seconds} — relative to now, e.g. {:ttl, 3600} for one hour
  • {:unix, seconds} — absolute Unix epoch seconds, e.g. {:unix, System.os_time(:second) + 3600}
  • {:monotonic, seconds} — absolute System.monotonic_time(:second) value

A bare integer is interpreted as a System.monotonic_time(:second) value for backwards compatibility. This is easy to get wrong: Unix epoch seconds and monotonic seconds are not interchangeable, and a Unix timestamp read as a monotonic value grants consent for decades. Values that are implausible as monotonic times (already in the past, or more than 365 days in the future) are rejected and the request fails closed. Use {:unix, seconds} or {:ttl, seconds} instead of a bare integer.

request_context()

@type request_context() :: map()

Context about the consent request.

resource_origin()

@type resource_origin() :: String.t()

The origin of the resource being accessed (e.g., "https://api.example.com").

user_id()

@type user_id() :: String.t() | atom()

The user identifier.

Callbacks

check_existing_consent(user_id, resource_origin)

@callback check_existing_consent(
  user_id :: user_id(),
  resource_origin :: resource_origin()
) :: {:ok, expires_at :: non_neg_integer()} | {:not_found} | {:expired}

Checks if a valid consent already exists.

This callback is primarily for handlers that might have their own persistent storage, separate from the global ConsentCache. Most handlers can simply return {:not_found} and rely on the cache.

request_consent(user_id, resource_origin, request_context)

@callback request_consent(
  user_id :: user_id(),
  resource_origin :: resource_origin(),
  request_context :: request_context()
) :: consent_result()

Requests user consent to access a resource.

The request_context map carries transport-specific information plus :transport and :consent_ttl. :consent_ttl is the configured consent lifetime in seconds (the :consent_ttl security setting itself is in milliseconds); it is a sensible default to hand back:

@impl ExMCP.ConsentHandler
def request_consent(_user_id, origin, context) do
  if approve?(origin) do
    {:ok, {:ttl, Map.get(context, :consent_ttl, 3600)}}
  else
    {:error, :denied}
  end
end

revoke_consent(user_id, resource_origin)

@callback revoke_consent(user_id :: user_id(), resource_origin :: resource_origin()) ::
  :ok | {:error, String.t()}

Revokes any existing consent for a user and resource.