feat(protocol): add the 2026-07-28 stateless dialect - #269
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughProblemThe protocol registry did not separate legacy handshake versions from the new stateless MCP dialect. SolutionAdd RationalePreserve legacy handshake behavior and prevent unsupported clients from negotiating the stateless dialect. No wire-level stateless behavior or new callbacks are added. WalkthroughThe change adds era-aware protocol APIs and registers stateless protocol version Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b0d98d02-be93-491b-b2ef-f2381a3c163c
📒 Files selected for processing (14)
lib/anubis/mcp/error.exlib/anubis/protocol.exlib/anubis/protocol/registry.exlib/anubis/protocol/schema.exlib/anubis/protocol/v2026_07_28.exlib/anubis/server.exlib/anubis/server/transport/streamable_http/plug.extest/anubis/mcp/error_test.exstest/anubis/protocol/dialect_test.exstest/anubis/protocol/registry_test.exstest/anubis/protocol/v2026_07_28_test.exstest/anubis/protocol/version_modules_test.exstest/anubis/protocol_test.exstest/anubis/server/transport/streamable_http/plug_test.exs
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
lib/anubis/mcp/error.ex (1)
176-191: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winP1 — Validate the complete reserved error payload.
The guards only validate
mapandlistouter types. For example,unsupported_protocol_version("x", [1])andmissing_required_client_capability(%{"elicitation" => 1})both create invalid reserved payloads.Validate the
supported,requested, andrequiredCapabilitiesvalues with Peri schemas. Apply the same validation to the reservedprotocol/2clauses, or prevent raw construction of these reserved errors.As per coding guidelines, use
import Perianddefschemafor all validation.Also applies to: 233-251
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 568e6667-21f5-469b-8b94-586ea42015a3
📒 Files selected for processing (9)
lib/anubis/mcp/error.exlib/anubis/protocol.exlib/anubis/protocol/registry.exlib/anubis/protocol/schema.exlib/anubis/protocol/v2026_07_28.extest/anubis/mcp/error_test.exstest/anubis/protocol/registry_test.exstest/anubis/protocol/v2026_07_28_test.exstest/anubis/protocol_test.exs
85fe48b to
40c3685
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
lib/anubis/mcp/error.ex (1)
167-192: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winP2 — Enforce reserved-error schemas on every construction path.
protocol/2accepts%{}for:unsupported_protocol_versionand:missing_required_client_capability. It can therefore encode payloads that omit required fields.unsupported_protocol_version/2also accepts non-string entries such as[123]because it checks onlyis_list/1.MCP requires
supportedandrequestedfor-32022, and it requiresrequiredCapabilitiesas an object for-32021. (github.com)
lib/anubis/mcp/error.ex#L167-L192: validate reserved reasons before constructing the error.lib/anubis/mcp/error.ex#L233-L252: validate eachsupportedelement and the full capability payload.test/anubis/mcp/error_test.exs#L122-L125: add rejection tests for malformed directprotocol/2payloads and non-stringsupportedmembers.As per coding guidelines, use
import Periand module-attributedefschemavalidation, with{:custom, &validator_function/1}for complex validators.Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b58a82f4-b5a0-4ec2-8218-3f3f955403d1
📒 Files selected for processing (14)
lib/anubis/mcp/error.exlib/anubis/protocol.exlib/anubis/protocol/registry.exlib/anubis/protocol/schema.exlib/anubis/protocol/v2026_07_28.exlib/anubis/server.exlib/anubis/server/transport/streamable_http/plug.extest/anubis/mcp/error_test.exstest/anubis/protocol/dialect_test.exstest/anubis/protocol/registry_test.exstest/anubis/protocol/v2026_07_28_test.exstest/anubis/protocol/version_modules_test.exstest/anubis/protocol_test.exstest/anubis/server/transport/streamable_http/plug_test.exs
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/anubis/mcp/error.ex (1)
115-117: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winP2 — Make the doctest independent of
datakey order.
datais an Elixir map, so the inspection order ofsupportedandrequestedis not guaranteed. This can make the doctest fail despite a correct payload. Compare scalar/error fields and assert the pair{|error.data.requested, error.data.supported|}instead.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 6b6288f9-d11b-470a-981d-f3aa3f301fc6
📒 Files selected for processing (9)
lib/anubis/mcp/error.exlib/anubis/protocol.exlib/anubis/protocol/registry.exlib/anubis/protocol/schema.extest/anubis/mcp/error_test.exstest/anubis/protocol/registry_test.exstest/anubis/protocol/v2026_07_28_test.exstest/anubis/protocol_test.exstest/anubis/server/transport/streamable_http/plug_test.exs
|
wow, thanks for the contribution! just a few comments |
zoedsoupe
left a comment
There was a problem hiding this comment.
Reviewed this slice against the dialect contract from the multi-version RFC (module owns its schemas/flags, registry grouping by era, no edits to Message/Session/handlers). Conformance is clean: containment guards keep negotiation legacy-only, and the deferred pieces (transport entries, result shaping, -32002 reallocation) match the slice scoping. Four inline notes, one of them worth acting on.
|
|
||
| @capability_keys ~w(prompts tools resources completion logging extensions) | ||
|
|
||
| @removed_features [:ping] |
There was a problem hiding this comment.
@removed_features subtracts only :ping, but this module also drops roots/list, sampling/createMessage, elicitation/create, and logging/setLevel as methods. The inherited flags :roots, :sampling, and :elicitation still report true, and :multi_round_trip_requests is claimed with no engine behind it yet.
In every other version module the flags track method presence, which is exactly why :ping is subtracted here. As soon as supports_feature?/2 feeds capability shaping or dispatch, this version overstates what it can do.
Two ways out: subtract the method-backed flags too ([:ping, :roots, :sampling, :elicitation]), or document that flags mean "capability exists in some form (including via MRTR)" and drop :multi_round_trip_requests until the engine lands. (:logging is fine to keep either way, since per-request logLevel keeps the capability alive.)
| def progress_params_schema, do: V2025_06_18.progress_params_schema() | ||
|
|
||
| @impl true | ||
| def request_result_schema(_method), do: nil |
There was a problem hiding this comment.
Returning nil for every method means tools/call structured output goes unvalidated in this era, while the dialect contract makes version modules own their result schemas. The PR body already defers this deliberately, which is fine for the slice, but let's track it (issue or TODO) so the stateless era doesn't ship without result schemas.
|
|
||
| @doc """ | ||
| Validates the reserved `io.modelcontextprotocol/*` keys of a stateless-era | ||
| request's `_meta`, returning the map unchanged so unmodeled keys survive. |
There was a problem hiding this comment.
The doc says the validator works by "returning the map unchanged", but the function returns :ok. Unmodeled keys survive because a {:custom, _} validator never transforms the value, not because the map is returned. Same wording on validate_subscription_meta/1 below. Suggest rewording to "leaving the map untouched so unmodeled keys survive".
| """ | ||
| @spec unsupported_protocol_version(String.t(), [String.t()]) :: t() | ||
| def unsupported_protocol_version(requested, supported) when is_binary(requested) and is_list(supported) do | ||
| if !Enum.all?(supported, &is_binary/1) do |
There was a problem hiding this comment.
Nit: if !Enum.all?(...) reads against house style. Prefer if not Enum.all?(supported, &is_binary/1) do.
## Problem #269 registered the 2026-07-28 dialect but served nothing on the wire. `server/discover` had no handler, `request_result_schema/1` returned `nil` everywhere, the reserved `-32021`/`-32022` constructors had no caller, and `decode/1` validated every message against the newest *legacy* version. A stateless request died as `method_not_found` before reaching a session. It also left a live defect: `Session` hard-matched `{:ok, _, _}` on `Registry.negotiate/2`, which #269 taught to return bare `:error`. A server declaring only 2026-07-28 crashed on every `initialize`. ## Solution Second slice of #263 — the era is served, transport-independently. - A request is stateless iff `params._meta` declares a protocol version, the discriminator the spec gives a dual-era server. `decode/1` validates each message against the version it declares; an unregistered version resolves to the newest stateless dialect, so the session answers `-32022` rather than a parse error. - `Anubis.Server.Stateless` owns admission, the discover result and result shaping. Its context rides on the transport context — already request-scoped through the scheduler queue into the frame — so client capabilities and identity never reach session state. - `server/discover` routes through `Handlers` like any other method, inheriting the scheduler and telemetry. - Stateless results gain `resultType` and `_meta` server identity at the scheduler's single reply point; legacy results are untouched. - `-32602` replaces `-32002` for resource-not-found under this era only; `-32021` replaces the untyped string at the one site already detecting a missing client capability. ## Rationale `supportedVersions` comes from the same helper as `-32022`'s `supported`, and omits legacy versions: one cannot be selected per request, so advertising it would name a version the client could not retry with. `ttlMs: 0` / `cacheScope: "private"` are conservative — components may be registered at runtime through the frame, so a longer-lived cache could be wrong. Step 6 makes them configurable. `Registry.latest_module/1` fills the gap beside `latest_version/1`, so the unregistered-version fallback comes from the registry, not a literal. Deferred by intent: the Streamable HTTP binding is step 3, so the plug still rejects stateless versions and its containment test is unchanged; `subscriptions/listen` step 4; `InputRequiredResult` step 5; the remaining cacheable results step 6; the client era probe step 7.
Problem
Spec revision 2026-07-28 makes MCP stateless: no
initializehandshake, noMcp-Session-Id, and the protocol version plus client capabilities travel in per-request_meta. Anubis has no version module for it, andAnubis.Protocol.Behaviourhas declaredera :: :legacy | :statelesssince 1.14 with no production caller.Registering it naively would break legacy negotiation:
supported_versions/0sorts descending so 2026-07-28 becomes the head, andnegotiate/2falls back to that head for an unknown client version. A legacy client proposing an unsupported version would get the stateless module throughinitialize.Solution
First slice of #263 — the dialect plus the guard, nothing served on the wire yet.
Anubis.Protocol.V2026_07_28implementing all 14 dialect callbacks. Addsserver/discoverandsubscriptions/listen; dropsinitialize,ping,logging/setLevel, the resource subscribe RPCs,tasks/*, and the three server-initiated methods now carried by MRTR. Adds the MRTR retry params on the three methods that may returninput_required, and the newextensionscapability.Registrygroups versions byera/0—versions_for_era/1,legacy_versions/0,stateless_versions/0,era/1,latest_version/1. Bothnegotiatearities now resolve only legacy versions, andnegotiate/2returns:errorfor a server list with no legacy version instead of substituting the head.Schema.with_request_meta/1andstateless_request_branch/2for the per-request_metaslot.Containment is asserted by tests:
latest_version/0stays 2025-11-25, and both the server DSL default and the Streamable HTTP plug fall back to legacy versions only.Rationale
_metais checked by a validator function rather than a nested schema because Peri strips unmodeled keys, and the spec requires extension and OpenTelemetry_metakeys to survive validation. For the same reasonparamsis required on stateless branches — an omittedparamswould skip_metavalidation entirely.No new behaviour callbacks. The contract already covers this slice, and the repo already carries one callback with no consumers; result caching and discover-result shaping land with the code that emits them.
The existing compliance loops assumed every version models
initialize, so they are split into an era-agnostic block and per-era blocks, both driven from the registry.