AttestoMCP.Anubis.Registry.Horde (AttestoMCP v1.0.1)

Copy Markdown View Source

Horde.Registry-backed session registry for the Anubis MCP transports.

Why not Anubis.Server.Registry.PG

The bundled Anubis.Server.Registry.PG adapter does not implement the optional session_name/2 callback. When the transport resolves a session name, the fallback builds a dynamic atom from the client-supplied mcp-session-id:

:"#{registry_name}.session.#{session_id}"

Atoms are never garbage collected, so a client that walks distinct mcp-session-id values can exhaust the BEAM atom table and crash the node - a remote, unauthenticated denial of service. :pg also gives no cluster-wide name uniqueness, only process-group membership.

This adapter implements session_name/2, returning a :via tuple:

{:via, Horde.Registry, {registry_name, session_id}}

Because Anubis passes that name to the session GenServer.start_link/3, the session auto-registers in the CRDT-backed Horde.Registry on start. The session_id is kept as ETS/CRDT data, never converted to an atom, so the atom-exhaustion vector is closed. Horde gives cluster-wide name uniqueness and resolves a registered name to the owning (possibly remote) pid, so a request that lands on any node routes transparently to the node holding the session.

Anubis starts the session DynamicSupervisor itself, so this adapter only starts the Horde.Registry; cross-node request routing is handled by distributed Erlang GenServer.call/3 against the pid that Horde.Registry.lookup/2 returns, exactly as the :pg adapter relied on.

Wiring

{MyApp.MCP.Server,
 transport: :streamable_http,
 registry: {AttestoMCP.Anubis.Registry.Horde, []},
 # ...
}

Cluster membership uses members: :auto, so every node running the same Anubis registry name discovers its peers (pair it with a clustering library such as libcluster so the nodes connect).

Compile-guarded on Anubis.Server.Registry and Horde.Registry.

Summary

Functions

Starts the Horde.Registry for the given Anubis registry name.

Looks the session pid up in the cluster-wide Horde.Registry.

No-op: the session registers itself via the :via tuple from session_name/2 when it starts.

Returns the :via tuple Anubis uses as the session GenServer name.

No-op: Horde.Registry removes the entry automatically when the owning session process exits.

Functions

child_spec(opts)

Starts the Horde.Registry for the given Anubis registry name.

Anubis injects :name (a compile-time bounded atom such as :"Anubis.MyApp.MCP.Server.registry") into opts. keys: :unique is required for :via registration; members: :auto lets Horde discover peers running the same registry name across the connected cluster.

lookup_session(registry_name, session_id)

Looks the session pid up in the cluster-wide Horde.Registry.

register_session(registry_name, session_id, pid)

No-op: the session registers itself via the :via tuple from session_name/2 when it starts.

session_name(registry_name, session_id)

Returns the :via tuple Anubis uses as the session GenServer name.

Returning a :via tuple (rather than an atom) means the session registers itself in Horde.Registry on start_link, so register_session/3 is a no-op and no atom is ever derived from the session_id.

unregister_session(registry_name, session_id)

No-op: Horde.Registry removes the entry automatically when the owning session process exits.

Horde.Registry.unregister/2 only unregisters names owned by the calling process; here the caller is the transport, not the session, so an explicit unregister would be a misleading no-op. Anubis terminates sessions through the session DynamicSupervisor, and the resulting process exit drops the Horde entry. Anubis never invokes this callback itself; it exists only to satisfy the behaviour.