Class: Otto::MCP::RateLimiter

Inherits:
Security::RateLimiting show all
Defined in:
lib/otto/mcp/rate_limiting.rb

Overview

Rate limiter for MCP protocol endpoints

Rack::Attack configuration is process-global and a throttle is keyed by name alone, so registering 'mcp_requests' for a second MCP app in the same process used to REPLACE the first app's throttle: the captured endpoint moved from /a to /b and /a stopped being rate limited. Each configured endpoint now gets its own throttle pair ('mcp_requests:/a', 'mcp_tool_calls:/a', ...) with its own limits, and every endpoint configured in the process is remembered in RateLimiter.registered_endpoints so the (single, global) throttled responder and log subscriber recognise all of them, not just the most recent.

Known limitation: throttles are keyed by endpoint path, not by Otto instance. Two apps in one process that mount MCP on the SAME path (for example both on /_mcp behind a host or tenant dispatcher) share one throttle definition, so the limits configured last apply to both, and one set of per-client-IP counters. Use distinct endpoint paths per app.

Constant Summary collapse

DEFAULT_HTTP_ENDPOINT =
'/_mcp'

Constants inherited from Security::RateLimiting

Security::RateLimiting::RACK_ATTACK_REQUIREMENT

Class Method Summary collapse

Methods inherited from Security::RateLimiting

configure_responses, ensure_available!, general_throttled_response, throttle_headers

Class Method Details

.configure_loggingObject

Keep one MCP-aware subscriber while allowing it to recognize every endpoint registered in this process.



193
194
195
196
197
198
199
200
# File 'lib/otto/mcp/rate_limiting.rb', line 193

def self.configure_logging
  return unless defined?(ActiveSupport::Notifications)

  ActiveSupport::Notifications.unsubscribe(@log_subscriber) if @log_subscriber
  @log_subscriber = ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
    log_throttled_request(payload)
  end
end

.configure_mcp_rules(config) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/otto/mcp/rate_limiting.rb', line 128

def self.configure_mcp_rules(config)
  # Captured once, outside the blocks: they run per request, long after
  # this configuration hash is gone.
  configured_endpoint = config[:mcp_http_endpoint]

  # MCP endpoint requests - 60 per minute by default. Only the exact
  # endpoint path counts: the router dispatches the MCP route by literal
  # match, so a prefix match here would let /admin traffic exhaust (and be
  # refused by) the counter for an endpoint at /a. PATH_INFO, not #path:
  # a mount prefix (SCRIPT_NAME) is invisible to the router and must be
  # invisible here too, or a mounted Otto is never throttled.
  mcp_requests_limit = config[:mcp_requests_per_minute] || 60

  Rack::Attack.throttle(throttle_name('mcp_requests', configured_endpoint),
                        limit: mcp_requests_limit, period: 60) do |request|
    endpoint = mcp_endpoint_for(configured_endpoint, request.env)
    request.ip if Otto::MCP.endpoint_path?(request.path_info, endpoint)
  end

  # Tool calls are more expensive - 20 per minute by default
  tool_calls_limit = config[:tool_calls_per_minute] || 20

  Rack::Attack.throttle(throttle_name('mcp_tool_calls', configured_endpoint),
                        limit: tool_calls_limit, period: 60) do |request|
    endpoint = mcp_endpoint_for(configured_endpoint, request.env)
    if Otto::MCP.endpoint_path?(request.path_info, endpoint) && request.post?
      begin
        body = request.body.read
        data = JSON.parse(body)
        request.ip if data['method'] == 'tools/call'
      rescue JSON::ParserError
        nil
      ensure
        request.body.rewind if request.body.respond_to?(:rewind)
      end
    end
  end
end

.configure_rack_attack!(config = {}) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/otto/mcp/rate_limiting.rb', line 59

def self.configure_rack_attack!(config = {})
  # Start with base configuration from general rate limiting. The base
  # configure_rack_attack! assigns the single Rack::Attack
  # throttled_responder (dispatching to .throttled_response, overridden
  # below) and calls .configure_logging (also overridden below), so this
  # class registers no responder or subscriber of its own after super.
  super

  register_endpoint(config[:mcp_http_endpoint])
  configure_mcp_rules(config)
end

.log_throttled_request(payload) ⇒ Object

Masked address only — see the note on the base implementation in Otto::Security::RateLimiting (issue #219).



204
205
206
207
208
209
210
# File 'lib/otto/mcp/rate_limiting.rb', line 204

def self.log_throttled_request(payload)
  req = payload[:request]
  return super unless mcp_request?(req)

  ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
  Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
end

.mcp_endpoint_for(configured, env) ⇒ String

Resolve the MCP endpoint a Rack::Attack throttle should match against.

Rack::Attack is mounted by the hosting app OUTSIDE Otto and runs before every Otto middleware, including the proc that sets env, so inside a throttle that env key is absent. The endpoint therefore has to arrive with the configuration (Server#apply_rate_limits publishes it as :mcp_http_endpoint). The env key is kept as a fallback for callers that run Rack::Attack inside Otto's own stack, and the documented default closes the chain.

Parameters:

  • endpoint from the rate limiting config

  • the request env

Returns:



84
85
86
# File 'lib/otto/mcp/rate_limiting.rb', line 84

def self.mcp_endpoint_for(configured, env)
  configured || env['otto.mcp_http_endpoint'] || DEFAULT_HTTP_ENDPOINT
end

.mcp_request?(request) ⇒ Boolean

Whether a request targets ANY MCP endpoint configured in this process.

Used by the throttled responder and the log subscriber, which are global (the last configuration wins) and so cannot capture a single endpoint the way a per-endpoint throttle can. Consults the registry at call time, then the env key, then the default when nothing is configured at all. A request matches only the exact endpoint path the router would dispatch to the MCP handler (see Otto::MCP.endpoint_path?), never a sibling that merely shares the prefix.

Compares PATH_INFO, not Rack::Request#path (SCRIPT_NAME + PATH_INFO): the router dispatches on PATH_INFO, so under map '/api' { run otto } the MCP request for an endpoint at /_mcp arrives as SCRIPT_NAME=/api, PATH_INFO=/_mcp and the full path /api/_mcp never equals the endpoint.

Parameters:

Returns:



105
106
107
108
109
110
111
112
# File 'lib/otto/mcp/rate_limiting.rb', line 105

def self.mcp_request?(request)
  candidates = registered_endpoints
  env_endpoint = request.env['otto.mcp_http_endpoint']
  candidates << env_endpoint if env_endpoint
  candidates << DEFAULT_HTTP_ENDPOINT if candidates.empty?

  candidates.any? { |endpoint| Otto::MCP.endpoint_path?(request.path_info, endpoint) }
end

.register_endpoint(endpoint) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

API:

  • private



52
53
54
55
56
# File 'lib/otto/mcp/rate_limiting.rb', line 52

def register_endpoint(endpoint)
  return if endpoint.nil?

  @endpoints_mutex.synchronize { @endpoints << endpoint }
end

.registered_endpointsArray<String>

Every MCP endpoint configured via configure_rack_attack! in this process, in configuration order.

Returns:



39
40
41
# File 'lib/otto/mcp/rate_limiting.rb', line 39

def registered_endpoints
  @endpoints_mutex.synchronize { @endpoints.to_a }
end

.reset_endpoints!Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Forget every registered endpoint. Test isolation only: production configuration is additive for the life of the process, like Rack::Attack's own.

API:

  • private



47
48
49
# File 'lib/otto/mcp/rate_limiting.rb', line 47

def reset_endpoints!
  @endpoints_mutex.synchronize { @endpoints.clear }
end

.throttle_name(rule, endpoint) ⇒ String

Name of the Rack::Attack throttle for rule on endpoint.

The endpoint is part of the name so that two MCP apps in one process get independent throttles (and independent counters: Rack::Attack keys the cache by throttle name). Without a configured endpoint the bare rule name is used and the block resolves the endpoint per request.

Parameters:

  • 'mcp_requests' or 'mcp_tool_calls'

Returns:



124
125
126
# File 'lib/otto/mcp/rate_limiting.rb', line 124

def self.throttle_name(rule, endpoint)
  endpoint ? "#{rule}:#{endpoint}" : rule
end

.throttled_response(request) ⇒ Object

JSON-RPC formatted 429 for MCP requests; the general Otto response (route response_type, then Accept header) for everything else.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/otto/mcp/rate_limiting.rb', line 169

def self.throttled_response(request)
  return super unless mcp_request?(request)

  match_data = request.env['rack.attack.match_data']
  headers    = throttle_headers(match_data)

  error_response = {
    jsonrpc: '2.0',
    id: nil,
    error: {
      code: -32_000,
      message: 'Rate limit exceeded',
      data: {
        retry_after: headers['retry-after'].to_i,
        limit: match_data[:limit],
        period: match_data[:period],
      },
    },
  }
  [429, headers, [JSON.generate(error_response)]]
end