Authorization is an optional seam, not a role system. The application supplies a policy module; snodo calls it and enforces the decision.

defmodule MyApp.Policy do
  @behaviour Snodo.Authorization

  @impl true
  def authorize(_phase, %Snodo.Authorization.Component{kind: :tool, name: "admin_reset"}, context, _options) do
    if admin?(context.auth), do: :ok, else: {:error, Snodo.Error.authorization(-32_003, "Not permitted")}
  end

  def authorize(_phase, _component, _context, _options), do: :ok

  defp admin?(%{"role" => "admin"}), do: true
  defp admin?(_auth), do: false
end

defmodule MyApp.Server do
  use Snodo.Server,
    name: "my-server",
    version: "1.0.0",
    authorization: {MyApp.Policy, []}
end

Where it applies

The seam sits inside the router, below every transport and dialect, so direct dispatch, stdio, the native HTTP listener, and Plug share one decision.

PhaseOperationsEffect of a refusal
:discoverytools/list, prompts/list, resources/list, resources/templates/listthe component is left out of the list
:invocationtools/call, prompts/get, resources/read, completion/completeby default the request fails with the policy's own Snodo.Error, before argument validation and before any application callback

Filtering happens before paging, so a cursor belongs to the catalog that context can see and expires if replayed against a different one.

Concealing refused components

Set refusal: :conceal in the policy options to return the same JSON-RPC error as an unknown component when the policy refuses an invocation:

authorization: {MyApp.Policy, [refusal: :conceal]}

Map options with an atom :refusal key work too. The policy still receives the options unchanged and runs for the refused invocation, so it can record an audit event. Discovery filtering and policy faults retain their usual behavior. For initialize-era tool calls, the policy decides before the server reports that a tool's schema cannot be expressed in that dialect.

For a resource template, component.uri is the registered template, while component.requested_uri is the concrete URI on resources/read and on a subscription read check. A policy can refuse one URI without hiding or refusing the whole template:

def authorize(:invocation,
      %Snodo.Authorization.Component{
        kind: :resource_template,
        requested_uri: "file:///private/report"
      }, _context, _options) do
  {:error, Snodo.Error.authorization(-32_003, "Not permitted")}
end

requested_uri is nil during discovery and template completion because neither names a concrete resource. It is also nil for other component kinds.

The Tasks extension runs the :invocation check, and argument validation, when it accepts a task-augmented tools/call, before it stores anything. Work that a durable executor later runs has already passed the check. Inside the task worker, context.request_method is still "tools/call".

What it does not do

  • It does not authenticate. context.auth is whatever the transport or application put there: a Plug pipeline, the native listener's request gate, or auth: on Snodo.Client.direct/2 (Snodo.Test.Assertions.client_as/3) in tests.
  • It defines no roles, scopes, or refusal codes. The application chooses the code (JSON-RPC reserves -32000 to -32099 for implementation-defined errors). For OAuth scopes, snodo_oauth ships Snodo.OAuth.ResourceServer.ScopePolicy, a policy keyed on the scopes a bearer token grants.
  • It does not log. The callback is the place to record refusals.
  • It does not filter list-changed notifications, which carry no component data. Subscription sources receive the same context for anything else.

A subscriptions/listen request's resourceSubscriptions pass through the :invocation check as reads: a resource the caller may not read is left out of the accepted filter, so its updates are never delivered. A URI that no resource matches is kept, since there is no component to decide on.

With a policy, the tools, prompts, and resources caches must use scope "private": results differ per principal, and a public hint would let a shared cache hand one principal's result to another. Snodo.Server.Runtime.new/1 raises otherwise.

A policy that raises or returns anything else is a fault: the operation fails rather than silently emptying a catalog. The callback runs once per listed component, so keep it cheap.

Example

examples/23_authorization.exs.