Skip to content

fix(session): return encodable JSON-RPC errors when init/2 fails - #211

Merged
zoedsoupe merged 2 commits into
zoedsoupe:mainfrom
syf2211:fix/init-failure-json-rpc-error
Jul 16, 2026
Merged

zoedsoupe merged 2 commits into
zoedsoupe:mainfrom
syf2211:fix/init-failure-json-rpc-error

Conversation

@syf2211

@syf2211 syf2211 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

When Server.init/2 (or session recovery) rejects a session, normalize the failure to an encodable %Anubis.MCP.Error{} instead of returning raw {:init_failed, reason} / {:recovery_rejected, reason} tuples that crash JSON.encode!/1 in the StreamableHTTP transport.

Motivation

Fixes the HTTP 500 + Protocol.UndefinedError reported in #205 when a server legitimately rejects a session in init/2. Clients now receive a well-formed JSON-RPC error they can act on.

Changes

  • Add Error.wrap_reason/1 to pass through %Error{} and stringify other failure terms for JSON encoding
  • Normalize init/2 and handle_session_expired/2 rejection paths in Session
  • Use Error.wrap_reason/1 in StreamableHTTP transport error handling (defense in depth)
  • Add regression tests for plain and structured init/2 rejections via HTTP and session auto-init

Tests

mix test test/anubis/mcp/error_test.exs test/anubis/server/session_expiry_test.exs test/anubis/server/transport/streamable_http/plug_test.exs

All 57 tests pass.

Notes

  • SSE transport still has similar catch-alls; could be aligned in a follow-up.
  • notifications/initialized path still assumes init/2 returns {:ok, frame} — separate from the auto-init path covered here.

Fixes #205

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Problem

Session initialization and recovery failures could return raw tuples that failed JSON encoding, causing HTTP 500 responses.

Solution

Added Error.wrap_reason/1 and applied it across session and StreamableHTTP rejection paths. Updated the init/2 contract and added regression coverage for plain and structured errors.

Rationale

Ensure all failures produce valid JSON-RPC responses while preserving structured MCP errors.

Walkthrough

Adds Error.wrap_reason/1 to preserve existing MCP errors and convert other terms into JSON-safe internal errors. Session auto-initialization and Streamable HTTP error paths now use this helper. The init/2 callback accepts error tuples, with tests covering reason formatting, session failures, and HTTP responses for plain and structured errors.

Possibly related PRs

  • zoedsoupe/anubis-mcp#125: Adds the auto-initialization failure flow that this PR updates to wrap and propagate MCP errors.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has useful content, but it doesn't follow the required Problem/Solution/Rationale template headings. Reformat it into Problem, Solution, and Rationale sections and move the current summary, motivation, and implementation details into them.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and matches the main change: encoding init/2 failures as JSON-RPC errors.
Linked Issues check ✅ Passed The code normalizes init/recovery failures into encodable MCP errors and adds regression tests, matching #205.
Out of Scope Changes check ✅ Passed The changes stay focused on init/recovery error handling, transport encoding, docs, and tests.
✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/init-failure-json-rpc-error
✨ 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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 870d8d54-fe87-4fde-b016-c1b12b44f7cb

📥 Commits

Reviewing files that changed from the base of the PR and between 2c641a0 and 0ee7beb.

📒 Files selected for processing (7)
  • lib/anubis/mcp/error.ex
  • lib/anubis/server/session.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/server/session_expiry_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
  • test/support/stub_session_recovery_server.ex

Comment thread lib/anubis/mcp/error.ex
syf2211 added a commit to syf2211/anubis-mcp that referenced this pull request Jul 12, 2026
@syf2211

syf2211 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the CodeRabbit doc feedback in 0091443 — added iex> examples to wrap_reason/1 consistent with the rest of the module.

@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)

305-311: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: handle bare atoms in stringify_reason/1 for cleaner error messages.

When init/2 returns {:error, :timeout}, the current catch-all produces "error: :timeout" (note the leading colon from inspect/1). Adding an atom clause would yield the cleaner "error: timeout".

✨ Optional refactor
 defp stringify_reason(reason) when is_binary(reason), do: reason

+defp stringify_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
+
 defp stringify_reason({tag, inner}) when is_atom(tag) do
   "#{tag}: #{stringify_reason(inner)}"
 end

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 68d0657c-282a-47b3-b90a-e2ae5d789ed4

📥 Commits

Reviewing files that changed from the base of the PR and between 0ee7beb and 0091443.

📒 Files selected for processing (1)
  • lib/anubis/mcp/error.ex

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 12, 2026
zoedsoupe
zoedsoupe previously approved these changes Jul 16, 2026
@zoedsoupe

Copy link
Copy Markdown
Owner

could you please address the test errors? the doctest one seems to be a escape issue on " char on lib/anubis/mcp/error.ex:255

the second one i think its related to the merge i did with main 🤔

@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: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07516de1-daaa-4f22-8130-4b8569c75700

📥 Commits

Reviewing files that changed from the base of the PR and between 0091443 and 4103f2e.

📒 Files selected for processing (5)
  • lib/anubis/server/session.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/server/session_expiry_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
  • test/support/stub_session_recovery_server.ex

@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

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07516de1-daaa-4f22-8130-4b8569c75700

📥 Commits

Reviewing files that changed from the base of the PR and between 0091443 and 4103f2e.

📒 Files selected for processing (5)
  • lib/anubis/server/session.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/server/session_expiry_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
  • test/support/stub_session_recovery_server.ex
🛑 Comments failed to post (2)
lib/anubis/server/session.ex (2)

142-146: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Prevent timeouts during session recovery by threading the configured timeout (P2). 😎

GenServer.call/2 defaults to a 5-second timeout. Because auto_initialize triggers the server module's init/2 callback (which might perform heavier logic or DB I/O), it can easily exceed this limit, causing a crash that bubbles up as {:session_unavailable, {:timeout, ...}}. Exposing and passing the plug's configured timeout resolves this.

  • lib/anubis/server/session.ex#L142-L146: Add an optional timeout \\ 5000 parameter to auto_initialize and pass it to GenServer.call/3. (Merge the 1-arity and 2-arity function definitions to avoid default argument conflicts).
  • lib/anubis/server/transport/streamable_http/plug.ex#L381-L384: Pass opts.timeout to Session.auto_initialize/3 so the plug's request timeout (default 30s) is respected during auto-recovery.
🛠️ Proposed fixes

lib/anubis/server/session.ex:

-  `@spec` auto_initialize(GenServer.server()) :: :ok | {:error, term()}
-  def auto_initialize(session), do: auto_initialize(session, nil)
-
-  `@spec` auto_initialize(GenServer.server(), map() | nil) :: :ok | {:error, term()}
-  def auto_initialize(session, transport_context) do
-    GenServer.call(session, {:auto_initialize, transport_context})
+  `@spec` auto_initialize(GenServer.server(), map() | nil, timeout()) :: :ok | {:error, term()}
+  def auto_initialize(session, transport_context \\ nil, timeout \\ 5000) do
+    GenServer.call(session, {:auto_initialize, transport_context}, timeout)

lib/anubis/server/transport/streamable_http/plug.ex:

     defp start_and_auto_initialize_session(opts, session_id, context) do
       case start_new_session(opts, session_id) do
         {:ok, pid} ->
-          case Session.auto_initialize(pid, context) do
+          case Session.auto_initialize(pid, context, opts.timeout) do
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  `@spec` auto_initialize(GenServer.server(), map() | nil, timeout()) :: :ok | {:error, term()}
  def auto_initialize(session, transport_context \\ nil, timeout \\ 5000) do
    GenServer.call(session, {:auto_initialize, transport_context}, timeout)
📍 Affects 2 files
  • lib/anubis/server/session.ex#L142-L146 (this comment)
  • lib/anubis/server/transport/streamable_http/plug.ex#L381-L384

501-504: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Log unexpected linked process exits (P3). 😎

Silently ignoring {:EXIT, pid, reason} can hide critical failures from your observability stack, such as an attached process crashing unexpectedly. Let's add a quick debug log so these ghosts don't haunt us later!

🛠️ Proposed fix to log the exit
-  def handle_info({:EXIT, _pid, _reason}, state) do
+  def handle_info({:EXIT, pid, reason}, state) do
+    Logging.server_event("linked_process_exited", %{pid: inspect(pid), reason: inspect(reason)}, level: :debug)
     {:noreply, state}
   end

@zoedsoupe
zoedsoupe dismissed their stale review July 16, 2026 12:47

The merge-base changed after approval.

zoedsoupe pushed a commit to syf2211/anubis-mcp that referenced this pull request Jul 16, 2026
@zoedsoupe
zoedsoupe force-pushed the fix/init-failure-json-rpc-error branch from 4103f2e to dddedc1 Compare July 16, 2026 13:18
syf2211 added 2 commits July 16, 2026 10:37
Normalize init/2 and session-recovery failures to %Anubis.MCP.Error{}
via Error.wrap_reason/1 instead of returning raw {:init_failed, reason}
tuples that crash JSON.encode! in the StreamableHTTP transport.

Add transport-side wrap_reason/1 fallback and regression tests covering
plain and structured init/2 rejections.

Fixes zoedsoupe#205
@zoedsoupe
zoedsoupe force-pushed the fix/init-failure-json-rpc-error branch from dddedc1 to 14a8bff Compare July 16, 2026 13:39
@zoedsoupe

Copy link
Copy Markdown
Owner

hey, i've messed with history and fixed for you. thanks for the contribution and sorry.

@zoedsoupe
zoedsoupe merged commit f9af7cc into zoedsoupe:main Jul 16, 2026
12 checks passed
@zoedsoupe zoedsoupe mentioned this pull request Jul 16, 2026

@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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d24037e-5db8-4c8b-be09-4c589982b81c

📥 Commits

Reviewing files that changed from the base of the PR and between 4103f2e and 14a8bff.

📒 Files selected for processing (8)
  • lib/anubis/mcp/error.ex
  • lib/anubis/server.ex
  • lib/anubis/server/session.ex
  • lib/anubis/server/transport/streamable_http/plug.ex
  • test/anubis/mcp/error_test.exs
  • test/anubis/server/session_expiry_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
  • test/support/stub_session_recovery_server.ex

Comment on lines +642 to +649
request = %{
"jsonrpc" => "2.0",
"id" => "req-init-fail",
"method" => "tools/list",
"params" => %{}
}

body = JSON.encode!(request)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

P3 — Use test builders for message construction 😎

Both of these test payloads construct raw JSON-RPC maps and manually stringify them. As per coding guidelines, use Anubis.MCP.Builders and Message.encode_request/2 to keep test payloads consistent with the rest of the codebase (and the earlier tests in this exact file!).

  • test/anubis/server/transport/streamable_http/plug_test.exs#L642-L649: replace the raw map and JSON.encode! with build_request/2 and Message.encode_request/2.
  • test/anubis/server/transport/streamable_http/plug_test.exs#L670-L677: apply the same builder pattern here.
♻️ Example Refactor (for L642-649)
-      request = %{
-        "jsonrpc" => "2.0",
-        "id" => "req-init-fail",
-        "method" => "tools/list",
-        "params" => %{}
-      }
-
-      body = JSON.encode!(request)
+      request = build_request("tools/list", %{})
+      {:ok, body} = Message.encode_request(request, "req-init-fail")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
request = %{
"jsonrpc" => "2.0",
"id" => "req-init-fail",
"method" => "tools/list",
"params" => %{}
}
body = JSON.encode!(request)
request = build_request("tools/list", %{})
{:ok, body} = Message.encode_request(request, "req-init-fail")
📍 Affects 1 file
  • test/anubis/server/transport/streamable_http/plug_test.exs#L642-L649 (this comment)
  • test/anubis/server/transport/streamable_http/plug_test.exs#L670-L677

Source: Coding guidelines

zoedsoupe added a commit that referenced this pull request Jul 16, 2026
🚀 Want to release this?
---


##
[1.9.0](v1.8.0...v1.9.0)
(2026-07-16)


### Features

* **streamable_http:** add spec resumability (Last-Event-ID replay)
([#216](#216))
([78e33b4](78e33b4))
* support pre_initialized sessions for cross-pod restore
([#187](#187))
([13be0d7](13be0d7))


### Bug Fixes

* **session:** return encodable JSON-RPC errors when init/2 fails
([#211](#211))
([f9af7cc](f9af7cc))
* **streamable_http:** emit telemetry on SSE handler registration
([#217](#217))
([4a4c528](4a4c528))
* **streamable_http:** restore session from store on notif/resp registry
miss ([#221](#221))
([d757b39](d757b39))
* **streamable_http:** return correct JSON-RPC error codes for parse
failures ([#222](#222))
([1867994](1867994))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
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.

A failing Server.init/2 crashes the StreamableHTTP transport (JSON.Encoder/Tuple) instead of returning a JSON-RPC error

2 participants