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 - 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 IP
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():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 already supports per-user rate limiting when the
ConduitMcp.Plugs.Auth plug is in the pipeline. If conn.assigns[:current_user]
is set, it will be used as the key. Otherwise, the client IP is used.
You can also provide a custom key function:
message_rate_limit: [
backend: MyApp.RateLimiter,
limit: 50,
key_func: fn conn ->
"msg:custom:" <> get_custom_key(conn)
end
]Without message rate limiting
Simply omit the :message_rate_limit option from your transport config.