diff --git a/lib/anubis/mcp/error.ex b/lib/anubis/mcp/error.ex index a5efdb4c..adbbf3c5 100644 --- a/lib/anubis/mcp/error.ex +++ b/lib/anubis/mcp/error.ex @@ -12,6 +12,14 @@ defmodule Anubis.MCP.Error do - **Resource Errors**: MCP-specific resource handling errors - **Execution Errors**: Tool and operation execution failures + ## Error code ranges + + MCP partitions the JSON-RPC implementation-defined range: `-32000` to + `-32019` holds codes allocated before the policy existed (such as `-32002` + for a missing resource, and the generic `-32000` used by transport and + execution errors), while `-32020` to `-32099` is reserved for codes the + specification defines. + ## Examples # Protocol errors @@ -25,6 +33,10 @@ defmodule Anubis.MCP.Error do # Resource errors Anubis.MCP.Error.resource(:not_found, %{uri: "file:///missing.txt"}) + # Reserved errors whose payload the specification pins down + Anubis.MCP.Error.unsupported_protocol_version("1900-01-01", ["2026-07-28"]) + Anubis.MCP.Error.missing_required_client_capability(%{"elicitation" => %{}}) + # Execution errors with custom messages Anubis.MCP.Error.execution("Database connection failed", %{retries: 3}) @@ -54,6 +66,10 @@ defmodule Anubis.MCP.Error do # MCP-specific error codes @resource_not_found -32_002 + @header_mismatch -32_020 + @missing_required_client_capability -32_021 + @unsupported_protocol_version -32_022 + # Generic server error code for custom errors @server_error -32_000 @@ -65,6 +81,9 @@ defmodule Anubis.MCP.Error do invalid_params: "Invalid params", internal_error: "Internal error", resource_not_found: "Resource not found", + header_mismatch: "Header mismatch", + missing_required_client_capability: "Missing required client capability", + unsupported_protocol_version: "Unsupported protocol version", server_error: "Server error" } @@ -80,6 +99,11 @@ defmodule Anubis.MCP.Error do - `:method_not_found` - The method does not exist - `:invalid_params` - Invalid method parameters - `:internal_error` - Internal JSON-RPC error + - `:header_mismatch` - Transport headers disagree with the request body + - `:missing_required_client_capability` - The request needs a capability the + client did not declare + - `:unsupported_protocol_version` - The requested protocol version is not + implemented ## Examples @@ -88,6 +112,9 @@ defmodule Anubis.MCP.Error do iex> Anubis.MCP.Error.protocol(:method_not_found, %{method: "foo"}) %Anubis.MCP.Error{code: -32601, reason: :method_not_found, message: "Method not found", data: %{method: "foo"}} + + iex> Anubis.MCP.Error.protocol(:unsupported_protocol_version, %{supported: ["2026-07-28"], requested: "1900-01-01"}) + %Anubis.MCP.Error{code: -32022, reason: :unsupported_protocol_version, message: "Unsupported protocol version", data: %{supported: ["2026-07-28"], requested: "1900-01-01"}} """ @spec protocol(atom(), map()) :: t() def protocol(reason, data \\ %{}) @@ -137,6 +164,33 @@ defmodule Anubis.MCP.Error do } end + def protocol(:header_mismatch, data) do + %__MODULE__{ + code: @header_mismatch, + reason: :header_mismatch, + message: @error_messages.header_mismatch, + data: data + } + end + + def protocol(:missing_required_client_capability, data) do + %__MODULE__{ + code: @missing_required_client_capability, + reason: :missing_required_client_capability, + message: @error_messages.missing_required_client_capability, + data: data + } + end + + def protocol(:unsupported_protocol_version, data) do + %__MODULE__{ + code: @unsupported_protocol_version, + reason: :unsupported_protocol_version, + message: @error_messages.unsupported_protocol_version, + data: data + } + end + @doc """ Creates a transport-level error. @@ -162,6 +216,41 @@ defmodule Anubis.MCP.Error do } end + @doc """ + Creates an `UnsupportedProtocolVersion` error for a version this peer does + not implement. + + The specification requires the `data` payload to carry both the versions + this peer supports and the version that was requested, so prefer this over + `protocol/2` to build the payload for you. + + ## Examples + + iex> Anubis.MCP.Error.unsupported_protocol_version("1900-01-01", ["2026-07-28"]) + %Anubis.MCP.Error{code: -32022, reason: :unsupported_protocol_version, message: "Unsupported protocol version", data: %{supported: ["2026-07-28"], requested: "1900-01-01"}} + """ + @spec unsupported_protocol_version(String.t(), [String.t()]) :: t() + def unsupported_protocol_version(requested, supported) when is_binary(requested) and is_list(supported) do + protocol(:unsupported_protocol_version, %{supported: supported, requested: requested}) + end + + @doc """ + Creates a `MissingRequiredClientCapability` error for a request that needs a + capability the client did not declare. + + `capabilities` is a `ClientCapabilities` map — the capabilities required to + process the request, not a list of their names. + + ## Examples + + iex> Anubis.MCP.Error.missing_required_client_capability(%{"elicitation" => %{}}) + %Anubis.MCP.Error{code: -32021, reason: :missing_required_client_capability, message: "Missing required client capability", data: %{requiredCapabilities: %{"elicitation" => %{}}}} + """ + @spec missing_required_client_capability(map()) :: t() + def missing_required_client_capability(capabilities) when is_map(capabilities) do + protocol(:missing_required_client_capability, %{requiredCapabilities: capabilities}) + end + @doc """ Creates a resource-specific error. @@ -296,6 +385,9 @@ defmodule Anubis.MCP.Error do defp reason_from_code(@invalid_params), do: :invalid_params defp reason_from_code(@internal_error), do: :internal_error defp reason_from_code(@resource_not_found), do: :resource_not_found + defp reason_from_code(@header_mismatch), do: :header_mismatch + defp reason_from_code(@missing_required_client_capability), do: :missing_required_client_capability + defp reason_from_code(@unsupported_protocol_version), do: :unsupported_protocol_version defp reason_from_code(_), do: :server_error defp default_message(reason) do diff --git a/lib/anubis/protocol.ex b/lib/anubis/protocol.ex index 21577fa7..7970a1d3 100644 --- a/lib/anubis/protocol.ex +++ b/lib/anubis/protocol.ex @@ -17,6 +17,7 @@ defmodule Anubis.Protocol do @type version :: String.t() @type feature :: atom() + @type era :: Anubis.Protocol.Behaviour.era() @doc """ Returns all supported protocol versions. @@ -25,11 +26,30 @@ defmodule Anubis.Protocol do defdelegate supported_versions(), to: Registry @doc """ - Returns the latest supported protocol version. + Returns the supported protocol versions belonging to an era. + """ + @spec supported_versions(era()) :: [version()] + defdelegate supported_versions(era), to: Registry, as: :versions_for_era + + @doc """ + Returns the era a protocol version belongs to. + """ + @spec era(version()) :: {:ok, era()} | :error + defdelegate era(version), to: Registry + + @doc """ + Returns the latest protocol version reachable through the `initialize` + handshake. """ @spec latest_version() :: version() defdelegate latest_version(), to: Registry + @doc """ + Returns the latest supported protocol version of an era. + """ + @spec latest_version(era()) :: version() | nil + defdelegate latest_version(era), to: Registry + @doc """ Returns the fallback protocol version for compatibility. """ @@ -37,17 +57,22 @@ defmodule Anubis.Protocol do defdelegate fallback_version(), to: Registry @doc """ - Validates if a protocol version is supported. + Validates that a protocol version can be negotiated with the `initialize` + handshake. + + Only `:legacy` versions qualify. A `:stateless` version is registered and + valid, but it is not reachable through the handshake, so accepting it here + would report success for a connection that cannot be established. """ @spec validate_version(version()) :: :ok | {:error, Error.t()} def validate_version(version) do - if Registry.supported?(version) do + if era(version) == {:ok, :legacy} do :ok else {:error, Error.protocol(:invalid_params, %{ version: version, - supported: supported_versions() + supported: supported_versions(:legacy) })} end end diff --git a/lib/anubis/protocol/registry.ex b/lib/anubis/protocol/registry.ex index 2f6be70b..ddc7828f 100644 --- a/lib/anubis/protocol/registry.ex +++ b/lib/anubis/protocol/registry.ex @@ -5,13 +5,28 @@ defmodule Anubis.Protocol.Registry do Maps version strings to their implementing modules, supports version negotiation, and provides the central dispatch point for version-specific protocol logic. + ## Eras + + Registered versions group into the eras defined by + `Anubis.Protocol.Behaviour` — `:legacy` versions negotiate a session with + the `initialize` handshake, `:stateless` versions carry their metadata on + every request. The grouping is derived from each module's `c:Anubis.Protocol.Behaviour.era/0` + callback, so registering a version is still a single entry in `@versions`. + + Because the two eras open a connection in structurally different ways, they + negotiate separately: `negotiate/1` and `negotiate/2` serve the `initialize` + handshake and therefore only ever resolve to a `:legacy` version. + ## Usage iex> Anubis.Protocol.Registry.get("2025-11-25") {:ok, Anubis.Protocol.V2025_11_25} iex> Anubis.Protocol.Registry.supported_versions() - ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + + iex> Anubis.Protocol.Registry.versions_for_era(:stateless) + ["2026-07-28"] iex> Anubis.Protocol.Registry.negotiate("2025-03-26") {:ok, "2025-03-26", Anubis.Protocol.V2025_03_26} @@ -21,13 +36,19 @@ defmodule Anubis.Protocol.Registry do "2024-11-05" => Anubis.Protocol.V2024_11_05, "2025-03-26" => Anubis.Protocol.V2025_03_26, "2025-06-18" => Anubis.Protocol.V2025_06_18, - "2025-11-25" => Anubis.Protocol.V2025_11_25 + "2025-11-25" => Anubis.Protocol.V2025_11_25, + "2026-07-28" => Anubis.Protocol.V2026_07_28 } - @latest_version "2025-11-25" + @versions_by_era @versions + |> Enum.group_by(fn {_version, mod} -> mod.era() end, fn {version, _mod} -> version end) + |> Map.new(fn {era, versions} -> {era, Enum.sort(versions, :desc)} end) + + @latest_version hd(@versions_by_era[:legacy]) @fallback_version "2025-03-26" @type version :: String.t() + @type era :: Anubis.Protocol.Behaviour.era() @doc """ Get the protocol module for a given version string. @@ -52,11 +73,76 @@ defmodule Anubis.Protocol.Registry do end @doc """ - Returns the latest supported protocol version string. + List the versions belonging to an era, in preference order (newest first). + + ## Examples + + iex> Anubis.Protocol.Registry.versions_for_era(:stateless) + ["2026-07-28"] + """ + @spec versions_for_era(era()) :: [version()] + def versions_for_era(era) when era in [:legacy, :stateless] do + Map.get(@versions_by_era, era, []) + end + + @doc """ + List the versions that negotiate a session with the `initialize` handshake. + """ + @spec legacy_versions() :: [version()] + def legacy_versions, do: versions_for_era(:legacy) + + @doc """ + List the versions that carry their metadata on every request. + """ + @spec stateless_versions() :: [version()] + def stateless_versions, do: versions_for_era(:stateless) + + @doc """ + Returns the era a version belongs to. + + ## Examples + + iex> Anubis.Protocol.Registry.era("2026-07-28") + {:ok, :stateless} + + iex> Anubis.Protocol.Registry.era("unknown") + :error + """ + @spec era(version()) :: {:ok, era()} | :error + def era(version) do + case get(version) do + {:ok, mod} -> {:ok, mod.era()} + :error -> :error + end + end + + @doc """ + Returns the latest protocol version reachable through the `initialize` + handshake. + + Stateless versions are not handshake-negotiable; use `latest_version/1` to + ask for the newest version of a specific era. """ @spec latest_version() :: version() def latest_version, do: @latest_version + @doc """ + Returns the latest supported version of an era, or `nil` when none is + registered. + + ## Examples + + iex> Anubis.Protocol.Registry.latest_version(:stateless) + "2026-07-28" + """ + @spec latest_version(era()) :: version() | nil + def latest_version(era) do + case versions_for_era(era) do + [latest | _] -> latest + [] -> nil + end + end + @doc """ Returns the fallback protocol version for compatibility. """ @@ -82,6 +168,9 @@ defmodule Anubis.Protocol.Registry do If we support the requested version, use it. Otherwise, return an error with the list of supported versions. + This serves the `initialize` handshake, so it only resolves to `:legacy` + versions — a stateless version requested here is treated as unsupported. + ## Examples iex> Anubis.Protocol.Registry.negotiate("2025-11-25") @@ -89,12 +178,15 @@ defmodule Anubis.Protocol.Registry do iex> Anubis.Protocol.Registry.negotiate("9999-01-01") {:error, :unsupported_version, ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]} + + iex> Anubis.Protocol.Registry.negotiate("2026-07-28") + {:error, :unsupported_version, ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]} """ @spec negotiate(version()) :: {:ok, version(), module()} | {:error, :unsupported_version, [version()]} def negotiate(client_version) do - case get(client_version) do + case legacy_module(client_version) do {:ok, mod} -> {:ok, client_version, mod} - :error -> {:error, :unsupported_version, supported_versions()} + :error -> {:error, :unsupported_version, legacy_versions()} end end @@ -102,8 +194,13 @@ defmodule Anubis.Protocol.Registry do Negotiate version between client and server supported version lists. Used when the server has a restricted set of supported versions. - Returns the best matching version (client's preference if in server list, - otherwise server's latest). + Returns the client's preference when the server offers it, otherwise the + newest legacy version the server offers, regardless of the order they were + given in. + + Non-legacy and unregistered entries are ignored, and a server list that + offers no legacy version returns `:error` rather than silently substituting + another version. ## Examples @@ -112,19 +209,25 @@ defmodule Anubis.Protocol.Registry do iex> Anubis.Protocol.Registry.negotiate("2024-11-05", ["2025-11-25", "2025-03-26"]) {:ok, "2025-11-25", Anubis.Protocol.V2025_11_25} + + iex> Anubis.Protocol.Registry.negotiate("2024-11-05", ["2025-03-26", "2025-11-25"]) + {:ok, "2025-11-25", Anubis.Protocol.V2025_11_25} + + iex> Anubis.Protocol.Registry.negotiate("2026-07-28", ["2026-07-28"]) + :error """ @spec negotiate(version(), [version()]) :: {:ok, version(), module()} | :error - def negotiate(client_version, [latest | _] = server_versions) do - version = - if client_version in server_versions do - client_version - else - latest - end + def negotiate(client_version, server_versions) when is_list(server_versions) do + legacy = legacy_versions() - case get(version) do - {:ok, mod} -> {:ok, version, mod} - :error -> :error + case Enum.filter(server_versions, &(&1 in legacy)) do + [] -> + :error + + candidates -> + version = if client_version in candidates, do: client_version, else: Enum.max(candidates) + + with {:ok, mod} <- legacy_module(version), do: {:ok, version, mod} end end @@ -164,4 +267,13 @@ defmodule Anubis.Protocol.Registry do :error -> :error end end + + defp legacy_module(version) do + with {:ok, mod} <- get(version), + :legacy <- mod.era() do + {:ok, mod} + else + _ -> :error + end + end end diff --git a/lib/anubis/protocol/schema.ex b/lib/anubis/protocol/schema.ex index c09c5614..0456923b 100644 --- a/lib/anubis/protocol/schema.ex +++ b/lib/anubis/protocol/schema.ex @@ -5,12 +5,25 @@ defmodule Anubis.Protocol.Schema do A protocol version owns the full shape of its messages: method names, params schemas, and the JSON-RPC envelope around them. These helpers build the envelope branches for `{:multi, :method, branches}` schemas and merge the - generic `params._meta.progressToken` slot, so version modules stay focused + `params._meta` slots every version shares, so version modules stay focused on what changed between versions. + + The `_meta` slot differs by era. Legacy versions negotiate their metadata + once and carry only `progressToken` per request (`with_progress_meta/1`); + stateless versions carry the protocol version, capabilities and identity on + every request (`with_request_meta/1`). """ @progress_meta %{"_meta" => %{"progressToken" => {:either, {:string, :integer}}}} + @log_levels ~w(debug info notice warning error critical alert emergency) + + @request_meta %{"_meta" => {:required, {:custom, &__MODULE__.validate_request_meta/1}}} + + @subscription_meta %{"_meta" => {:required, {:custom, &__MODULE__.validate_subscription_meta/1}}} + + @subscription_id_key "io.modelcontextprotocol/subscriptionId" + @doc """ Returns the schema fragment for the `params._meta.progressToken` slot shared by all MCP requests. @@ -27,6 +40,122 @@ defmodule Anubis.Protocol.Schema do def with_progress_meta(schema) when is_map(schema), do: Map.merge(schema, @progress_meta) def with_progress_meta(schema), do: schema + @doc """ + Returns the log levels defined by the MCP specification, in ascending + severity (RFC 5424). + """ + @spec log_levels() :: [String.t()] + def log_levels, do: @log_levels + + @doc """ + Merges the stateless-era per-request `_meta` slot into a params schema map. + + Versions in the `:stateless` era carry the protocol version, client + capabilities and optional client identity on every request instead of + negotiating them once. The slot is validated by + `validate_request_meta/1` rather than declared as a nested schema, so + that `_meta` keys this version does not model — extension and + OpenTelemetry keys — survive validation instead of being stripped. + + Non-map schemas (e.g. `:map`) pass through unchanged. + """ + @spec with_request_meta(term()) :: term() + def with_request_meta(schema) when is_map(schema), do: Map.merge(schema, @request_meta) + def with_request_meta(schema), do: schema + + @doc """ + Validates the reserved `io.modelcontextprotocol/*` keys of a stateless-era + request's `_meta`, returning the map unchanged so unmodeled keys survive. + + `protocolVersion` and `clientCapabilities` are required on every request; + `clientInfo` and `logLevel` are optional but validated when present. + """ + @spec validate_request_meta(term()) :: :ok | {:error, String.t(), keyword()} + def validate_request_meta(meta) when is_map(meta) do + with :ok <- validate_protocol_version(meta), + :ok <- validate_client_capabilities(meta), + :ok <- validate_client_info(meta) do + validate_log_level(meta) + end + end + + def validate_request_meta(other) do + {:error, "_meta must be a map, got %{actual}", actual: inspect(other)} + end + + @doc """ + Returns the `_meta` slot required on notifications delivered over a + `subscriptions/listen` stream. + + Every message on the stream must be tagged with the subscription it belongs + to, so clients can demultiplex them on transports that share one channel. + """ + @spec subscription_meta() :: map() + def subscription_meta, do: @subscription_meta + + @doc """ + Validates that a subscription notification's `_meta` carries + `io.modelcontextprotocol/subscriptionId`, returning the map unchanged so + unmodeled keys survive. + + The value is the JSON-RPC id of the originating `subscriptions/listen` + request, so it is a string or an integer. + """ + @spec validate_subscription_meta(term()) :: :ok | {:error, String.t(), keyword()} + def validate_subscription_meta(meta) when is_map(meta) do + case Map.get(meta, @subscription_id_key) do + id when is_binary(id) or is_integer(id) -> + :ok + + nil -> + {:error, "_meta is missing required key %{key}", key: @subscription_id_key} + + other -> + {:error, "%{key} must be a string or an integer, got %{actual}", key: @subscription_id_key, + actual: inspect(other)} + end + end + + def validate_subscription_meta(other) do + {:error, "_meta must be a map, got %{actual}", actual: inspect(other)} + end + + defp validate_protocol_version(meta) do + case Map.get(meta, "io.modelcontextprotocol/protocolVersion") do + version when is_binary(version) -> :ok + _ -> {:error, "_meta is missing required key %{key}", key: "io.modelcontextprotocol/protocolVersion"} + end + end + + defp validate_client_capabilities(meta) do + case Map.get(meta, "io.modelcontextprotocol/clientCapabilities") do + capabilities when is_map(capabilities) -> :ok + _ -> {:error, "_meta is missing required key %{key}", key: "io.modelcontextprotocol/clientCapabilities"} + end + end + + defp validate_client_info(meta) do + case Map.get(meta, "io.modelcontextprotocol/clientInfo") do + nil -> :ok + %{"name" => name, "version" => version} when is_binary(name) and is_binary(version) -> :ok + other -> {:error, "clientInfo must declare a name and version, got %{actual}", actual: inspect(other)} + end + end + + defp validate_log_level(meta) do + case Map.get(meta, "io.modelcontextprotocol/logLevel") do + nil -> + :ok + + level when level in @log_levels -> + :ok + + other -> + {:error, "logLevel must be one of %{expected}, got %{actual}", expected: Enum.join(@log_levels, ", "), + actual: inspect(other)} + end + end + @doc """ Builds a JSON-RPC request branch for a `{:multi, :method, branches}` schema. """ @@ -40,6 +169,31 @@ defmodule Anubis.Protocol.Schema do } end + @doc """ + Builds a JSON-RPC request branch for a version in the `:stateless` era. + + Identical to `request_branch/2` except that `params` is required: the + per-request `_meta` fields are mandatory on every stateless request, and an + omitted `params` would otherwise skip validating them entirely. + + Raises when `params_schema` is not a map. `with_request_meta/1` passes + non-map schemas through untouched, so an open schema such as `:map` would + silently drop the mandatory `_meta` slot; failing here surfaces a request + method that has no params schema instead of accepting it unvalidated. + """ + @spec stateless_request_branch(String.t(), term()) :: map() + def stateless_request_branch(method, params_schema) when is_map(params_schema) do + method + |> request_branch(with_request_meta(params_schema)) + |> Map.update!("params", &{:required, &1}) + end + + def stateless_request_branch(method, params_schema) do + raise ArgumentError, + "#{method}: a stateless request params schema must be a map to carry the " <> + "required _meta slot, got #{inspect(params_schema)}" + end + @doc """ Builds a JSON-RPC notification branch for a `{:multi, :method, branches}` schema. """ diff --git a/lib/anubis/protocol/v2026_07_28.ex b/lib/anubis/protocol/v2026_07_28.ex new file mode 100644 index 00000000..8d02ae5c --- /dev/null +++ b/lib/anubis/protocol/v2026_07_28.ex @@ -0,0 +1,188 @@ +# credo:disable-for-this-file Credo.Check.Readability.ModuleNames +defmodule Anubis.Protocol.V2026_07_28 do + @moduledoc """ + Protocol implementation for MCP specification version 2026-07-28. + + The first version in the `:stateless` era. Rather than building on + 2025-11-25, it reshapes the protocol around per-request metadata: + + - Removes the `initialize` / `notifications/initialized` handshake. Every + request carries `io.modelcontextprotocol/protocolVersion` and + `io.modelcontextprotocol/clientCapabilities` in `_meta`. + - Adds `server/discover`, which servers must implement to advertise their + supported versions, capabilities and identity. + - Replaces `resources/subscribe` / `resources/unsubscribe` with + `subscriptions/listen`, a single opt-in notification stream whose + messages are tagged with `io.modelcontextprotocol/subscriptionId`. + - Removes `ping` and `logging/setLevel`; log verbosity is requested + per-request via `io.modelcontextprotocol/logLevel`. + - Removes server-initiated requests. `roots/list`, + `sampling/createMessage` and `elicitation/create` are no longer + JSON-RPC methods; they are carried inside an `InputRequiredResult` + under the multi round-trip requests pattern. + - Moves tasks out of the core protocol into the + `io.modelcontextprotocol/tasks` extension, advertised under the new + `extensions` capability. + """ + + @behaviour Anubis.Protocol.Behaviour + + alias Anubis.Protocol.Schema + alias Anubis.Protocol.V2025_06_18 + + @version "2026-07-28" + + @era :stateless + + @transport_rules %{batching: false, protocol_version_header: true} + + @capability_keys ~w(prompts tools resources completion logging extensions) + + @removed_features [:ping] + + @features [ + :stateless, + :discovery, + :subscriptions, + :multi_round_trip_requests, + :result_caching, + :extensions, + :standard_request_headers + | V2025_06_18.supported_features() -- @removed_features + ] + + @request_methods ~w( + server/discover + subscriptions/listen + resources/list resources/templates/list resources/read + prompts/get prompts/list + tools/call tools/list + completion/complete + ) + + @notification_methods ~w( + notifications/cancelled + notifications/progress + notifications/message + notifications/subscriptions/acknowledged + notifications/tools/list_changed + notifications/prompts/list_changed + notifications/resources/list_changed + notifications/resources/updated + ) + + @subscription_filter %{ + "toolsListChanged" => :boolean, + "promptsListChanged" => :boolean, + "resourcesListChanged" => :boolean, + "resourceSubscriptions" => {:list, :string} + } + + @mrtr_params %{ + "inputResponses" => :map, + "requestState" => :string + } + + @impl true + def era, do: @era + + @impl true + def version, do: @version + + @impl true + def supported_features, do: @features + + @impl true + def supports_feature?(feature), do: feature in @features + + @impl true + def transport_rules, do: @transport_rules + + @impl true + def server_capabilities(capabilities) when is_map(capabilities) do + Map.take(capabilities, @capability_keys) + end + + @impl true + def request_methods, do: @request_methods + + @impl true + def notification_methods, do: @notification_methods + + @impl true + def progress_params_schema, do: V2025_06_18.progress_params_schema() + + @impl true + def request_result_schema(_method), do: nil + + @impl true + def request_message_schema do + {:multi, :method, + Map.new(@request_methods, fn method -> + {method, Schema.stateless_request_branch(method, request_params_schema(method))} + end)} + end + + @impl true + def notification_message_schema do + {:multi, :method, + Map.new(@notification_methods, fn method -> + {method, Schema.notification_branch(method, notification_params_schema(method))} + end)} + end + + @impl true + def request_params_schema("server/discover"), do: %{} + + def request_params_schema("subscriptions/listen"), do: %{"notifications" => @subscription_filter} + + def request_params_schema("resources/list"), do: %{"cursor" => :string} + def request_params_schema("resources/templates/list"), do: %{"cursor" => :string} + def request_params_schema("prompts/list"), do: %{"cursor" => :string} + def request_params_schema("tools/list"), do: %{"cursor" => :string} + + def request_params_schema("resources/read") do + Map.put(@mrtr_params, "uri", {:required, :string}) + end + + def request_params_schema(method) when method in ~w(prompts/get tools/call) do + Map.merge(@mrtr_params, %{"name" => {:required, :string}, "arguments" => :map}) + end + + def request_params_schema("completion/complete") do + V2025_06_18.request_params_schema("completion/complete") + end + + def request_params_schema(method) when method in @request_methods, do: %{} + + def request_params_schema(_method), do: :map + + @impl true + def notification_params_schema("notifications/cancelled") do + V2025_06_18.notification_params_schema("notifications/cancelled") + end + + def notification_params_schema("notifications/progress"), do: progress_params_schema() + + def notification_params_schema("notifications/message") do + V2025_06_18.notification_params_schema("notifications/message") + end + + def notification_params_schema("notifications/subscriptions/acknowledged") do + Map.put(Schema.subscription_meta(), "notifications", @subscription_filter) + end + + def notification_params_schema("notifications/resources/updated") do + Map.put(Schema.subscription_meta(), "uri", {:required, :string}) + end + + def notification_params_schema(method) when method in ~w( + notifications/tools/list_changed + notifications/prompts/list_changed + notifications/resources/list_changed + ) do + Schema.subscription_meta() + end + + def notification_params_schema(_method), do: :map +end diff --git a/lib/anubis/server.ex b/lib/anubis/server.ex index a8cd5c81..65149d40 100644 --- a/lib/anubis/server.ex +++ b/lib/anubis/server.ex @@ -107,7 +107,7 @@ defmodule Anubis.Server do alias Anubis.Server.Response @server_capabilities ~w(prompts tools resources logging completion)a - @protocol_versions Anubis.Protocol.Registry.supported_versions() + @protocol_versions Anubis.Protocol.Registry.legacy_versions() @type request :: map() @type response :: map() diff --git a/lib/anubis/server/discovery.ex b/lib/anubis/server/discovery.ex new file mode 100644 index 00000000..84f19567 --- /dev/null +++ b/lib/anubis/server/discovery.ex @@ -0,0 +1,22 @@ +defmodule Anubis.Server.Discovery do + @moduledoc false + + @server_info_meta "io.modelcontextprotocol/serverInfo" + + @spec result(module(), module()) :: map() + def result(server, protocol_module) do + maybe_put( + %{ + "resultType" => "complete", + "supportedVersions" => server.supported_protocol_versions(), + "capabilities" => protocol_module.server_capabilities(server.server_capabilities()), + "_meta" => %{@server_info_meta => server.server_info()} + }, + "instructions", + server.server_instructions() + ) + end + + defp maybe_put(result, _key, nil), do: result + defp maybe_put(result, key, value), do: Map.put(result, key, value) +end diff --git a/lib/anubis/server/transport/streamable_http/plug.ex b/lib/anubis/server/transport/streamable_http/plug.ex index 0b1cd8a6..43fa992e 100644 --- a/lib/anubis/server/transport/streamable_http/plug.ex +++ b/lib/anubis/server/transport/streamable_http/plug.ex @@ -3,13 +3,20 @@ if Code.ensure_loaded?(Plug) do @moduledoc """ A Plug implementation for the Streamable HTTP transport. - This plug handles the MCP Streamable HTTP protocol as specified in MCP 2025-03-26. - It provides a single endpoint that supports both GET and POST methods: + This plug handles the session-oriented Streamable HTTP protocol introduced + in MCP 2025-03-26 and the experimental stateless discovery flow introduced + in MCP 2026-07-28. + + Legacy protocol versions provide a single endpoint with three methods: - GET: Opens an SSE stream for server-to-client communication - POST: Handles JSON-RPC messages from client to server - DELETE: Closes a session + Servers explicitly configured for MCP 2026-07-28 accept POST only and do + not create protocol sessions. The initial stateless server slice implements + `server/discover`; operational methods remain follow-up work. + ## Usage in Phoenix Router pipeline :mcp do @@ -45,11 +52,13 @@ if Code.ensure_loaded?(Plug) do alias Anubis.MCP.Error alias Anubis.MCP.ID alias Anubis.MCP.Message + alias Anubis.Protocol.Registry, as: ProtocolRegistry alias Anubis.Server.Authorization alias Anubis.Server.Registry alias Anubis.Server.Supervisor, as: ServerSupervisor alias Anubis.Server.Transport.Session alias Anubis.Server.Transport.StreamableHTTP + alias Anubis.Server.Transport.StreamableHTTP.Stateless alias Anubis.SSE.Streaming alias Anubis.Telemetry alias Plug.Conn.Unfetched @@ -116,45 +125,91 @@ if Code.ensure_loaded?(Plug) do end defp handle_request(conn, opts) do - case validate_protocol_version_header(conn, opts) do - :ok -> - case conn.method do - "GET" -> handle_get(conn, opts) - "POST" -> handle_post(conn, opts) - "DELETE" -> handle_delete(conn, opts) - _ -> send_error(conn, 405, "Method not allowed") - end + case resolve_protocol_era(conn, opts) do + {:ok, :legacy} -> + handle_legacy_request(conn, opts) - {:error, version} -> - Logging.transport_event("unsupported_protocol_version", %{version: version}, level: :warning) + {:ok, {:stateless, protocol_module}} -> + Stateless.handle(conn, opts, protocol_module) + + {:error, error} -> + handle_protocol_resolution_error(conn, error) + end + end - send_error(conn, 400, "Unsupported MCP-Protocol-Version: #{version}") + defp handle_legacy_request(conn, opts) do + case conn.method do + "GET" -> handle_get(conn, opts) + "POST" -> handle_post(conn, opts) + "DELETE" -> handle_delete(conn, opts) + _ -> send_error(conn, 405, "Method not allowed") end end + defp handle_protocol_resolution_error(conn, {:header_mismatch, data}) do + send_jsonrpc_error(conn, Error.protocol(:header_mismatch, data), nil) + end + + defp handle_protocol_resolution_error(conn, {:unsupported_version, version, supported, true}) do + Logging.transport_event("unsupported_protocol_version", %{version: version}, level: :warning) + send_jsonrpc_error(conn, Error.unsupported_protocol_version(version, supported), nil) + end + + defp handle_protocol_resolution_error(conn, {:unsupported_version, version, _supported, false}) do + Logging.transport_event("unsupported_protocol_version", %{version: version}, level: :warning) + send_error(conn, 400, "Unsupported MCP-Protocol-Version: #{version}") + end + # Per MCP 2025-06-18, clients send the negotiated protocol version on the # MCP-Protocol-Version header of every request after initialize. When the # header is absent the server SHOULD assume 2025-03-26 for backwards # compatibility; when present but unsupported the request is rejected. - defp validate_protocol_version_header(conn, opts) do + defp resolve_protocol_era(conn, opts) do + supported = supported_protocol_versions(opts.server) + stateless? = supports_stateless?(supported) + case get_req_header(conn, "mcp-protocol-version") do [] -> - :ok + if stateless? and not supports_legacy?(supported) do + {:error, {:header_mismatch, %{header: "mcp-protocol-version", expected: supported, actual: []}}} + else + {:ok, :legacy} + end - [version | _] -> - if version in supported_protocol_versions(opts.server) do - :ok + [version] -> + with true <- version in supported, + {:ok, protocol_module} <- ProtocolRegistry.get(version) do + {:ok, protocol_era(protocol_module)} else - {:error, version} + _ -> {:error, {:unsupported_version, version, supported, stateless?}} end + + versions when stateless? -> + {:error, {:header_mismatch, %{header: "mcp-protocol-version", expected: supported, actual: versions}}} + + [version | _] -> + if version in supported, + do: {:ok, :legacy}, + else: {:error, {:unsupported_version, version, supported, false}} + end + end + + defp protocol_era(protocol_module) do + case protocol_module.era() do + :legacy -> :legacy + :stateless -> {:stateless, protocol_module} end end + defp supports_stateless?(versions), do: Enum.any?(versions, &(ProtocolRegistry.era(&1) == {:ok, :stateless})) + defp supports_legacy?(versions), do: Enum.any?(versions, &(ProtocolRegistry.era(&1) == {:ok, :legacy})) + + # This endpoint is the session-oriented binding, so it defaults to legacy. defp supported_protocol_versions(server) do if Anubis.exported?(server, :supported_protocol_versions, 0) do server.supported_protocol_versions() else - Anubis.Protocol.Registry.supported_versions() + ProtocolRegistry.legacy_versions() end end diff --git a/lib/anubis/server/transport/streamable_http/stateless.ex b/lib/anubis/server/transport/streamable_http/stateless.ex new file mode 100644 index 00000000..5a58d7e9 --- /dev/null +++ b/lib/anubis/server/transport/streamable_http/stateless.ex @@ -0,0 +1,243 @@ +if Code.ensure_loaded?(Plug) do + defmodule Anubis.Server.Transport.StreamableHTTP.Stateless do + @moduledoc false + + import Plug.Conn + + alias Anubis.MCP.Error + alias Anubis.MCP.ID + alias Anubis.MCP.Message + alias Anubis.Server.Discovery + alias Plug.Conn.Unfetched + + require Message + + @protocol_version_meta "io.modelcontextprotocol/protocolVersion" + @named_methods %{ + "tools/call" => "name", + "prompts/get" => "name", + "resources/read" => "uri" + } + + @spec handle(Plug.Conn.t(), map(), module()) :: Plug.Conn.t() + def handle(conn, opts, protocol_module) do + case validate_origin(conn) do + :ok -> + handle_method(conn, opts, protocol_module) + + {:error, reason} -> + send_protocol_error(conn, 403, Error.protocol(:invalid_request, %{reason: reason}), nil) + end + end + + defp handle_method(%{method: "POST"} = conn, opts, protocol_module) do + with :ok <- validate_content_types(conn), + {:ok, body, conn} <- read_request_body(conn, opts), + {:ok, message} <- decode_request(body, protocol_module), + :ok <- validate_standard_headers(conn, message) do + dispatch(conn, message, opts.server, protocol_module) + else + {:error, :invalid_accept_header} -> + send_protocol_error( + conn, + 406, + Error.protocol(:invalid_request, %{message: "Accept must include application/json and text/event-stream"}), + nil + ) + + {:error, :invalid_content_type} -> + send_protocol_error( + conn, + 415, + Error.protocol(:invalid_request, %{message: "Content-Type must be application/json"}), + nil + ) + + {:error, :parse_error, body} -> + send_protocol_error(conn, 400, Error.protocol(:parse_error), extract_request_id(body)) + + {:error, :method_not_found, body} -> + send_protocol_error(conn, 404, Error.protocol(:method_not_found), extract_request_id(body)) + + {:error, :invalid_request, body} -> + send_protocol_error(conn, 400, Error.protocol(:invalid_request), extract_request_id(body)) + + {:error, {:header_mismatch, data}, message} -> + send_protocol_error(conn, 400, Error.protocol(:header_mismatch, data), extract_request_id(message)) + + {:error, reason} -> + send_protocol_error(conn, 400, Error.wrap_reason(reason), nil) + end + end + + defp handle_method(conn, _opts, _protocol_module) do + send_protocol_error( + conn, + 405, + Error.protocol(:method_not_found, %{message: "Stateless MCP only accepts POST"}), + nil + ) + end + + defp dispatch(conn, %{"method" => "server/discover", "id" => id}, server, protocol_module) do + {:ok, response} = Message.encode_response(%{"result" => Discovery.result(server, protocol_module)}, id) + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, response) + end + + defp dispatch(conn, %{"method" => method, "id" => id}, _server, _protocol_module) do + send_protocol_error(conn, 404, Error.protocol(:method_not_found, %{method: method}), id) + end + + defp validate_content_types(conn) do + accepted_types = + conn + |> get_req_header("accept") + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.map(&media_type/1) + + content_type = + conn + |> get_req_header("content-type") + |> List.first("") + |> media_type() + + cond do + content_type != "application/json" -> {:error, :invalid_content_type} + "application/json" not in accepted_types -> {:error, :invalid_accept_header} + "text/event-stream" not in accepted_types -> {:error, :invalid_accept_header} + true -> :ok + end + end + + defp media_type(value) do + value + |> String.split(";", parts: 2) + |> hd() + |> String.trim() + |> String.downcase() + end + + defp validate_standard_headers(conn, message) do + metadata_version = get_in(message, ["params", "_meta", @protocol_version_meta]) + + with :ok <- header_matches(conn, "mcp-protocol-version", metadata_version), + :ok <- header_matches(conn, "mcp-method", message["method"]), + :ok <- validate_name_header(conn, message) do + :ok + else + {:error, data} -> {:error, {:header_mismatch, data}, message} + end + end + + defp validate_name_header(conn, %{"method" => method, "params" => params}) when is_map_key(@named_methods, method) do + body_value = Map.get(params, @named_methods[method]) + + case get_req_header(conn, "mcp-name") do + [header_value] -> + case decode_header_value(header_value) do + {:ok, ^body_value} -> :ok + {:ok, decoded} -> {:error, header_mismatch("mcp-name", body_value, decoded)} + :error -> {:error, header_mismatch("mcp-name", body_value, header_value)} + end + + values -> + {:error, header_mismatch("mcp-name", body_value, values)} + end + end + + defp validate_name_header(_conn, _message), do: :ok + + defp header_matches(conn, header, body_value) do + case get_req_header(conn, header) do + [^body_value] -> :ok + values -> {:error, header_mismatch(header, body_value, values)} + end + end + + defp header_mismatch(header, expected, actual) do + %{header: header, expected: expected, actual: actual} + end + + defp decode_header_value("=?base64?" <> encoded) do + with true <- String.ends_with?(encoded, "?="), + payload = String.slice(encoded, 0, byte_size(encoded) - 2), + {:ok, decoded} <- Base.decode64(payload) do + {:ok, decoded} + else + _ -> :error + end + end + + defp decode_header_value(value), do: {:ok, value} + + defp decode_request(body, protocol_module) when is_binary(body) do + case Message.decode(body, protocol_module) do + {:ok, [message]} when Message.is_request(message) -> {:ok, message} + {:ok, _messages} -> {:error, :invalid_request, body} + {:error, reason} when reason in [:parse_error, :method_not_found, :invalid_request] -> {:error, reason, body} + {:error, _reason} -> {:error, :invalid_request, body} + end + end + + defp decode_request(body, protocol_module) when is_map(body) do + case Message.validate_message(body, protocol_module) do + {:ok, message} when Message.is_request(message) -> {:ok, message} + {:ok, _message} -> {:error, :invalid_request, body} + {:error, :method_not_found} -> {:error, :method_not_found, body} + {:error, _reason} -> {:error, :invalid_request, body} + end + end + + defp read_request_body(%{body_params: %Unfetched{aspect: :body_params}} = conn, %{timeout: timeout}) do + case Plug.Conn.read_body(conn, read_timeout: timeout) do + {:ok, body, conn} -> {:ok, body, conn} + {:error, reason} -> {:error, reason} + end + end + + defp read_request_body(%{body_params: body} = conn, _opts), do: {:ok, body, conn} + + defp validate_origin(conn) do + case get_req_header(conn, "origin") do + [] -> :ok + [origin] -> if same_origin?(origin, conn), do: :ok, else: {:error, :invalid_origin} + _ -> {:error, :invalid_origin} + end + end + + defp same_origin?(origin, conn) do + uri = URI.parse(origin) + + uri.scheme == Atom.to_string(conn.scheme) and + uri.host == conn.host and + origin_port(uri) == conn.port + end + + defp origin_port(%URI{port: port}) when is_integer(port), do: port + defp origin_port(%URI{scheme: "http"}), do: 80 + defp origin_port(%URI{scheme: "https"}), do: 443 + defp origin_port(_uri), do: nil + + defp send_protocol_error(conn, status, error, id) do + {:ok, response} = Error.to_json_rpc(error, id || ID.generate_error_id()) + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, response) + end + + defp extract_request_id(%{"id" => id}), do: id + + defp extract_request_id(body) when is_binary(body) do + case JSON.decode(body) do + {:ok, %{"id" => id}} -> id + _ -> nil + end + end + + defp extract_request_id(_body), do: nil + end +end diff --git a/pages/transports.md b/pages/transports.md index ab59e21c..b0ae386a 100644 --- a/pages/transports.md +++ b/pages/transports.md @@ -87,6 +87,29 @@ The plug accepts a few options besides `server`: - `:request_timeout` bounds each request, defaulting to 30 seconds. - `:subscriber_metadata` takes a function from `Plug.Conn` to a map, letting you tag SSE subscribers with data derived from the request, such as a tenant id. +### Experimental 2026-07-28 discovery + +Servers can opt into the stateless protocol era introduced by MCP `2026-07-28`: + +```elixir +use Anubis.Server, + name: "My Server", + version: "2.0.0", + capabilities: [:tools], + protocol_versions: ["2026-07-28"] +``` + +For that version, the Streamable HTTP endpoint accepts independent POST requests, +validates the request metadata and standard MCP headers, and implements +`server/discover` without creating or returning a protocol session. HTTP GET and +DELETE return `405 Method Not Allowed`; legacy `Mcp-Session-Id` and +`Last-Event-ID` request headers are ignored. + +This is an experimental first server slice rather than complete `2026-07-28` +support. Other stateless operations currently return `404 Method not found`. +Subscriptions, request-scoped SSE, multi round-trip requests, result caching, +custom `Mcp-Param-*` headers, and stateless client support remain follow-up work. + ### Sessions Each connecting client gets its own session process, identified by the session id header the server assigns during initialization. Sessions hold the frame state described in [Building a Server](building-a-server.md) and expire after 30 minutes idle by default. Tune that with `session_idle_timeout`: diff --git a/test/anubis/mcp/error_test.exs b/test/anubis/mcp/error_test.exs index f252f0cf..1f4e476b 100644 --- a/test/anubis/mcp/error_test.exs +++ b/test/anubis/mcp/error_test.exs @@ -47,6 +47,82 @@ defmodule Anubis.MCP.ErrorTest do assert error.reason == :internal_error assert error.message == "Internal error" end + + test "protocol/2 creates header mismatch error" do + error = Error.protocol(:header_mismatch, %{header: "Mcp-Name"}) + assert error.code == -32_020 + assert error.reason == :header_mismatch + assert error.message == "Header mismatch" + assert error.data.header == "Mcp-Name" + end + + test "protocol/2 creates missing required client capability error" do + capabilities = %{"elicitation" => %{}} + error = Error.protocol(:missing_required_client_capability, %{requiredCapabilities: capabilities}) + assert error.code == -32_021 + assert error.reason == :missing_required_client_capability + assert error.message == "Missing required client capability" + assert error.data.requiredCapabilities == capabilities + end + + test "protocol/2 creates unsupported protocol version error" do + error = Error.protocol(:unsupported_protocol_version, %{supported: ["2026-07-28"], requested: "1900-01-01"}) + assert error.code == -32_022 + assert error.reason == :unsupported_protocol_version + assert error.message == "Unsupported protocol version" + assert error.data.requested == "1900-01-01" + end + + test "the codes reserved for the specification stay out of the legacy sub-range" do + for reason <- [:header_mismatch, :missing_required_client_capability, :unsupported_protocol_version] do + assert Error.protocol(reason).code in -32_099..-32_020 + end + end + end + + describe "reserved errors with a specified payload" do + test "unsupported_protocol_version/2 carries both supported and requested" do + error = Error.unsupported_protocol_version("1900-01-01", ["2026-07-28", "2025-11-25"]) + + assert error.code == -32_022 + assert error.reason == :unsupported_protocol_version + assert error.data == %{supported: ["2026-07-28", "2025-11-25"], requested: "1900-01-01"} + end + + test "missing_required_client_capability/1 keeps capabilities as an object" do + error = Error.missing_required_client_capability(%{"elicitation" => %{}}) + + assert error.code == -32_021 + assert error.reason == :missing_required_client_capability + assert error.data == %{requiredCapabilities: %{"elicitation" => %{}}} + end + + test "unsupported_protocol_version/2 serializes to the specified wire payload" do + {:ok, encoded} = Error.to_json_rpc(Error.unsupported_protocol_version("1900-01-01", ["2026-07-28"]), 1) + + assert %{ + "error" => %{ + "code" => -32_022, + "data" => %{"supported" => ["2026-07-28"], "requested" => "1900-01-01"} + } + } = Jason.decode!(encoded) + end + + test "missing_required_client_capability/1 serializes requiredCapabilities as an object" do + {:ok, encoded} = Error.to_json_rpc(Error.missing_required_client_capability(%{"elicitation" => %{}}), 1) + + assert %{ + "error" => %{ + "code" => -32_021, + "data" => %{"requiredCapabilities" => %{"elicitation" => %{}}} + } + } = Jason.decode!(encoded) + end + + test "rejects argument shapes the specification does not allow" do + assert_raise FunctionClauseError, fn -> Error.unsupported_protocol_version("1900-01-01", "2026-07-28") end + assert_raise FunctionClauseError, fn -> Error.missing_required_client_capability(["elicitation"]) end + end end describe "wrap_reason/1" do @@ -126,6 +202,21 @@ defmodule Anubis.MCP.ErrorTest do assert error.reason == :resource_not_found end + test "from_json_rpc/1 round-trips the specification-reserved codes" do + reasons = %{ + -32_020 => :header_mismatch, + -32_021 => :missing_required_client_capability, + -32_022 => :unsupported_protocol_version + } + + for {code, reason} <- reasons do + error = Error.from_json_rpc(%{"code" => code, "message" => "boom"}) + + assert error.code == code + assert error.reason == reason + end + end + test "from_json_rpc/1 includes data if present" do json_error = %{ "code" => -32_000, diff --git a/test/anubis/protocol/dialect_test.exs b/test/anubis/protocol/dialect_test.exs index 393a37c6..15664f2b 100644 --- a/test/anubis/protocol/dialect_test.exs +++ b/test/anubis/protocol/dialect_test.exs @@ -1,19 +1,32 @@ defmodule Anubis.Protocol.DialectTest do use ExUnit.Case, async: true + alias Anubis.Protocol.Registry alias Anubis.Protocol.V2024_11_05 alias Anubis.Protocol.V2025_03_26 alias Anubis.Protocol.V2025_06_18 alias Anubis.Protocol.V2025_11_25 + alias Anubis.Protocol.V2026_07_28 @versions [V2024_11_05, V2025_03_26, V2025_06_18, V2025_11_25] describe "era/0" do - test "all current versions belong to the legacy era" do + test "the handshake versions belong to the legacy era" do for mod <- @versions do assert mod.era() == :legacy end end + + test "2026-07-28 opens the stateless era" do + assert V2026_07_28.era() == :stateless + end + + test "every registered version reports an era the registry groups it under" do + for version <- Registry.supported_versions() do + {:ok, mod} = Registry.get(version) + assert version in Registry.versions_for_era(mod.era()) + end + end end describe "supports_feature?/1" do diff --git a/test/anubis/protocol/registry_test.exs b/test/anubis/protocol/registry_test.exs index 185349ae..e03e31f2 100644 --- a/test/anubis/protocol/registry_test.exs +++ b/test/anubis/protocol/registry_test.exs @@ -25,17 +25,60 @@ defmodule Anubis.Protocol.RegistryTest do test "returns all versions newest first" do versions = Registry.supported_versions() assert is_list(versions) - assert length(versions) == 4 - assert hd(versions) == "2025-11-25" + assert length(versions) == 5 + assert hd(versions) == "2026-07-28" + assert "2025-11-25" in versions assert "2025-06-18" in versions assert "2025-03-26" in versions assert "2024-11-05" in versions end end + describe "versions_for_era/1" do + test "groups every registered version into exactly one era" do + legacy = Registry.versions_for_era(:legacy) + stateless = Registry.versions_for_era(:stateless) + + assert Enum.sort(legacy ++ stateless) == Enum.sort(Registry.supported_versions()) + assert legacy -- stateless == legacy + end + + test "the handshake versions are legacy and 2026-07-28 is stateless" do + assert Registry.legacy_versions() == ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + assert Registry.stateless_versions() == ["2026-07-28"] + end + + test "each era is ordered newest first" do + for era <- [:legacy, :stateless] do + versions = Registry.versions_for_era(era) + assert versions == Enum.sort(versions, :desc) + end + end + end + + describe "era/1" do + test "reports the era of a registered version" do + assert {:ok, :legacy} = Registry.era("2025-11-25") + assert {:ok, :stateless} = Registry.era("2026-07-28") + end + + test "returns :error for unknown version" do + assert :error = Registry.era("9999-01-01") + end + end + describe "latest_version/0" do - test "returns the latest version" do + test "returns the latest handshake-negotiable version" do assert "2025-11-25" = Registry.latest_version() + assert Registry.latest_version() == Registry.latest_version(:legacy) + assert {:ok, :legacy} = Registry.era(Registry.latest_version()) + end + end + + describe "latest_version/1" do + test "returns the newest version of an era" do + assert "2025-11-25" = Registry.latest_version(:legacy) + assert "2026-07-28" = Registry.latest_version(:stateless) end end @@ -75,8 +118,12 @@ defmodule Anubis.Protocol.RegistryTest do test "returns error for unsupported client version" do assert {:error, :unsupported_version, versions} = Registry.negotiate("9999-01-01") - assert is_list(versions) - assert length(versions) == 4 + assert versions == Registry.legacy_versions() + end + + test "treats a stateless version as unsupported by the handshake" do + assert {:error, :unsupported_version, versions} = Registry.negotiate("2026-07-28") + refute "2026-07-28" in versions end end @@ -91,10 +138,44 @@ defmodule Anubis.Protocol.RegistryTest do Registry.negotiate("2024-11-05", ["2025-06-18", "2025-03-26"]) end + test "falls back to the newest server version regardless of list order" do + for server_versions <- [ + ["2024-11-05", "2025-11-25"], + ["2025-11-25", "2024-11-05"], + ["2025-03-26", "2025-11-25", "2024-11-05"] + ] do + assert {:ok, "2025-11-25", V2025_11_25} = Registry.negotiate("9999-01-01", server_versions) + end + end + test "returns client version when it matches server's only version" do assert {:ok, "2025-03-26", V2025_03_26} = Registry.negotiate("2025-03-26", ["2025-03-26"]) end + + test "never resolves to a stateless version" do + for client_version <- ["2026-07-28", "2025-11-25", "9999-01-01"] do + refute match?({:ok, "2026-07-28", _}, Registry.negotiate(client_version, Registry.supported_versions())) + end + end + + test "an unknown client version never falls back into the stateless era" do + assert {:ok, version, module} = Registry.negotiate("9999-01-01", Registry.supported_versions()) + + assert version == "2025-11-25" + assert module.era() == :legacy + end + + test "ignores stateless and unregistered entries in the server list" do + assert {:ok, "2025-03-26", V2025_03_26} = + Registry.negotiate("2025-03-26", ["2026-07-28", "9999-01-01", "2025-03-26"]) + end + + test "returns :error when the server list offers no legacy version" do + assert :error = Registry.negotiate("2026-07-28", ["2026-07-28"]) + assert :error = Registry.negotiate("2025-03-26", ["9999-01-01"]) + assert :error = Registry.negotiate("2025-03-26", []) + end end describe "get_features/1" do diff --git a/test/anubis/protocol/v2026_07_28_test.exs b/test/anubis/protocol/v2026_07_28_test.exs new file mode 100644 index 00000000..02d5602c --- /dev/null +++ b/test/anubis/protocol/v2026_07_28_test.exs @@ -0,0 +1,311 @@ +# credo:disable-for-this-file Credo.Check.Readability.ModuleNames +defmodule Anubis.Protocol.V2026_07_28Test do + use ExUnit.Case, async: true + + alias Anubis.MCP.Message + alias Anubis.Protocol.Schema + alias Anubis.Protocol.V2025_11_25 + alias Anubis.Protocol.V2026_07_28 + + @meta %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{}} + } + + @subscription_id "io.modelcontextprotocol/subscriptionId" + + defp request(method, params \\ %{}) do + %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Map.put(params, "_meta", @meta)} + end + + # `notifications/resources/updated` is the only stream notification carrying + # a body field of its own, so it needs the `uri` alongside the tagged `_meta`. + defp stream_notification(method, meta) do + params = + case method do + "notifications/resources/updated" -> %{"_meta" => meta, "uri" => "file:///a.txt"} + _ -> %{"_meta" => meta} + end + + %{"jsonrpc" => "2.0", "method" => method, "params" => params} + end + + describe "version/0 and era/0" do + test "identifies the first stateless version" do + assert V2026_07_28.version() == "2026-07-28" + assert V2026_07_28.era() == :stateless + end + end + + describe "request_methods/0" do + test "adds discovery and subscriptions" do + assert "server/discover" in V2026_07_28.request_methods() + assert "subscriptions/listen" in V2026_07_28.request_methods() + end + + test "drops the handshake, ping and logging/setLevel" do + for method <- ~w(initialize ping logging/setLevel) do + refute method in V2026_07_28.request_methods() + end + end + + test "drops the resource subscribe RPCs replaced by subscriptions/listen" do + for method <- ~w(resources/subscribe resources/unsubscribe) do + refute method in V2026_07_28.request_methods() + end + end + + test "drops server-initiated requests, now carried by MRTR" do + for method <- ~w(roots/list sampling/createMessage elicitation/create) do + refute method in V2026_07_28.request_methods() + end + end + + test "drops the task methods, now an extension" do + for method <- V2025_11_25.request_methods(), String.starts_with?(method, "tasks/") do + refute method in V2026_07_28.request_methods() + end + end + + test "keeps the core primitives" do + for method <- ~w(tools/list tools/call prompts/list prompts/get + resources/list resources/templates/list resources/read + completion/complete) do + assert method in V2026_07_28.request_methods() + end + end + end + + describe "notification_methods/0" do + test "adds the subscription acknowledgement" do + assert "notifications/subscriptions/acknowledged" in V2026_07_28.notification_methods() + end + + test "drops initialized, roots list_changed and task status" do + for method <- ~w(notifications/initialized notifications/roots/list_changed notifications/tasks/status) do + refute method in V2026_07_28.notification_methods() + end + end + end + + describe "supported_features/0" do + @introduced [:stateless, :discovery, :subscriptions, :multi_round_trip_requests, :result_caching, :extensions] + + test "declares the features this revision introduces" do + for feature <- @introduced do + assert V2026_07_28.supports_feature?(feature) + end + end + + test "no longer supports ping" do + refute V2026_07_28.supports_feature?(:ping) + end + end + + describe "server_capabilities/1" do + test "advertises the new extensions capability and drops tasks" do + declared = %{ + "tools" => %{}, + "logging" => %{}, + "extensions" => %{"io.modelcontextprotocol/tasks" => %{}}, + "tasks" => %{"list" => true} + } + + shaped = V2026_07_28.server_capabilities(declared) + + assert shaped == Map.delete(declared, "tasks") + end + end + + describe "per-request _meta" do + test "accepts a request carrying the required fields" do + assert {:ok, _} = Message.validate_message(request("tools/list"), V2026_07_28) + end + + test "rejects a request whose params are missing entirely" do + message = %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/list"} + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + + test "rejects a request with no _meta" do + message = %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => %{}} + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + + test "rejects a request missing protocolVersion or clientCapabilities" do + for key <- Map.keys(@meta) do + params = %{"_meta" => Map.delete(@meta, key)} + message = %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => params} + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + end + + test "rejects an unrecognized logLevel" do + message = request("tools/list", %{}) + meta = Map.put(@meta, "io.modelcontextprotocol/logLevel", "chatty") + message = put_in(message, ["params", "_meta"], meta) + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + + test "accepts every specification log level" do + for level <- Schema.log_levels() do + meta = Map.put(@meta, "io.modelcontextprotocol/logLevel", level) + message = put_in(request("tools/list"), ["params", "_meta"], meta) + + assert {:ok, _} = Message.validate_message(message, V2026_07_28) + end + end + + test "preserves _meta keys the version does not model" do + meta = + Map.merge(@meta, %{ + "com.example/tenant" => "acme", + "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", + "progressToken" => "tok-1" + }) + + message = put_in(request("tools/list"), ["params", "_meta"], meta) + + assert {:ok, validated} = Message.validate_message(message, V2026_07_28) + assert validated["params"]["_meta"] == meta + end + + test "rejects a client identity without a name and version" do + meta = Map.put(@meta, "io.modelcontextprotocol/clientInfo", %{"name" => "c"}) + message = put_in(request("tools/list"), ["params", "_meta"], meta) + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + end + + describe "server/discover" do + test "takes no parameters beyond _meta" do + assert V2026_07_28.request_params_schema("server/discover") == %{} + assert {:ok, _} = Message.validate_message(request("server/discover"), V2026_07_28) + end + end + + describe "subscriptions/listen" do + test "accepts the notification filter" do + params = %{ + "notifications" => %{ + "toolsListChanged" => true, + "resourceSubscriptions" => ["file:///project/config.json"] + } + } + + assert {:ok, _} = Message.validate_message(request("subscriptions/listen", params), V2026_07_28) + end + + test "rejects a filter with the wrong types" do + params = %{"notifications" => %{"toolsListChanged" => "yes"}} + + assert {:error, :invalid_request} = Message.validate_message(request("subscriptions/listen", params), V2026_07_28) + end + end + + describe "subscription stream notifications" do + @stream_notifications ~w( + notifications/subscriptions/acknowledged + notifications/resources/updated + notifications/tools/list_changed + notifications/prompts/list_changed + notifications/resources/list_changed + ) + + test "every stream notification requires a subscription id" do + for method <- @stream_notifications do + assert {:error, :invalid_request} = + Message.validate_message(stream_notification(method, %{}), V2026_07_28) + end + end + + test "a subscription id may be a string or an integer, matching the request id" do + for method <- @stream_notifications, id <- [4, "sub-4"] do + message = stream_notification(method, %{@subscription_id => id}) + + assert {:ok, _} = Message.validate_message(message, V2026_07_28) + end + end + + test "rejects a subscription id that is not a request id" do + message = stream_notification("notifications/tools/list_changed", %{@subscription_id => %{"nested" => true}}) + + assert {:error, :invalid_request} = Message.validate_message(message, V2026_07_28) + end + + test "preserves unmodeled _meta keys alongside the subscription id" do + meta = %{@subscription_id => 4, "com.example/tenant" => "acme"} + message = stream_notification("notifications/tools/list_changed", meta) + + assert {:ok, validated} = Message.validate_message(message, V2026_07_28) + assert validated["params"]["_meta"] == meta + end + end + + describe "request params schemas" do + test "every request method has a map schema so _meta is always required" do + for method <- V2026_07_28.request_methods() do + assert is_map(V2026_07_28.request_params_schema(method)), + "#{method} has no params schema, so its mandatory _meta would be dropped" + end + end + + test "a stateless branch cannot be built from an open schema" do + assert_raise ArgumentError, ~r/must be a map/, fn -> + Schema.stateless_request_branch("some/method", :map) + end + end + end + + describe "multi round-trip request retries" do + @retry %{ + "inputResponses" => %{"github_login" => %{"action" => "accept"}}, + "requestState" => "opaque-blob" + } + + test "the three supported methods carry inputResponses and requestState through" do + retries = [ + {"tools/call", %{"name" => "weather", "arguments" => %{}}}, + {"prompts/get", %{"name" => "summarize"}}, + {"resources/read", %{"uri" => "file:///a.txt"}} + ] + + for {method, params} <- retries do + message = request(method, Map.merge(params, @retry)) + + assert {:ok, validated} = Message.validate_message(message, V2026_07_28) + assert validated["params"]["inputResponses"] == @retry["inputResponses"] + assert validated["params"]["requestState"] == @retry["requestState"] + end + end + + test "methods that cannot return input_required drop retry fields before dispatch" do + for method <- ~w(tools/list prompts/list resources/list server/discover) do + message = request(method, @retry) + + assert {:ok, validated} = Message.validate_message(message, V2026_07_28) + refute Map.has_key?(validated["params"], "inputResponses") + refute Map.has_key?(validated["params"], "requestState") + end + end + + test "requestState must be a string" do + params = %{"uri" => "file:///a.txt", "requestState" => %{"forged" => true}} + + assert {:error, :invalid_request} = Message.validate_message(request("resources/read", params), V2026_07_28) + end + end + + describe "unknown methods" do + test "report method_not_found rather than a schema failure" do + for method <- ~w(initialize ping resources/subscribe tasks/get) do + assert {:error, :method_not_found} = Message.validate_message(request(method), V2026_07_28) + end + end + end +end diff --git a/test/anubis/protocol/version_modules_test.exs b/test/anubis/protocol/version_modules_test.exs index 54fbd677..70a05c3b 100644 --- a/test/anubis/protocol/version_modules_test.exs +++ b/test/anubis/protocol/version_modules_test.exs @@ -1,10 +1,10 @@ defmodule Anubis.Protocol.VersionModulesTest do use ExUnit.Case, async: true + alias Anubis.Protocol.Registry alias Anubis.Protocol.V2024_11_05 alias Anubis.Protocol.V2025_03_26 alias Anubis.Protocol.V2025_06_18 - alias Anubis.Protocol.V2025_11_25 describe "V2024_11_05" do test "version/0 returns correct string" do @@ -187,11 +187,12 @@ defmodule Anubis.Protocol.VersionModulesTest do end describe "behaviour compliance" do - for mod <- [V2024_11_05, V2025_03_26, V2025_06_18, V2025_11_25] do - test "#{mod} implements all callbacks" do - mod = unquote(mod) + for version <- Registry.supported_versions() do + test "#{version} implements every dialect callback" do + {:ok, mod} = Registry.get(unquote(version)) + + assert mod.version() == unquote(version) assert mod.era() in [:legacy, :stateless] - assert is_binary(mod.version()) assert is_list(mod.supported_features()) assert is_boolean(mod.supports_feature?(:ping)) assert %{batching: batching?, protocol_version_header: header?} = mod.transport_rules() @@ -201,11 +202,29 @@ defmodule Anubis.Protocol.VersionModulesTest do assert is_list(mod.request_methods()) assert is_list(mod.notification_methods()) assert is_map(mod.progress_params_schema()) - assert "initialize" |> mod.request_params_schema() |> is_map() - assert mod.notification_params_schema("notifications/initialized") == :map assert {:multi, :method, _} = mod.request_message_schema() assert {:multi, :method, _} = mod.notification_message_schema() end end + + for version <- Registry.versions_for_era(:legacy) do + test "#{version} models the initialize handshake" do + {:ok, mod} = Registry.get(unquote(version)) + + assert "initialize" in mod.request_methods() + assert "notifications/initialized" in mod.notification_methods() + assert "initialize" |> mod.request_params_schema() |> is_map() + assert mod.notification_params_schema("notifications/initialized") == :map + end + end + + for version <- Registry.versions_for_era(:stateless) do + test "#{version} models no handshake" do + {:ok, mod} = Registry.get(unquote(version)) + + refute "initialize" in mod.request_methods() + refute "notifications/initialized" in mod.notification_methods() + end + end end end diff --git a/test/anubis/protocol_test.exs b/test/anubis/protocol_test.exs index 031adf07..e03bf6b4 100644 --- a/test/anubis/protocol_test.exs +++ b/test/anubis/protocol_test.exs @@ -33,6 +33,19 @@ defmodule Anubis.ProtocolTest do assert {:error, %Error{}} = Protocol.validate_version("9999-01-01") end + test "validate_version/1 accepts every version the handshake can negotiate" do + for version <- Protocol.supported_versions(:legacy) do + assert :ok = Protocol.validate_version(version) + end + end + + test "validate_version/1 rejects a stateless version it could not negotiate" do + for version <- Protocol.supported_versions(:stateless) do + assert {:error, %Error{data: data}} = Protocol.validate_version(version) + refute version in data.supported + end + end + test "get_features/1 returns features for known version" do features = Protocol.get_features("2024-11-05") assert is_list(features) @@ -79,4 +92,44 @@ defmodule Anubis.ProtocolTest do assert :error = Protocol.get_module("9999-01-01") end end + + describe "era accessors" do + test "supported_versions/1 partitions by era" do + assert Protocol.supported_versions(:stateless) == ["2026-07-28"] + refute "2026-07-28" in Protocol.supported_versions(:legacy) + end + + test "era/1 and latest_version/1 delegate to the registry" do + assert {:ok, :stateless} = Protocol.era("2026-07-28") + assert {:ok, :legacy} = Protocol.era("2025-11-25") + assert Protocol.latest_version(:stateless) == "2026-07-28" + end + end + + describe "registering a stateless version leaves the legacy era untouched" do + defmodule DefaultVersionsServer do + @moduledoc false + use Anubis.Server, name: "test", version: "1.0.0", capabilities: [:tools] + end + + test "a server built with the DSL advertises no stateless version" do + versions = DefaultVersionsServer.supported_protocol_versions() + + assert versions == Protocol.supported_versions(:legacy) + refute "2026-07-28" in versions + end + + test "the client default protocol version stays in the legacy era" do + assert {:ok, :legacy} = Protocol.era(Protocol.latest_version()) + end + + test "negotiate_version/2 cannot resolve into the stateless era" do + for client_version <- ["2026-07-28", "9999-01-01"] do + refute match?( + {:ok, "2026-07-28", _}, + Protocol.negotiate_version(client_version, Protocol.supported_versions()) + ) + end + end + end end diff --git a/test/anubis/server/discovery_test.exs b/test/anubis/server/discovery_test.exs new file mode 100644 index 00000000..1d8e0eca --- /dev/null +++ b/test/anubis/server/discovery_test.exs @@ -0,0 +1,47 @@ +defmodule Anubis.Server.DiscoveryTest do + use ExUnit.Case, async: true + + alias Anubis.Protocol.V2026_07_28 + alias Anubis.Server.Discovery + + defmodule ServerWithInstructions do + @moduledoc false + + def supported_protocol_versions, do: ["2026-07-28"] + def server_capabilities, do: %{"tools" => %{}, "resources" => %{}, "roots" => %{}} + def server_info, do: %{"name" => "Stateless Test Server", "version" => "2.0.0"} + def server_instructions, do: "Discover a version before sending operational requests." + end + + defmodule ServerWithoutInstructions do + @moduledoc false + + def supported_protocol_versions, do: ["2026-07-28"] + def server_capabilities, do: %{} + def server_info, do: %{"name" => "Minimal Server", "version" => "1.0.0"} + def server_instructions, do: nil + end + + describe "result/2" do + test "given a stateless server, when discovery is rendered, then it returns the complete server contract" do + assert Discovery.result(ServerWithInstructions, V2026_07_28) == %{ + "resultType" => "complete", + "supportedVersions" => ["2026-07-28"], + "capabilities" => %{"tools" => %{}, "resources" => %{}}, + "instructions" => "Discover a version before sending operational requests.", + "_meta" => %{ + "io.modelcontextprotocol/serverInfo" => %{ + "name" => "Stateless Test Server", + "version" => "2.0.0" + } + } + } + end + + test "given no server instructions, when discovery is rendered, then it omits the optional field" do + result = Discovery.result(ServerWithoutInstructions, V2026_07_28) + + refute Map.has_key?(result, "instructions") + end + end +end diff --git a/test/anubis/server/transport/streamable_http/plug_test.exs b/test/anubis/server/transport/streamable_http/plug_test.exs index 60a11c81..d8b42a12 100644 --- a/test/anubis/server/transport/streamable_http/plug_test.exs +++ b/test/anubis/server/transport/streamable_http/plug_test.exs @@ -336,6 +336,27 @@ defmodule Anubis.Server.Transport.StreamableHTTP.PlugTest do assert conn.resp_body =~ "1999-01-01" end + test "POST request with a stateless-era MCP-Protocol-Version returns 400", %{ + opts: opts, + test_session_id: session_id + } do + request = build_request("ping", %{}) + {:ok, body} = Message.encode_request(request, 1) + [stateless | _] = Anubis.Protocol.Registry.stateless_versions() + + conn = + :post + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("accept", "application/json") + |> put_req_header("mcp-session-id", session_id) + |> put_req_header("mcp-protocol-version", stateless) + |> StreamableHTTPPlug.call(opts) + + assert conn.status == 400 + assert conn.resp_body =~ stateless + end + test "POST request with supported MCP-Protocol-Version succeeds", %{ opts: opts, test_session_id: session_id @@ -343,7 +364,7 @@ defmodule Anubis.Server.Transport.StreamableHTTP.PlugTest do request = build_request("ping", %{}) {:ok, body} = Message.encode_request(request, 1) - [version | _] = Anubis.Protocol.Registry.supported_versions() + [version | _] = Anubis.Protocol.Registry.legacy_versions() conn = :post diff --git a/test/anubis/server/transport/streamable_http/stateless_test.exs b/test/anubis/server/transport/streamable_http/stateless_test.exs new file mode 100644 index 00000000..019c5b1b --- /dev/null +++ b/test/anubis/server/transport/streamable_http/stateless_test.exs @@ -0,0 +1,283 @@ +defmodule Anubis.Server.Transport.StreamableHTTP.StatelessTest do + use Anubis.MCP.Case, async: false + + import Plug.Conn + import Plug.Test + + alias Anubis.Server.Registry + alias Anubis.Server.Supervisor, as: ServerSupervisor + alias Anubis.Server.Transport.StreamableHTTP.Plug, as: StreamableHTTPPlug + + defmodule StatelessServer do + @moduledoc false + + use Anubis.Server, + name: "Stateless Test Server", + version: "2.0.0", + capabilities: [:tools, :resources], + protocol_versions: ["2026-07-28"], + instructions: "Discover a version before sending operational requests." + end + + setup do + transport_name = Registry.transport_name(StatelessServer, StubTransport) + + session_config = %{ + server_module: StatelessServer, + registry_mod: Registry.None, + transport: [layer: StubTransport, name: transport_name], + session_idle_timeout: nil, + timeout: 30_000, + task_supervisor: Registry.task_supervisor_name(StatelessServer) + } + + :persistent_term.put({ServerSupervisor, StatelessServer, :session_config}, session_config) + + on_exit(fn -> + :persistent_term.erase({ServerSupervisor, StatelessServer, :session_config}) + end) + + %{opts: StreamableHTTPPlug.init(server: StatelessServer)} + end + + describe "2026-07-28 stateless endpoint" do + test "given a discovery request, when posted, then it returns server metadata without a session", %{opts: opts} do + conn = stateless_request(opts, "server/discover") + + assert conn.status == 200 + assert get_resp_header(conn, "mcp-session-id") == [] + + assert %{ + "id" => "stateless-request", + "result" => %{ + "resultType" => "complete", + "supportedVersions" => ["2026-07-28"], + "capabilities" => %{"resources" => %{}, "tools" => %{}}, + "instructions" => "Discover a version before sending operational requests.", + "_meta" => %{ + "io.modelcontextprotocol/serverInfo" => %{ + "name" => "Stateless Test Server", + "version" => "2.0.0" + } + } + } + } = JSON.decode!(conn.resp_body) + end + + test "given legacy session headers, when discovery is posted, then they are ignored", %{opts: opts} do + conn = + stateless_request(opts, "server/discover", + extra_headers: [{"mcp-session-id", "legacy-session"}, {"last-event-id", "event-42"}] + ) + + assert conn.status == 200 + assert get_resp_header(conn, "mcp-session-id") == [] + end + + test "given a missing method header, when discovery is posted, then it returns HeaderMismatch", %{opts: opts} do + conn = stateless_request(opts, "server/discover", include_method_header?: false) + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_020 + end + + test "given no protocol header on a stateless-only server, when posted, then it returns HeaderMismatch", %{ + opts: opts + } do + conn = stateless_request(opts, "server/discover", include_protocol_header?: false) + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_020 + end + + test "given mismatched protocol metadata, when discovery is posted, then it returns HeaderMismatch", %{opts: opts} do + conn = stateless_request(opts, "server/discover", metadata_version: "2025-11-25") + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_020 + end + + test "given an unsupported protocol version, when discovery is posted, then it returns the reserved error", %{ + opts: opts + } do + conn = + stateless_request(opts, "server/discover", + header_version: "2099-01-01", + metadata_version: "2099-01-01" + ) + + assert conn.status == 400 + + assert %{ + "code" => -32_022, + "data" => %{ + "requested" => "2099-01-01", + "supported" => ["2026-07-28"] + } + } = JSON.decode!(conn.resp_body)["error"] + end + + test "given an unknown method, when posted, then it returns HTTP 404 and Method not found", %{opts: opts} do + conn = stateless_request(opts, "unknown/method") + + assert conn.status == 404 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_601 + end + + test "given a named request without Mcp-Name, when posted, then it returns HeaderMismatch", %{opts: opts} do + conn = + stateless_request(opts, "tools/call", + params: %{"name" => "echo", "arguments" => %{}}, + include_name_header?: false + ) + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_020 + end + + test "given a Base64-encoded Mcp-Name, when it matches the body, then header validation succeeds", %{opts: opts} do + name = "echo 世界" + encoded_name = "=?base64?#{Base.encode64(name)}?=" + + conn = + stateless_request(opts, "tools/call", + params: %{"name" => name, "arguments" => %{}}, + name_header: encoded_name + ) + + assert conn.status == 404 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_601 + end + + test "given an invalid Origin, when discovery is posted, then it is forbidden", %{opts: opts} do + conn = stateless_request(opts, "server/discover", origin: "https://attacker.example") + + assert conn.status == 403 + end + + test "given a same-origin Origin, when discovery is posted, then it is accepted", %{opts: opts} do + conn = stateless_request(opts, "server/discover", origin: "http://www.example.com") + + assert conn.status == 200 + end + + test "given a non-POST request, when sent to the stateless endpoint, then it is rejected", %{opts: opts} do + for method <- [:get, :delete, :put] do + conn = + method + |> conn("/") + |> put_req_header("mcp-protocol-version", "2026-07-28") + |> StreamableHTTPPlug.call(opts) + + assert conn.status == 405 + end + end + + test "given malformed JSON, when posted, then it returns Parse error", %{opts: opts} do + conn = raw_stateless_request(opts, "not-json", "server/discover") + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_700 + end + + test "given an Accept lookalike, when discovery is posted, then content negotiation rejects it", %{opts: opts} do + conn = stateless_request(opts, "server/discover", accept: "application/json-patch+json, text/event-stream") + + assert conn.status == 406 + end + + test "given a notification body, when posted, then it is rejected without crashing", %{opts: opts} do + body = + JSON.encode!(%{ + "jsonrpc" => "2.0", + "method" => "notifications/cancelled", + "params" => %{ + "requestId" => "cancelled-request", + "_meta" => request_meta("2026-07-28") + } + }) + + conn = raw_stateless_request(opts, body, "notifications/cancelled") + + assert conn.status == 400 + assert JSON.decode!(conn.resp_body)["error"]["code"] == -32_600 + end + end + + defp stateless_request(opts, method, request_opts \\ []) do + metadata_version = Keyword.get(request_opts, :metadata_version, "2026-07-28") + header_version = Keyword.get(request_opts, :header_version, "2026-07-28") + + params = + request_opts + |> Keyword.get(:params, %{}) + |> Map.put("_meta", request_meta(metadata_version)) + + body = + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => "stateless-request", + "method" => method, + "params" => params + }) + + raw_stateless_request(opts, body, method, + header_version: header_version, + include_protocol_header?: Keyword.get(request_opts, :include_protocol_header?, true), + include_method_header?: Keyword.get(request_opts, :include_method_header?, true), + include_name_header?: Keyword.get(request_opts, :include_name_header?, true), + name_header: Keyword.get(request_opts, :name_header), + params: params, + origin: Keyword.get(request_opts, :origin), + accept: Keyword.get(request_opts, :accept, "application/json, text/event-stream"), + extra_headers: Keyword.get(request_opts, :extra_headers, []) + ) + end + + defp raw_stateless_request(opts, body, method, request_opts \\ []) do + conn = + :post + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("accept", Keyword.get(request_opts, :accept, "application/json, text/event-stream")) + |> maybe_put_header( + "mcp-protocol-version", + Keyword.get(request_opts, :header_version, "2026-07-28"), + Keyword.get(request_opts, :include_protocol_header?, true) + ) + |> maybe_put_header("mcp-method", method, Keyword.get(request_opts, :include_method_header?, true)) + |> maybe_put_name_header(method, request_opts) + |> maybe_put_header("origin", Keyword.get(request_opts, :origin), true) + + conn = + Enum.reduce(Keyword.get(request_opts, :extra_headers, []), conn, fn {header, value}, conn -> + put_req_header(conn, header, value) + end) + + StreamableHTTPPlug.call(conn, opts) + end + + defp maybe_put_name_header(conn, method, request_opts) when method in ["tools/call", "prompts/get"] do + value = Keyword.get(request_opts, :name_header) || get_in(request_opts, [:params, "name"]) + maybe_put_header(conn, "mcp-name", value, Keyword.get(request_opts, :include_name_header?, true)) + end + + defp maybe_put_name_header(conn, "resources/read", request_opts) do + value = Keyword.get(request_opts, :name_header) || get_in(request_opts, [:params, "uri"]) + maybe_put_header(conn, "mcp-name", value, Keyword.get(request_opts, :include_name_header?, true)) + end + + defp maybe_put_name_header(conn, _method, _request_opts), do: conn + + defp maybe_put_header(conn, _header, nil, _include?), do: conn + defp maybe_put_header(conn, _header, _value, false), do: conn + defp maybe_put_header(conn, header, value, true), do: put_req_header(conn, header, value) + + defp request_meta(version) do + %{ + "io.modelcontextprotocol/protocolVersion" => version, + "io.modelcontextprotocol/clientInfo" => %{"name" => "test-client", "version" => "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities" => %{} + } + end +end