Source code for smarter.apps.mcpserver.protocol.ratelimit

"""
The per-IP rate limit of the MCPServer's public endpoint.

The MCP server requires no authentication, so each client IP address may send at most
``smarter_settings.mcpserver_rate_limit`` requests a minute, 1200 by default. The requests
are counted in fixed one-minute windows, in Redis, so that every pod of the platform shares
the count. A request beyond the limit is answered ``429 Too Many Requests``, with a
``Retry-After`` header, until the window ends.

The client's address is found as :class:`~smarter.common.mixins.SmarterMiddlewareMixin`
finds it, from ``X-Forwarded-For``, ``X-Real-IP``, ``CF-Connecting-IP`` or the connection
itself, ignoring private addresses, which are the platform's own proxies. A request whose
public address cannot be determined, e.g. in local development, is not limited.

.. note::

    **Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

import time
from typing import Optional

from cache_results import lazy_cache
from django.http import HttpRequest

from smarter.common.conf import smarter_settings
from smarter.common.mixins import SmarterMiddlewareMixin

WINDOW_SECONDS = 60
"""The length of a rate limit window, in seconds."""

WINDOW_KEY_TIMEOUT = WINDOW_SECONDS * 2
"""How long a window's count is kept in the cache: two windows, so that it outlives its window."""

RATE_LIMITED = -32010
"""The JSON-RPC error code of a rate-limited request, in the implementation-defined range of MCP's error codes."""


class ClientAddress(SmarterMiddlewareMixin):
    """
    Finds a request's client address, as the platform's middleware does, outside of a middleware.

    :class:`~smarter.common.mixins.SmarterMiddlewareMixin` knows which headers carry the
    client's address behind the platform's proxies, in
    :meth:`~smarter.common.mixins.SmarterMiddlewareMixin.get_client_ip`. It is a middleware, so
    its constructor needs a ``get_response`` callable, which is never called here.
    """

    def __init__(self):
        """Create the helper, with a ``get_response`` that is never called."""
        super().__init__(get_response=lambda request: None)


_client_address: Optional[ClientAddress] = None
"""The one :class:`ClientAddress` of the process, created on the first call of :func:`client_ip`."""


[docs] def client_ip(request: HttpRequest) -> Optional[str]: """ Return the public IP address of a request's client. The :class:`ClientAddress` helper is created on the first call, and reused. :param request: The request. :returns: The address, or None if it cannot be determined. """ global _client_address # pylint: disable=global-statement if _client_address is None: _client_address = ClientAddress() return _client_address.get_client_ip(request)
def current_window() -> int: """ Return the number of the current rate limit window. The windows are numbered from the Unix epoch, so every pod agrees on the current one. :returns: The number of :data:`WINDOW_SECONDS`-long windows since the epoch. """ now = time.time() return int(now // WINDOW_SECONDS)
[docs] def retry_after() -> int: """ Return the seconds until the current rate limit window ends, for a ``Retry-After`` header. :returns: The seconds left in the window, at least 1. """ now = time.time() seconds_into_window = int(now % WINDOW_SECONDS) seconds_left = WINDOW_SECONDS - seconds_into_window return max(1, seconds_left)
def rate_limit_key(ip: str, window: int) -> str: """ Return the cache key of a client's request count in a window. :param ip: The client's IP address. :param window: The window's number, from :func:`current_window`. :returns: The key, e.g. ``smarter.apps.mcpserver.protocol.ratelimit.203.0.113.7.29374512``. """ return f"smarter.apps.mcpserver.protocol.ratelimit.{ip}.{window}"
[docs] def is_rate_limited(ip: Optional[str]) -> bool: """ Count a request of a client, and return whether the client is over the limit. The count is kept in the cache (Redis), so every pod of the platform shares it. The first request of a window creates its key with ``add()``, and each later one increments it with ``incr()``, which is atomic, so that concurrent requests are all counted. Side effects: one or two cache writes, unless the limit is off or the address is unknown. :param ip: The client's IP address, from :func:`client_ip`. :returns: True if the client has sent more than ``smarter_settings.mcpserver_rate_limit`` requests in the current window. Always False if the limit is 0, or the address is unknown. """ limit = smarter_settings.mcpserver_rate_limit if not limit or not ip: return False window = current_window() key = rate_limit_key(ip, window) is_first_request = lazy_cache.add(key, 1, timeout=WINDOW_KEY_TIMEOUT) if is_first_request: count = 1 else: try: count = lazy_cache.incr(key) except ValueError: # the key expired between add() and incr(). lazy_cache.set(key, 1, timeout=WINDOW_KEY_TIMEOUT) count = 1 is_over_limit = count > limit return is_over_limit
__all__ = ["RATE_LIMITED", "client_ip", "is_rate_limited", "retry_after"]