Rate limiting plug for MCP servers.
This plug is completely optional. If you don't need rate limiting, simply
omit the :rate_limit option from your transport config — no additional
dependencies are required.
Dependencies
Rate limiting requires the hammer package.
Add it to your mix.exs only if you intend to use this plug:
{:hammer, "~> 7.2"}How it works
You define your own Hammer module, supervise it in your application, and pass
it as the :backend option. This gives you full control over the backend
(:ets, :atomic), algorithm (:fix_window, :leaky_bucket, etc.), and
supervision strategy.
Options
:backend- Required when enabled. A module that implementshit/3(e.g., a module defined withuse Hammer, backend: :ets). You must supervise this module in your own application supervision tree.:enabled- Enable/disable rate limiting (default:true):scale- Time window in milliseconds (default:60_000):limit- Maximum requests per window (default:60):key_func- Function to derive the rate limit key from the connection (default: the client's IPv4 address or IPv6/64, see below). Signature:(Plug.Conn.t()) -> String.t(). Pass a remote capture (&MyApp.RateKeys.http/1), not an anonymousfn:Plug.Router.forward/2escapes transport init opts at compile time, and anonymous functions cannot be escaped.
Setup
1. Define your Hammer module
defmodule MyApp.RateLimiter do
use Hammer, backend: :ets
end2. Add to your supervision tree
children = [
{MyApp.RateLimiter, [clean_period: :timer.minutes(1)]}
]3. Pass as :backend in transport config
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
rate_limit: [
backend: MyApp.RateLimiter,
scale: :timer.seconds(60),
limit: 100
]},
port: 4001}Default key
The default key is ConduitMcp.Principal.client_bucket/1, because this plug
bounds raw connections including unauthenticated ones: the address for an
IPv4 client, and the /64 prefix ("2001:db8:1:2::/64") for an IPv6 one.
One host is typically allocated a whole /64 and picks the low 64 bits
itself, so a per-address key would let it rotate addresses for a fresh
bucket on every request. IPv4-mapped IPv6 addresses (::ffff:192.0.2.1)
count as the embedded IPv4 address.
To key on the precise address instead — for example when every client
behind one /64 is a distinct tenant you trust — pass
ConduitMcp.Principal.client_ip/1:
rate_limit: [
backend: MyApp.RateLimiter,
key_func: &ConduitMcp.Principal.client_ip/1
]Per-user rate limiting
To bucket by authenticated caller instead, use the canonical principal
(anonymous callers still fall back to ConduitMcp.Principal.client_bucket/1):
rate_limit: [
backend: MyApp.RateLimiter,
limit: 100,
key_func: &ConduitMcp.Principal.rate_limit_key/1
]Do not hand-roll conn.remote_ip |> :inet.ntoa() |> to_string() in a custom
key function: :inet.ntoa/1 returns {:error, :einval} for a malformed
address and to_string/1 on that raises, killing the request process
instead of returning 429. ConduitMcp.Principal.client_ip/1 and
ConduitMcp.Principal.client_bucket/1 return "unknown" instead.
Without rate limiting
Simply omit the :rate_limit option from your transport config. The hammer
dependency is not required and won't be compiled.