Skip to content

Commit d9b6ff1

Browse files
committed
refactor(phase-1): abstract protocol version negotiation
1 parent 5a61c65 commit d9b6ff1

15 files changed

Lines changed: 1085 additions & 75 deletions

File tree

lib/anubis/client/state.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ defmodule Anubis.Client.State do
1414
server_capabilities: map() | nil,
1515
server_info: map() | nil,
1616
protocol_version: String.t(),
17+
protocol_module: module() | nil,
1718
timeout: pos_integer(),
1819
transport: map(),
1920
pending_requests: %{String.t() => Request.t()},
2021
progress_callbacks: %{String.t() => Base.progress_callback()},
2122
log_callback: Base.log_callback() | nil,
2223
sampling_callback: (map() -> {:ok, map()} | {:error, String.t()}) | nil,
23-
# Use a map with URI as key for faster access
2424
roots: %{String.t() => Base.root()}
2525
}
2626

@@ -31,6 +31,7 @@ defmodule Anubis.Client.State do
3131
:server_info,
3232
:timeout,
3333
:protocol_version,
34+
:protocol_module,
3435
:transport,
3536
pending_requests: %{},
3637
progress_callbacks: %{},
@@ -41,10 +42,17 @@ defmodule Anubis.Client.State do
4142

4243
@spec new(map()) :: t()
4344
def new(opts) do
45+
protocol_module =
46+
case Anubis.Protocol.Registry.get(opts.protocol_version) do
47+
{:ok, mod} -> mod
48+
:error -> nil
49+
end
50+
4451
%__MODULE__{
4552
client_info: opts.client_info,
4653
capabilities: opts.capabilities,
4754
protocol_version: opts.protocol_version,
55+
protocol_module: protocol_module,
4856
transport: opts.transport,
4957
timeout: opts.timeout
5058
}

lib/anubis/mcp/message.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,24 @@ defmodule Anubis.MCP.Message do
632632
"""
633633
def progress_params_schema, do: @progress_notif_params_schema
634634

635+
@doc """
636+
Returns the progress notification parameters schema for a given protocol version.
637+
638+
Delegates to the version module via `Anubis.Protocol.Registry`.
639+
640+
## Examples
641+
642+
iex> Message.progress_params_schema_for("2024-11-05")
643+
%{"progressToken" => {:required, {:either, {:string, :integer}}}, ...}
644+
645+
iex> Message.progress_params_schema_for("2025-03-26")
646+
%{"progressToken" => ..., "message" => :string}
647+
"""
648+
@spec progress_params_schema_for(String.t()) :: {:ok, map()} | :error
649+
def progress_params_schema_for(version) do
650+
Anubis.Protocol.Registry.progress_params_schema(version)
651+
end
652+
635653
@doc """
636654
Builds a response message map without encoding to JSON.
637655

lib/anubis/protocol.ex

Lines changed: 50 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,55 @@
11
defmodule Anubis.Protocol do
2-
@moduledoc false
2+
@moduledoc """
3+
MCP protocol version management.
4+
5+
Provides version validation, negotiation, feature detection, and transport
6+
compatibility checking. Delegates version-specific logic to modules under
7+
`Anubis.Protocol.*` via `Anubis.Protocol.Registry`.
8+
9+
## Adding a new protocol version
10+
11+
1. Create a new module under `lib/anubis/protocol/` implementing `Anubis.Protocol.Behaviour`
12+
2. Register it in `Anubis.Protocol.Registry`
13+
"""
314

415
alias Anubis.MCP.Error
16+
alias Anubis.Protocol.Registry
517

618
@type version :: String.t()
719
@type feature :: atom()
820

9-
@supported_versions ["2024-11-05", "2025-03-26", "2025-06-18"]
10-
@latest_version "2025-06-18"
11-
@fallback_version "2025-03-26"
12-
13-
@features_2024_11_05 [
14-
:basic_messaging,
15-
:resources,
16-
:tools,
17-
:prompts,
18-
:logging,
19-
:progress,
20-
:cancellation,
21-
:ping,
22-
:roots,
23-
:sampling
24-
]
25-
26-
@features_2025_03_26 [
27-
:authorization,
28-
:audio_content,
29-
:tool_annotations,
30-
:progress_messages,
31-
:completion_capability
32-
| @features_2024_11_05
33-
]
34-
35-
@features_2025_06_18 [
36-
:elicitation,
37-
:structured_tool_results,
38-
:tool_output_schemas,
39-
:model_preferences,
40-
:embedded_resources_in_prompts,
41-
:embedded_resources_in_tools
42-
| @features_2025_03_26
43-
]
44-
4521
@doc """
4622
Returns all supported protocol versions.
4723
"""
4824
@spec supported_versions() :: [version()]
49-
def supported_versions, do: @supported_versions
25+
defdelegate supported_versions(), to: Registry
5026

5127
@doc """
5228
Returns the latest supported protocol version.
5329
"""
5430
@spec latest_version() :: version()
55-
def latest_version, do: @latest_version
31+
defdelegate latest_version(), to: Registry
5632

5733
@doc """
5834
Returns the fallback protocol version for compatibility.
5935
"""
6036
@spec fallback_version() :: version()
61-
def fallback_version, do: @fallback_version
37+
defdelegate fallback_version(), to: Registry
6238

6339
@doc """
6440
Validates if a protocol version is supported.
6541
"""
6642
@spec validate_version(version()) :: :ok | {:error, Error.t()}
67-
def validate_version(version) when version in @supported_versions, do: :ok
68-
6943
def validate_version(version) do
70-
{:error,
71-
Error.protocol(:invalid_params, %{
72-
version: version,
73-
supported: @supported_versions
74-
})}
44+
if Registry.supported?(version) do
45+
:ok
46+
else
47+
{:error,
48+
Error.protocol(:invalid_params, %{
49+
version: version,
50+
supported: supported_versions()
51+
})}
52+
end
7553
end
7654

7755
@doc """
@@ -95,28 +73,29 @@ defmodule Anubis.Protocol do
9573

9674
defp supported_transport_versions(transport) do
9775
case transport.supported_protocol_versions() do
98-
:all -> @supported_versions
76+
:all -> supported_versions()
9977
[_ | _] = versions -> versions
10078
end
10179
end
10280

10381
@doc """
10482
Returns the set of features supported by a protocol version.
83+
84+
Delegates to the version module's `supported_features/0` callback.
10585
"""
10686
@spec get_features(version()) :: list(feature())
107-
def get_features("2024-11-05"), do: @features_2024_11_05
108-
def get_features("2025-03-26"), do: @features_2025_03_26
109-
def get_features("2025-06-18"), do: @features_2025_06_18
87+
def get_features(version) do
88+
case Registry.get_features(version) do
89+
{:ok, features} -> features
90+
:error -> []
91+
end
92+
end
11093

11194
@doc """
11295
Checks if a feature is supported by a protocol version.
11396
"""
11497
@spec supports_feature?(version(), feature()) :: boolean()
115-
def supports_feature?(version, feature) when is_binary(version) and is_atom(feature) do
116-
version
117-
|> get_features()
118-
|> Enum.member?(feature)
119-
end
98+
defdelegate supports_feature?(version, feature), to: Registry
12099

121100
@doc """
122101
Negotiates protocol version between client and server versions.
@@ -127,25 +106,36 @@ defmodule Anubis.Protocol do
127106
{:ok, version()} | {:error, Error.t()}
128107
def negotiate_version(client_version, server_version) do
129108
cond do
130-
client_version == server_version and client_version in @supported_versions ->
109+
client_version == server_version and Registry.supported?(client_version) ->
131110
{:ok, client_version}
132111

133-
server_version in @supported_versions ->
112+
Registry.supported?(server_version) ->
134113
{:ok, server_version}
135114

136-
client_version in @supported_versions ->
115+
Registry.supported?(client_version) ->
137116
{:ok, client_version}
138117

139118
true ->
140119
{:error,
141120
Error.protocol(:invalid_params, %{
142121
client_version: client_version,
143122
server_version: server_version,
144-
supported: @supported_versions
123+
supported: supported_versions()
145124
})}
146125
end
147126
end
148127

128+
@doc """
129+
Returns the protocol module for a given version string.
130+
131+
## Examples
132+
133+
iex> Anubis.Protocol.get_module("2025-06-18")
134+
{:ok, Anubis.Protocol.V2025_06_18}
135+
"""
136+
@spec get_module(version()) :: {:ok, module()} | :error
137+
defdelegate get_module(version), to: Registry, as: :get
138+
149139
@doc """
150140
Returns transport modules that support a protocol version.
151141
"""

lib/anubis/protocol/behaviour.ex

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
defmodule Anubis.Protocol.Behaviour do
2+
@moduledoc """
3+
Behaviour that each MCP protocol version module must implement.
4+
5+
Each protocol version (e.g., 2024-11-05, 2025-03-26, 2025-06-18) implements
6+
this behaviour to isolate version-specific logic. This makes it trivial to add
7+
support for new MCP spec versions without scattering conditionals across the codebase.
8+
9+
## Version differences
10+
11+
- **2024-11-05**: Initial spec, SSE transport, basic tools/resources/prompts
12+
- **2025-03-26**: Added Streamable HTTP, JSON-RPC batching, authorization framework, tool annotations
13+
- **2025-06-18**: Removed batching, added structured tool output, elicitation, resource_link type
14+
"""
15+
16+
@type version :: String.t()
17+
@type method :: String.t()
18+
@type params :: map()
19+
@type message :: map()
20+
@type feature :: atom()
21+
22+
@doc "Returns the version string this module implements (e.g., '2025-03-26')."
23+
@callback version() :: version()
24+
25+
@doc "List of features/capabilities this protocol version supports."
26+
@callback supported_features() :: [feature()]
27+
28+
@doc "Peri schema for validating request params by method for this version."
29+
@callback request_params_schema(method()) :: term()
30+
31+
@doc "Peri schema for validating notification params by method for this version."
32+
@callback notification_params_schema(method()) :: term()
33+
34+
@doc "Progress notification params schema for this version."
35+
@callback progress_params_schema() :: map()
36+
37+
@doc "All request methods supported by this version."
38+
@callback request_methods() :: [method()]
39+
40+
@doc "All notification methods supported by this version."
41+
@callback notification_methods() :: [method()]
42+
end

0 commit comments

Comments
 (0)