Extending Anubis

Copy Markdown

Anubis is a protocol framework, not a closed application. Almost every seam is an @behaviour you can implement, and every adapter is selected by option, so swapping one never requires patching another.

This page lists the extension points, what each one is for, and how it gets wired. It is a map, not a tutorial: the guides linked per section carry the examples.

Server components

Tools, prompts and resources are the units users actually see. Each is a module using Anubis.Server.Component, which injects the callbacks for the type you name.

BehaviourImplementsNotes
Anubis.Server.Component.Tooluse Anubis.Server.Component, type: :toolexecute/2, compiled schema/output_schema, :task_support
Anubis.Server.Component.Promptuse Anubis.Server.Component, type: :promptget_messages/2, argument schema
Anubis.Server.Component.Resourceuse Anubis.Server.Component, type: :resourceread/2, :uri or :uri_template, :mime_type

Schemas are Peri schemas, compiled at build time into the JSON Schema advertised to clients and reused for argument validation. See Building a Server.

Client transports

A client transport is a GenServer that owns the wire. Implement Anubis.Transport.Behaviour:

defmodule MyApp.Transport.WebSocket do
  use GenServer
  @behaviour Anubis.Transport.Behaviour

  @impl true
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  @impl true
  def send_message(pid, message, opts), do: GenServer.call(pid, {:send, message}, opts[:timeout])

  @impl true
  def shutdown(pid), do: GenServer.stop(pid)

  @impl true
  def supported_protocol_versions, do: ["2025-06-18", "2025-11-25"]
end

Three callbacks are required (start_link/1, send_message/3, shutdown/1, supported_protocol_versions/0). Four are optional and only used if exported: transport_init/1, parse/2, encode/2 and extract_metadata/2. They exist for transports that own parse state (newline-delimited JSON, SSE chunks, HTTP bodies); leave them out if you always hand the client fully-decoded messages.

See Transports.

Server transports

Server transports are selected by transport: on use Anubis.Server. The shipped ones are :stdio and :streamable_http. A transport never touches session internals: it resolves a session reference and delegates through Anubis.Server.Transport.Session. Implement that instead of reaching into Anubis.Server.Session.

Session dispatch

Anubis.Server.Transport.Session is the contract between a transport and the session process. The default (…Session.Local) uses GenServer.call/2 and cast/2. Swap it when sessions live on another node, or when the reply shape needs to differ:

config :anubis_mcp, :session_dispatcher, MyApp.ClusterDispatcher

The wire shapes ({:mcp_request, message, context} and friends) are part of the contract, so a custom dispatcher can change the delivery without breaking the session.

Session registry

Anubis.Server.Registry maps session ids to processes. STDIO defaults to Registry.None (one session, no lookup needed); HTTP defaults to Registry.Local. Pass a {module, opts} pair to choose your own:

use Anubis.Server, transport: :streamable_http, registry: {MyApp.RedixRegistry, pool: 10}

The registry also owns deterministic session naming, so it must resolve the same id to the same name on every node.

Persistence adapters

BehaviourOptionPurpose
Anubis.Server.Session.Store:session_store (app env)Persist session state across server restarts
Anubis.Server.TaskStoretask_store: {module, opts}Back Anubis.Server.Task entries with Redis, ETS, a database
Anubis.Server.Transport.StreamableHTTP.EventStoreevent_store: {module, opts}Record SSE events so a dropped stream can resume from a Last-Event-ID

The session store is configured globally because it starts the adapter process under the server supervisor:

config :anubis_mcp, session_store: [enabled: true, adapter: MyApp.RedixSessionStore, ttl: :timer.minutes(30)]

Task and event stores are always per-server.

Authorization

Anubis.Server.Authorization.Validator decides whether a bearer token is acceptable. The bundled JWTValidator verifies against a JWKS URI; anything else (introspection, a session table, an mTLS identity) is a module implementing validate/2:

use Anubis.Server,
  transport: :streamable_http,
  authorization: [
    authorization_servers: ["https://auth.example.com"],
    resource: "https://api.example.com",
    validator: {MyApp.IntrospectionValidator, endpoint: "https://auth.example.com/introspect"}
  ]

Authorization only applies to HTTP transports, per the MCP specification. Scopes are enforced separately — see Authorization.

Protocol dialects

Each supported MCP version has a module implementing Anubis.Protocol.Behaviour, holding the version-specific logic (which methods exist, which capabilities they imply, how requests are shaped). Anubis.Protocol.Registry picks one during the initialize handshake. To track a version that Anubis does not ship yet, implement the behaviour and register the module.

Telemetry

Not a behaviour, but the observability seam: every event is namespaced under [:anubis_mcp, …] and listed in Anubis.Telemetry. Tool calls are spans, so :start/:stop/:exception all fire. Payloads are opt-in — set :telemetry_capture_tool_payload only when you are willing to log arguments and results.