Sake.Middleware behaviour (Sake v0.4.3)

Copy Markdown View Source

Host code around every MCP call: audit, rate limits, feature flags and plans, argument rewriting, redaction. A middleware is a module with init/1 and call/2, as a Plug is, declared on the server for the events it handles:

defmodule MyApp.MCP do
  use Sake.Server, name: "my-app", version: "1.0.0"
  tools [MyApp.MCP.Search, MyApp.MCP.Export]

  middleware :before_request, [{MyApp.MCP.RateLimit, per_minute: 60}]
  middleware :before_call, [MyApp.MCP.Tenant, MyApp.MCP.Audit]
  middleware :after_call, [MyApp.MCP.Redact]
  middleware :after_list, [{MyApp.MCP.Plans, []}]
end

defmodule MyApp.MCP.Audit do
  @behaviour Sake.Middleware

  @impl true
  def init(opts), do: opts

  @impl true
  def call(%{tool: tool, arguments: args, ctx: ctx} = payload, _opts) do
    MyApp.Audit.record(ctx.scope, tool.name, args)
    {:cont, payload}
  end
end

call/2 returns {:cont, payload} to go on, with the payload as it is or changed, or {:halt, reason} to refuse. The plugs of an event run in the order they are declared, and a halt stops the request: the later plugs and events do not run, and the client gets the refusal the reason names (see "Halting" below).

init/1 is optional and runs once, when the server compiles, like a Plug's: what it returns is stored in the server module, so it must be plain data (no functions, PIDs or references). Read what changes at runtime, such as configuration, in call/2.

Events

Every payload has :server (the server module whose plugs run), :method (the JSON-RPC method), :ctx (the request's Sake.Context, with the principal in ctx.scope) and :assigns.

A server mounted in another (Sake.Server.mount/2) keeps its middleware around its own components, inside the parent's, and its plugs see the components by the names it gave them; see guides/composing.md.

EventRunsAlso in the payloadA plug may change
:before_requestbefore every request, any method (not the legacy initialize):params:assigns
:after_listafter Sake's own scope and MCP Apps filtering of tools/list, resources/list, resources/templates/list and prompts/list, and before any call reaches a component (below):kind (:tools, :resources or :prompts), :items (the specs):items
:before_callbefore a tool runs, after its arguments were validated:tool, :arguments:arguments, :assigns
:after_callafter a tool ran, or its task ended:tool, :arguments, :result:result
:before_readbefore a resource is read:resource, :uri, :params:params, :assigns
:after_readafter a resource was read:resource, :uri, :params, :result:result
:before_getbefore a prompt is rendered:prompt, :arguments:arguments, :assigns
:after_getafter a prompt was rendered:prompt, :arguments, :result:result

:tool, :resource and :prompt are the component's spec (Sake.Tool.Spec, Sake.Resource.Spec, Sake.Prompt.Spec). :arguments are what the component receives (atom keys), :params a resource template's parameters, and :result the result as the client gets it (a CallToolResult, ReadResourceResult or GetPromptResult with string keys). Sake reads back only the keys in the last column; a plug that continues with one of them of the wrong shape fails the request, as one that raises does.

Assigns

:assigns carries a decision from the before_ plugs to the later plugs of the same request, and to the component, which reads it as ctx.assigns. It starts as %{}; put your keys into the map rather than replace it. It belongs to one request: a retry with the client's input (the modern protocol's requestState) is a request of its own and starts again from %{}, and the :before_ events run again on it. A task's :after_call gets the assigns of the tools/call that started it.

Hiding components

An :after_list plug that drops a component from :items hides it everywhere, not only in the list: before a call, a read, a prompt, a completion or a subscription reaches a component, Sake runs the :after_list plugs again with :items holding just that component (and :method naming the request), and a component they drop is answered as one that does not exist. So the list and the call always agree, from one plug. With :after_list plugs, list results are cacheScope: "private", as they now differ between principals.

Halting

The reason decides what the client gets; anything else is refused without saying why, so an internal reason never reaches the client.

  • :not_found — the answer for a component or method that does not exist (-32602 "Unknown tool: …", for instance).
  • {:forbidden, scopes} — the answer for missing scopes: on HTTP a 403 whose WWW-Authenticate challenge names scopes, so the client can step up to them.
  • :forbidden — a 403 (on HTTP) with no challenge, for a refusal no scope would lift.
  • {:insufficient_user_authentication, %{acr_values: [acr], max_age: seconds | nil}} — the answer for a sign-in too weak or too old (RFC 9470): on HTTP a 401 whose insufficient_user_authentication challenge carries acr_values and max_age, so the client can have the person sign in again. require_authentication/2 returns it.
  • {:error, message} — a -32600 error with message.
  • {:tool_error, message} — on :before_call and :after_call, a tool result with isError: true and message, which the model reads; on other events, as {:error, message}.
  • anything else — a -32600 error, "Request refused".

A plug that raises, throws or exits counts as a halt: it is logged and the client gets a -32603 error, "The request failed.". The request process carries on.

Observing

Middleware decides; telemetry observes. [:sake, :server, :request] covers every request, refused ones too, and a halt emits [:sake, :middleware, :halt] with the event, the plug, the reason and the request's method, component, era, protocol_version and client_name. With OpenTelemetry, the request's span gets sake.middleware.event and sake.middleware.plug.

Step-up authentication

A tool, resource or prompt declares how the person must have signed in with authentication: (see "Step-up authentication" in Sake.Server). When that depends on the arguments, a :before_call (or :before_read, :before_get) plug asks for it with require_authentication/2:

def call(%{tool: %{name: "transfer"}, arguments: %{amount: amount}} = payload, _opts)
    when amount > 1_000,
    do: Sake.Middleware.require_authentication(payload, acr: ["mfa"], max_age: 300)

def call(payload, _opts), do: {:cont, payload}

See guides/middleware.md for worked examples.

Summary

Types

The events a server can declare middleware for.

What a plug receives and, changed or not, continues with.

Why a plug refused the request; see "Halting" for what the client gets.

Functions

The events a server can declare middleware for.

Continues with payload when the request's token says its person signed in as requirement asks (acr: ["mfa"], any one of the list, and max_age: seconds, either optional), and halts with {:insufficient_user_authentication, ...} otherwise, for a 401 the client can step up from. A request without a token (an endpoint without authorization) is not gated, as for authentication:.

Types

event()

@type event() ::
  :before_request
  | :after_list
  | :before_call
  | :after_call
  | :before_read
  | :after_read
  | :before_get
  | :after_get

The events a server can declare middleware for.

payload()

@type payload() :: %{
  :server => module(),
  :method => String.t(),
  :ctx => Sake.Context.t(),
  :assigns => map(),
  optional(atom()) => term()
}

What a plug receives and, changed or not, continues with.

reason()

@type reason() ::
  :not_found
  | :forbidden
  | {:forbidden, [String.t()]}
  | {:insufficient_user_authentication,
     %{acr_values: [String.t()], max_age: non_neg_integer() | nil}}
  | {:error, String.t()}
  | {:tool_error, String.t()}
  | term()

Why a plug refused the request; see "Halting" for what the client gets.

Callbacks

call(payload, opts)

@callback call(payload(), opts :: term()) :: {:cont, payload()} | {:halt, reason()}

init(opts)

(optional)
@callback init(opts :: term()) :: term()

Functions

events()

@spec events() :: [event()]

The events a server can declare middleware for.

require_authentication(payload, requirement)

@spec require_authentication(payload(), keyword()) ::
  {:cont, payload()} | {:halt, reason()}

Continues with payload when the request's token says its person signed in as requirement asks (acr: ["mfa"], any one of the list, and max_age: seconds, either optional), and halts with {:insufficient_user_authentication, ...} otherwise, for a 401 the client can step up from. A request without a token (an endpoint without authorization) is not gated, as for authentication:.