ConduitMCP supports two layers of rate limiting using Hammer. Both are optional.

Setup

Add hammer to your dependencies:

def deps do
  [
    {:conduit_mcp, "~> 0.10"},
    {:hammer, "~> 7.2"}
  ]
end

After adding it, force a rebuild of :conduit_mcp:

mix deps.get
mix deps.compile conduit_mcp --force

ConduitMcp.Plugs.RateLimit resolves its Hammer backend at runtime, but :hammer must be in your dependency tree for the backend module to exist. If you add an optional dependency after :conduit_mcp has already been compiled into _build, Mix will not rebuild it — the --force compile above is what makes the change take effect.

Define a Hammer module and add it to your supervision tree:

defmodule MyApp.RateLimiter do
  use Hammer, backend: :ets
end

# In application.ex
children = [
  {MyApp.RateLimiter, [clean_period: :timer.minutes(1)]}
]

HTTP Rate Limiting

Limits raw HTTP connections — prevents DDoS and connection flooding.

rate_limit: [
  backend: MyApp.RateLimiter,
  scale: :timer.seconds(60),
  limit: 100
]
OptionDefaultDescription
:backendrequiredHammer module with hit/3
:enabledtrueToggle on/off
:scale60_000Time window in ms
:limit60Max requests per window
:key_funcclient bucket(Plug.Conn.t()) -> String.t(); default is the IPv4 address or IPv6 /64 (see Anonymous clients)

Message Rate Limiting

Limits MCP method calls (tool calls, resource reads, prompt gets) per time window.

Think of it as: HTTP rate limit = "how fast can you knock on the door", message rate limit = "how many questions can you ask once inside."

message_rate_limit: [
  backend: MyApp.RateLimiter,
  scale: :timer.minutes(5),
  limit: 50,
  excluded_methods: ["initialize", "ping"]
]
OptionDefaultDescription
:backendrequiredHammer module with hit/3
:enabledtrueToggle on/off
:scale300_000Time window in ms (5 min)
:limit50Max messages per window
:key_funcprincipal-awareUses ConduitMcp.Principal.id/1 if authenticated, falls back to the client bucket (IPv4 address or IPv6 /64)
:excluded_methods[]Methods to skip (e.g., ["initialize", "ping"])

Behaviors:

  • POST only — GET and OPTIONS requests pass through
  • Notifications skipped — JSON-RPC notifications (no id field) are not counted
  • User-aware — default key uses the canonical ConduitMcp.Principal when an auth plug is in the pipeline
  • Key prefix — keys are prefixed with "msg:" to avoid collision with HTTP rate limiter
  • HTTP 429 — returns JSON-RPC error with code -32000 and Retry-After header
  • Era rejections not counted — a modern (2026-07-28) request rejected for its _meta or its Mcp-* headers, and a legacy request with an unsupported MCP-Protocol-Version header, are answered (HTTP 400) before this plug runs, so they don't use up the client's allowance. Authentication and the HTTP rate limiter still run first.

Per-user Rate Limiting

The message rate limiter already does this out of the box: its default key is "msg:user:" <> ConduitMcp.Principal.id(conn) for authenticated requests and "msg:" <> the client bucket otherwise. Two OAuth subjects behind the same proxy therefore get distinct buckets with no configuration.

The HTTP rate limiter keys on the client bucket by default, because it runs to bound raw connections — including unauthenticated ones. Key it on the principal when you want per-user HTTP limits:

rate_limit: [
  backend: MyApp.RateLimiter,
  limit: 100,
  key_func: &ConduitMcp.Principal.rate_limit_key/1
]

rate_limit_key/1 returns "user:" <> id when authenticated and the client bucket otherwise. Do not hand-roll conn.remote_ip |> :inet.ntoa() |> to_string(): :inet.ntoa/1 returns {:error, :einval} for a malformed address and to_string/1 then raises, killing the request process instead of returning 429.

Anonymous clients

Unauthenticated callers are keyed on ConduitMcp.Principal.client_bucket/1, not on their exact address:

Client addressBucket
IPv4 192.0.2.7"192.0.2.7"
IPv6 2001:db8:1:2:a:b:c:d"2001:db8:1:2::/64"
IPv4-mapped IPv6 ::ffff:192.0.2.1"192.0.2.1"
malformed remote_ip"unknown"

An ISP or hosting provider typically allocates a whole /64 to one subscriber or host, and the host chooses the low 64 bits itself (privacy extensions rotate them automatically). Keying on the full IPv6 address would let a single client take a fresh bucket for every request, so every address in a /64 shares one. A dual-stack socket reports IPv4 peers as IPv4-mapped IPv6 addresses; those unwrap to the IPv4 address so one IPv4 client gets one bucket regardless of the socket family, and IPv4 clients are not all merged under ::ffff:0:0/64.

The same bucket is the cancellation scope of anonymous requests (see ConduitMcp.Cancellation), so rotating addresses within a /64 cannot multiply the per-scope quota on recorded cancellations either.

ConduitMcp.Principal.client_ip/1 still returns the precise address for logging and display. To key the HTTP rate limiter on it instead, pass it as a remote capture (required by Plug.Router.forward/2, which escapes the options at compile time):

rate_limit: [
  backend: MyApp.RateLimiter,
  key_func: &ConduitMcp.Principal.client_ip/1
]

For the message rate limiter, keep the "msg:" prefix so the two limiters do not share buckets:

defmodule MyApp.RateKeys do
  def message(conn) do
    case ConduitMcp.Principal.id(conn) do
      nil -> "msg:" <> ConduitMcp.Principal.client_ip(conn)
      id -> "msg:user:" <> id
    end
  end
end

message_rate_limit: [backend: MyApp.RateLimiter, key_func: &MyApp.RateKeys.message/1]

Listen streams

A modern subscriptions/listen holds a stream open for a long time, so it is capped separately from the Hammer limiters. These caps are always on and need no backend:

Transport optionDefaultOver the limit
:max_listens_per_principal4admitted; the principal's oldest stream is ended gracefully (reason :superseded)
:max_streams1_000HTTP 503, JSON-RPC -32000, no stream (shared with streamed tools/call responses)
:listen_idle_timeout30 minutesthe server closes the stream
:max_stream_lifetime1 hourthe server closes the stream (or earlier, at the OAuth token's exp)
{ConduitMcp.Transport.StreamableHTTP,
 server_module: MyApp.MCPServer,
 max_listens_per_principal: 2,
 max_streams: 500}
  • The per-principal key is ConduitMcp.Principal.rate_limit_key/1: the authenticated id, otherwise the client bucket (see Anonymous clients).
  • Over the per-principal cap the new listen wins: the principal's oldest stream is closed gracefully, so the cap is never a 429. 503 means the server as a whole is full.
  • A listen without text/event-stream in Accept is answered 406 (-32600).
  • The listen POST itself goes through the HTTP and message rate limiters like any other request. A refused listen (406, 503) emits no subscription telemetry.

At the :max_streams cap, a streamed tools/call is not refused: it is answered as plain JSON without its progress or log notifications. See Subscriptions.

Configuration in Endpoint Mode

In Endpoint mode, rate limiting is declarative in the use opts:

defmodule MyApp.MCPServer do
  use ConduitMcp.Endpoint,
    name: "My Server",
    version: "1.0.0",
    rate_limit: [backend: MyApp.RateLimiter, limit: 60, scale: 60_000],
    message_rate_limit: [backend: MyApp.RateLimiter, limit: 50, scale: 300_000]

  component MyApp.Echo
end

# Transport auto-extracts rate_limit config
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
 port: 4001}

Explicit transport opts always override Endpoint config.

Telemetry

  • [:conduit_mcp, :rate_limit, :check] — HTTP rate limit checks with %{status, count, retry_after}
  • [:conduit_mcp, :message_rate_limit, :check] — Message rate limit checks with %{status, key, method}