Skip to content

feat(protocol): add the 2026-07-28 stateless dialect - #269

Merged
zoedsoupe merged 7 commits into
zoedsoupe:mainfrom
faisalnazir7:feature/protocol-2026-07-28-dialect
Aug 8, 2026
Merged

zoedsoupe merged 7 commits into
zoedsoupe:mainfrom
faisalnazir7:feature/protocol-2026-07-28-dialect

Conversation

@faisalnazir7

Copy link
Copy Markdown
Contributor

Problem

Spec revision 2026-07-28 makes MCP stateless: no initialize handshake, no Mcp-Session-Id, and the protocol version plus client capabilities travel in per-request _meta. Anubis has no version module for it, and Anubis.Protocol.Behaviour has declared era :: :legacy | :stateless since 1.14 with no production caller.

Registering it naively would break legacy negotiation: supported_versions/0 sorts descending so 2026-07-28 becomes the head, and negotiate/2 falls back to that head for an unknown client version. A legacy client proposing an unsupported version would get the stateless module through initialize.

Solution

First slice of #263 — the dialect plus the guard, nothing served on the wire yet.

  • New Anubis.Protocol.V2026_07_28 implementing all 14 dialect callbacks. Adds server/discover and subscriptions/listen; drops initialize, 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 return input_required, and the new extensions capability.
  • Registry groups versions by era/0 — versions_for_era/1, legacy_versions/0, stateless_versions/0, era/1, latest_version/1. Both negotiate arities now resolve only legacy versions, and negotiate/2 returns :error for a server list with no legacy version instead of substituting the head.
  • Schema.with_request_meta/1 and stateless_request_branch/2 for the per-request _meta slot.
  • The three error codes this revision reserves: -32020, -32021, -32022.

Containment is asserted by tests: latest_version/0 stays 2025-11-25, and both the server DSL default and the Streamable HTTP plug fall back to legacy versions only.

Rationale

_meta is checked by a validator function rather than a nested schema because Peri strips unmodeled keys, and the spec requires extension and OpenTelemetry _meta keys to survive validation. For the same reason params is required on stateless branches — an omitted params would skip _meta validation 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.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Problem

The protocol registry did not separate legacy handshake versions from the new stateless MCP dialect.

Solution

Add Anubis.Protocol.V2026_07_28, era-based registry APIs, stateless _meta validation, reserved protocol errors, and legacy-only negotiation defaults. Extend compliance and registry tests.

Rationale

Preserve legacy handshake behavior and prevent unsupported clients from negotiating the stateless dialect. No wire-level stateless behavior or new callbacks are added.

Walkthrough

The change adds era-aware protocol APIs and registers stateless protocol version 2026-07-28. Handshake negotiation and default server advertisements now use legacy versions. The stateless protocol defines request metadata, capabilities, methods, subscriptions, retries, and schemas. Streamable HTTP rejects stateless versions. MCP adds three reserved protocol errors with JSON-RPC mappings and validation tests.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the addition of the 2026-07-28 stateless protocol dialect.
Description check ✅ Passed The description includes complete Problem, Solution, and Rationale sections with detailed, relevant implementation context.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5454f3c and 7dc98cc.

📒 Files selected for processing (14)
  • lib/anubis/mcp/error.ex
  • lib/anubis/protocol.ex
  • lib/anubis/protocol/registry.ex
  • lib/anubis/protocol/schema.ex
  • lib/anubis/protocol/v2026_07_28.ex
  • lib/anubis/server.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/protocol/dialect_test.exs
  • test/anubis/protocol/registry_test.exs
  • test/anubis/protocol/v2026_07_28_test.exs
  • test/anubis/protocol/version_modules_test.exs
  • test/anubis/protocol_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs

Comment thread lib/anubis/mcp/error.ex
Comment thread lib/anubis/protocol.ex
Comment thread lib/anubis/protocol/registry.ex
Comment thread lib/anubis/protocol/registry.ex
Comment thread lib/anubis/protocol/v2026_07_28.ex
Comment thread lib/anubis/protocol/v2026_07_28.ex
Comment thread lib/anubis/protocol/v2026_07_28.ex
Comment thread test/anubis/mcp/error_test.exs
Comment thread test/anubis/protocol/registry_test.exs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
lib/anubis/mcp/error.ex (1)

176-191: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

P1 — Validate the complete reserved error payload.

The guards only validate map and list outer types. For example, unsupported_protocol_version("x", [1]) and missing_required_client_capability(%{"elicitation" => 1}) both create invalid reserved payloads.

Validate the supported, requested, and requiredCapabilities values with Peri schemas. Apply the same validation to the reserved protocol/2 clauses, or prevent raw construction of these reserved errors.

As per coding guidelines, use import Peri and defschema for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7dc98cc and a754bd7.

📒 Files selected for processing (9)
  • lib/anubis/mcp/error.ex
  • lib/anubis/protocol.ex
  • lib/anubis/protocol/registry.ex
  • lib/anubis/protocol/schema.ex
  • lib/anubis/protocol/v2026_07_28.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/protocol/registry_test.exs
  • test/anubis/protocol/v2026_07_28_test.exs
  • test/anubis/protocol_test.exs

Comment thread test/anubis/mcp/error_test.exs Outdated
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
lib/anubis/mcp/error.ex (1)

167-192: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

P2 — Enforce reserved-error schemas on every construction path.

protocol/2 accepts %{} for :unsupported_protocol_version and :missing_required_client_capability. It can therefore encode payloads that omit required fields. unsupported_protocol_version/2 also accepts non-string entries such as [123] because it checks only is_list/1.

MCP requires supported and requested for -32022, and it requires requiredCapabilities as 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 each supported element and the full capability payload.
  • test/anubis/mcp/error_test.exs#L122-L125: add rejection tests for malformed direct protocol/2 payloads and non-string supported members.

As per coding guidelines, use import Peri and module-attribute defschema validation, 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

📥 Commits

Reviewing files that changed from the base of the PR and between b386810 and 40c3685.

📒 Files selected for processing (14)
  • lib/anubis/mcp/error.ex
  • lib/anubis/protocol.ex
  • lib/anubis/protocol/registry.ex
  • lib/anubis/protocol/schema.ex
  • lib/anubis/protocol/v2026_07_28.ex
  • lib/anubis/server.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/protocol/dialect_test.exs
  • test/anubis/protocol/registry_test.exs
  • test/anubis/protocol/v2026_07_28_test.exs
  • test/anubis/protocol/version_modules_test.exs
  • test/anubis/protocol_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs

Comment thread lib/anubis/protocol/registry.ex
Comment thread lib/anubis/protocol/v2026_07_28.ex
Comment thread test/anubis/server/transport/streamable_http/plug_test.exs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

P2 — Make the doctest independent of data key order.

data is an Elixir map, so the inspection order of supported and requested is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 40c3685 and b0b0e50.

📒 Files selected for processing (9)
  • lib/anubis/mcp/error.ex
  • lib/anubis/protocol.ex
  • lib/anubis/protocol/registry.ex
  • lib/anubis/protocol/schema.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/protocol/registry_test.exs
  • test/anubis/protocol/v2026_07_28_test.exs
  • test/anubis/protocol_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 6, 2026
@zoedsoupe

Copy link
Copy Markdown
Owner

wow, thanks for the contribution! just a few comments

@zoedsoupe zoedsoupe left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/anubis/protocol/v2026_07_28.ex Outdated

@capability_keys ~w(prompts tools resources completion logging extensions)

@removed_features [:ping]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked in #263.

Comment thread lib/anubis/protocol/schema.ex Outdated

@doc """
Validates the reserved `io.modelcontextprotocol/*` keys of a stateless-era
request's `_meta`, returning the map unchanged so unmodeled keys survive.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment thread lib/anubis/mcp/error.ex Outdated
"""
@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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if !Enum.all?(...) reads against house style. Prefer if not Enum.all?(supported, &is_binary/1) do.

@faisalnazir7
faisalnazir7 requested a review from zoedsoupe August 8, 2026 04:45
@zoedsoupe
zoedsoupe merged commit 986fca6 into zoedsoupe:main Aug 8, 2026
33 of 36 checks passed
@zoedsoupe zoedsoupe mentioned this pull request Aug 8, 2026
zoedsoupe pushed a commit that referenced this pull request Oct 1, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants