Message-level rate limiting plug for MCP servers.
While the HTTP-level rate limit (ConduitMcp.Plugs.RateLimit) limits raw HTTP
connections, this plug limits the number of MCP message interactions (tool
calls, resource reads, prompt gets) a client can make 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."
This plug is completely optional. If you don't need message-level rate
limiting, simply omit the :message_rate_limit option from your transport
config — no additional dependencies are required.
Dependencies
Message 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
- Only POST requests are counted (GET/OPTIONS pass through)
- JSON-RPC notifications (requests without an
idfield) are not counted, exceptnotifications/cancelled, which mutates server state - Specific methods can be excluded (e.g.,
"initialize","ping") - Keys are prefixed with
"msg:"to prevent Hammer counter collision when both HTTP and message rate limiters share the same backend - Authenticated users are tracked by user ID; anonymous users by client
bucket (
ConduitMcp.Principal.client_bucket/1: the IPv4 address, or the IPv6 /64)
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 message rate limiting (default:true):scale- Time window in milliseconds (default:300_000/ 5 minutes):limit- Maximum messages per window (default:50):key_func- Function to derive the rate limit key from the connection (default: user-aware with"msg:"prefix). Signature:(Plug.Conn.t()) -> String.t(). Pass a remote capture (&MyApp.RateKeys.msg/1), not an anonymousfn:Plug.Router.forward/2escapes transport init opts at compile time, and anonymous functions cannot be escaped.:excluded_methods- List of MCP method names to skip rate limiting for (default:[]). Example:["initialize", "ping"]
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 :message_rate_limit in transport config
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
rate_limit: [
backend: MyApp.RateLimiter,
scale: :timer.seconds(60),
limit: 100
],
message_rate_limit: [
backend: MyApp.RateLimiter,
scale: :timer.minutes(5),
limit: 50
]},
port: 4001}Per-user rate limiting
The default key function gives each authenticated caller their own bucket:
it keys on ConduitMcp.Principal.id/1 — the stable scalar identity written
by ConduitMcp.Plugs.Auth and ConduitMcp.Plugs.OAuth — and falls back to
the client bucket (ConduitMcp.Principal.client_bucket/1: the IPv4 address,
or the IPv6 /64) when the request is unauthenticated. Two OAuth subjects
behind the same proxy therefore get distinct buckets.
To key anonymous callers on the exact address instead, or on anything else, provide a custom key function. It must be a remote capture of a public function:
defmodule MyApp.RateKeys do
def msg(conn), do: "msg:custom:" <> MyApp.custom_key(conn)
end
message_rate_limit: [
backend: MyApp.RateLimiter,
limit: 50,
key_func: &MyApp.RateKeys.msg/1
]Without message rate limiting
Simply omit the :message_rate_limit option from your transport config.