ConduitMcp.Plugs.MessageRateLimit (ConduitMCP v0.11.0)

Copy Markdown View Source

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 id field) are not counted, except notifications/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 implements hit/3 (e.g., a module defined with use 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 anonymous fn: Plug.Router.forward/2 escapes 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
end

2. 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.