An extension adds negotiated behavior without changing the core protocol profile. It implements Snodo.Extension, declares exact-versioned Snodo.Extension.Method values, and is installed on the server:

use Snodo.Server,
  name: "my-server",
  version: "1.0.0",
  extensions: [MyApp.Extension]

Runtime construction rejects duplicate extension IDs, methods that collide with any core method (including unsupported and MRTR-only ones), and collisions between extensions. An installed route runs only when both peers advertise the extension and its callback accepts the negotiation. An advertised, compatible extension may also wrap core dispatch and contribute HTTP policy through generic hooks. An installed but unadvertised extension does nothing.

Middleware that stores or defers a call instead of passing it on calls Snodo.Extension.check_dispatch/3 first, so the deferred work passes the same lookup, authorization, and argument checks as a direct call.

See examples/06_custom_extension.exs.

Declared extensions

Some extensions add no methods: the server advertises them, reads what the client declared, and attaches the extension's _meta to tools, resources, or results. Declare one with its server settings instead of writing a module:

use Snodo.Server,
  name: "my-server",
  version: "1.0.0",
  extensions: [{:declared, "com.example/charts", %{"version" => "1"}}]

The runtime advertises the id under capabilities["extensions"] with those settings; an entry you put there yourself wins. In a handler, Snodo.Context.extension_settings/2 returns the settings the client sent for the id, or nil when the client did not declare it, so the handler can fall back to plain content:

tool "sales_chart" do
  @impl true
  def call(_arguments, context) do
    case Snodo.Context.extension_settings(context, "com.example/charts") do
      nil ->
        {:ok, "Q3 sales: 120, 140, 180"}

      _client_settings ->
        {:ok,
         Snodo.Result.text("Q3 sales: 120, 140, 180",
           metadata: %{"com.example/charts" => %{"kind" => "bar", "series" => [120, 140, 180]}}
         )}
    end
  end
end

For a published extension such as MCP Apps (io.modelcontextprotocol/ui), attach the _meta keys its specification defines. A declared extension has no routes, middleware, or negotiation callback; write a Snodo.Extension module when you need any of those.

Tasks

snodo_tasks implements the io.modelcontextprotocol/tasks extension on those hooks. It augments tools/call, keeps tasks/get, tasks/update, and tasks/cancel outside the core catalog, and adds taskIds filters and notifications/tasks to subscriptions.

The application owns the store and runner. The package includes memory and DETS stores; snodo_tasks_postgres and snodo_tasks_sqlite add transactional stores behind an application-owned Ecto Repo. Recovery is at-least-once, so work must use its stable idempotency key for external effects.

Examples 07, 08, 09, 17, and 26 run from extensions/tasks; example 10 from extensions/tasks_postgres with a live database; example 11 from extensions/tasks_sqlite.