Class: Otto::MCP::RateLimiter
- Inherits:
-
Security::RateLimiting
- Object
- Security::RateLimiting
- Otto::MCP::RateLimiter
- 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
-
.configure_logging ⇒ Object
Keep one MCP-aware subscriber while allowing it to recognize every endpoint registered in this process.
- .configure_mcp_rules(config) ⇒ Object
- .configure_rack_attack!(config = {}) ⇒ Object
-
.log_throttled_request(payload) ⇒ Object
Masked address only — see the note on the base implementation in Otto::Security::RateLimiting (issue #219).
-
.mcp_endpoint_for(configured, env) ⇒ String
Resolve the MCP endpoint a Rack::Attack throttle should match against.
-
.mcp_request?(request) ⇒ Boolean
Whether a request targets ANY MCP endpoint configured in this process.
- .register_endpoint(endpoint) ⇒ Object private
-
.registered_endpoints ⇒ Array<String>
Every MCP endpoint configured via RateLimiter.configure_rack_attack! in this process, in configuration order.
-
.reset_endpoints! ⇒ Object
private
Forget every registered endpoint.
-
.throttle_name(rule, endpoint) ⇒ String
Name of the Rack::Attack throttle for
ruleonendpoint. -
.throttled_response(request) ⇒ Object
JSON-RPC formatted 429 for MCP requests; the general Otto response (route response_type, then Accept header) for everything else.
Methods inherited from Security::RateLimiting
configure_responses, ensure_available!, general_throttled_response, throttle_headers
Class Method Details
.configure_logging ⇒ Object
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.
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.
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.
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_endpoints ⇒ Array<String>
Every MCP endpoint configured via configure_rack_attack! in this process, in configuration order.
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.
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.
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 |