otto 2.9.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +8 -5
  12. data/AGENTS.md +40 -1
  13. data/CHANGELOG.rst +235 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +157 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +293 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +40 -23
  53. data/lib/otto/core/error_handler.rb +40 -2
  54. data/lib/otto/core/file_safety.rb +105 -31
  55. data/lib/otto/core/middleware_stack.rb +36 -36
  56. data/lib/otto/core/router.rb +94 -18
  57. data/lib/otto/core/static_mounts.rb +172 -0
  58. data/lib/otto/core.rb +1 -0
  59. data/lib/otto/env_keys.rb +20 -2
  60. data/lib/otto/mcp/auth/token.rb +10 -4
  61. data/lib/otto/mcp/core.rb +23 -5
  62. data/lib/otto/mcp/endpoint.rb +41 -0
  63. data/lib/otto/mcp/errors.rb +15 -0
  64. data/lib/otto/mcp/options.rb +292 -0
  65. data/lib/otto/mcp/protocol.rb +52 -22
  66. data/lib/otto/mcp/rate_limiting.rb +175 -97
  67. data/lib/otto/mcp/registry.rb +14 -14
  68. data/lib/otto/mcp/schema_validation.rb +20 -12
  69. data/lib/otto/mcp/server.rb +131 -32
  70. data/lib/otto/optional_dependency.rb +57 -0
  71. data/lib/otto/privacy/config.rb +10 -8
  72. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  73. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  74. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  76. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  77. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  78. data/lib/otto/security/config.rb +328 -14
  79. data/lib/otto/security/configurator.rb +36 -6
  80. data/lib/otto/security/core.rb +19 -1
  81. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  82. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  83. data/lib/otto/security/rate_limiter.rb +81 -46
  84. data/lib/otto/static.rb +38 -0
  85. data/lib/otto/utils.rb +50 -0
  86. data/lib/otto/version.rb +1 -1
  87. data/lib/otto.rb +130 -13
  88. data/otto.gemspec +0 -2
  89. metadata +32 -41
  90. data/docs/.gitignore +0 -10
  91. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  92. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  93. data/docs/enrichment.md +0 -128
  94. data/docs/geo-country.md +0 -181
  95. data/docs/ipaddr-encoding-quirk.md +0 -34
  96. data/docs/migrating/v2.0.0-pre1.md +0 -276
  97. data/docs/migrating/v2.0.0-pre2.md +0 -338
  98. data/docs/modern-authentication-authorization-landscape.md +0 -558
  99. data/docs/multi-strategy-authentication-design.md +0 -1401
  100. data/docs/reverse-proxy-network-services.md +0 -371
@@ -4,11 +4,47 @@
4
4
 
5
5
  require 'json'
6
6
  require_relative 'registry'
7
+ require_relative 'errors'
7
8
 
8
9
  class Otto
9
10
  module MCP
10
- # MCP protocol handler providing Model Context Protocol functionality
11
+ # MCP protocol handler providing Model Context Protocol functionality.
12
+ #
13
+ # == JSON-RPC error code to HTTP status mapping
14
+ #
15
+ # Code Meaning HTTP
16
+ # -32700 Parse error 400
17
+ # -32600 Invalid Request 400
18
+ # -32601 Method not found 400
19
+ # -32602 Invalid params 400
20
+ # -32001 Resource not found 404
21
+ # -32002 Tool not found 404
22
+ # -32603 Internal error 500
23
+ # -32099..-32000 server errors 500
24
+ #
25
+ # Protocol-level faults (a malformed or unsupported payload against a valid
26
+ # endpoint) are 400, including method-not-found: 404 there would conflate a
27
+ # bad payload with "no such route", which the MCP endpoint handler already
28
+ # returns when MCP is disabled. 404 is reserved for a well-formed request
29
+ # naming an entity that is not registered, and 500 for handler execution
30
+ # faults. Handler exception messages are logged, never returned: -32603
31
+ # responses carry a fixed message.
11
32
  class Protocol
33
+ # @see Protocol for the rationale behind this mapping.
34
+ ERROR_STATUS_MAP = {
35
+ -32_700 => 400, # Parse error
36
+ -32_600 => 400, # Invalid Request
37
+ -32_601 => 400, # Method not found
38
+ -32_602 => 400, # Invalid params
39
+ -32_001 => 404, # Resource not found
40
+ -32_002 => 404, # Tool not found
41
+ -32_603 => 500, # Internal error
42
+ }.freeze
43
+
44
+ # Implementation-defined server error range; anything unmapped inside it
45
+ # is an execution fault.
46
+ SERVER_ERROR_RANGE = (-32_099..-32_000)
47
+
12
48
  attr_reader :registry
13
49
 
14
50
  def initialize(otto_instance)
@@ -89,7 +125,15 @@ class Otto
89
125
 
90
126
  return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
91
127
 
92
- resource = @registry.read_resource(uri)
128
+ begin
129
+ resource = @registry.read_resource(uri)
130
+ rescue StandardError => e
131
+ # Detail stays in the log: handler exceptions (Errno::*, constant
132
+ # resolution) can carry absolute paths and internals.
133
+ Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.class}: #{e.message}"
134
+ return error_response(data['id'], -32_603, 'Internal error', 'Resource read failed')
135
+ end
136
+
93
137
  if resource
94
138
  success_response(data['id'], resource)
95
139
  else
@@ -112,9 +156,11 @@ class Otto
112
156
  begin
113
157
  result = @registry.call_tool(name, arguments, env)
114
158
  success_response(data['id'], result)
159
+ rescue Otto::MCP::ToolNotFoundError => e
160
+ error_response(data['id'], -32_002, 'Tool not found', e.message)
115
161
  rescue StandardError => e
116
- Otto.logger.error "[MCP] Tool call error: #{e.message}"
117
- error_response(data['id'], -32_603, 'Internal error', e.message)
162
+ Otto.logger.error "[MCP] Tool call error for #{name}: #{e.class}: #{e.message}"
163
+ error_response(data['id'], -32_603, 'Internal error', 'Tool execution failed')
118
164
  end
119
165
  end
120
166
 
@@ -138,24 +184,8 @@ class Otto
138
184
  error: error,
139
185
  })
140
186
 
141
- # Map JSON-RPC error codes to appropriate HTTP status codes
142
- http_status = case code
143
- when -32_700..-32_600 # Parse error, Invalid Request, Method not found
144
- 400
145
- when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
146
- 500
147
- when -32_001 # Resource not found
148
- 404
149
- when -32_002 # Tool not found
150
- 404
151
- when -32_601 # Method not found
152
- 404
153
- when -32_602 # Invalid params
154
- 400
155
- else
156
- # Default client error for unknown non-server codes; treat server-range as 500
157
- (-32_099..-32_000).cover?(code) ? 500 : 400
158
- end
187
+ # Map JSON-RPC error codes to HTTP status codes (see ERROR_STATUS_MAP).
188
+ http_status = ERROR_STATUS_MAP[code] || (SERVER_ERROR_RANGE.cover?(code) ? 500 : 400)
159
189
 
160
190
  [http_status, { 'content-type' => 'application/json' }, [body]]
161
191
  end
@@ -5,44 +5,152 @@
5
5
  require 'json'
6
6
 
7
7
  require_relative '../security/rate_limiting'
8
-
9
- begin
10
- require 'rack/attack'
11
- rescue LoadError
12
- # rack-attack is optional - graceful fallback
13
- end
8
+ require_relative 'endpoint'
14
9
 
15
10
  class Otto
16
11
  module MCP
17
12
  # Rate limiter for MCP protocol endpoints
13
+ #
14
+ # Rack::Attack configuration is process-global and a throttle is keyed by
15
+ # name alone, so registering 'mcp_requests' for a second MCP app in the
16
+ # same process used to REPLACE the first app's throttle: the captured
17
+ # endpoint moved from /a to /b and /a stopped being rate limited. Each
18
+ # configured endpoint now gets its own throttle pair
19
+ # ('mcp_requests:/a', 'mcp_tool_calls:/a', ...) with its own limits, and
20
+ # every endpoint configured in the process is remembered in
21
+ # {.registered_endpoints} so the (single, global) throttled responder and
22
+ # log subscriber recognise all of them, not just the most recent.
23
+ #
24
+ # Known limitation: throttles are keyed by endpoint path, not by Otto
25
+ # instance. Two apps in one process that mount MCP on the SAME path (for
26
+ # example both on /_mcp behind a host or tenant dispatcher) share one
27
+ # throttle definition, so the limits configured last apply to both, and
28
+ # one set of per-client-IP counters. Use distinct endpoint paths per app.
18
29
  class RateLimiter < Otto::Security::RateLimiting
19
- def self.configure_rack_attack!(config = {})
20
- return unless defined?(Rack::Attack)
30
+ DEFAULT_HTTP_ENDPOINT = '/_mcp'
31
+
32
+ @endpoints = Set.new
33
+ @endpoints_mutex = Mutex.new
34
+
35
+ class << self
36
+ # Every MCP endpoint configured via {.configure_rack_attack!} in this
37
+ # process, in configuration order.
38
+ # @return [Array<String>]
39
+ def registered_endpoints
40
+ @endpoints_mutex.synchronize { @endpoints.to_a }
41
+ end
42
+
43
+ # Forget every registered endpoint. Test isolation only: production
44
+ # configuration is additive for the life of the process, like
45
+ # Rack::Attack's own.
46
+ # @api private
47
+ def reset_endpoints!
48
+ @endpoints_mutex.synchronize { @endpoints.clear }
49
+ end
50
+
51
+ # @api private
52
+ def register_endpoint(endpoint)
53
+ return if endpoint.nil?
21
54
 
22
- # Start with base configuration from general rate limiting
55
+ @endpoints_mutex.synchronize { @endpoints << endpoint }
56
+ end
57
+ end
58
+
59
+ def self.configure_rack_attack!(config = {})
60
+ # Start with base configuration from general rate limiting. The base
61
+ # configure_rack_attack! assigns the single Rack::Attack
62
+ # throttled_responder (dispatching to .throttled_response, overridden
63
+ # below) and calls .configure_logging (also overridden below), so this
64
+ # class registers no responder or subscriber of its own after super.
23
65
  super
24
66
 
25
- # Add MCP-specific rules
67
+ register_endpoint(config[:mcp_http_endpoint])
26
68
  configure_mcp_rules(config)
27
- configure_mcp_responses
28
- configure_mcp_logging
69
+ end
70
+
71
+ # Resolve the MCP endpoint a Rack::Attack throttle should match against.
72
+ #
73
+ # Rack::Attack is mounted by the hosting app OUTSIDE Otto and runs before
74
+ # every Otto middleware, including the proc that sets
75
+ # env['otto.mcp_http_endpoint'], so inside a throttle that env key is
76
+ # absent. The endpoint therefore has to arrive with the configuration
77
+ # (Server#apply_rate_limits publishes it as :mcp_http_endpoint). The env
78
+ # key is kept as a fallback for callers that run Rack::Attack inside
79
+ # Otto's own stack, and the documented default closes the chain.
80
+ #
81
+ # @param configured [String, nil] endpoint from the rate limiting config
82
+ # @param env [Hash] the request env
83
+ # @return [String]
84
+ def self.mcp_endpoint_for(configured, env)
85
+ configured || env['otto.mcp_http_endpoint'] || DEFAULT_HTTP_ENDPOINT
86
+ end
87
+
88
+ # Whether a request targets ANY MCP endpoint configured in this process.
89
+ #
90
+ # Used by the throttled responder and the log subscriber, which are
91
+ # global (the last configuration wins) and so cannot capture a single
92
+ # endpoint the way a per-endpoint throttle can. Consults the registry at
93
+ # call time, then the env key, then the default when nothing is
94
+ # configured at all. A request matches only the exact endpoint path the
95
+ # router would dispatch to the MCP handler (see Otto::MCP.endpoint_path?),
96
+ # never a sibling that merely shares the prefix.
97
+ #
98
+ # Compares PATH_INFO, not Rack::Request#path (SCRIPT_NAME + PATH_INFO):
99
+ # the router dispatches on PATH_INFO, so under `map '/api' { run otto }`
100
+ # the MCP request for an endpoint at /_mcp arrives as SCRIPT_NAME=/api,
101
+ # PATH_INFO=/_mcp and the full path /api/_mcp never equals the endpoint.
102
+ #
103
+ # @param request [Rack::Attack::Request, #path_info, #env]
104
+ # @return [Boolean]
105
+ def self.mcp_request?(request)
106
+ candidates = registered_endpoints
107
+ env_endpoint = request.env['otto.mcp_http_endpoint']
108
+ candidates << env_endpoint if env_endpoint
109
+ candidates << DEFAULT_HTTP_ENDPOINT if candidates.empty?
110
+
111
+ candidates.any? { |endpoint| Otto::MCP.endpoint_path?(request.path_info, endpoint) }
112
+ end
113
+
114
+ # Name of the Rack::Attack throttle for +rule+ on +endpoint+.
115
+ #
116
+ # The endpoint is part of the name so that two MCP apps in one process
117
+ # get independent throttles (and independent counters: Rack::Attack keys
118
+ # the cache by throttle name). Without a configured endpoint the bare
119
+ # rule name is used and the block resolves the endpoint per request.
120
+ #
121
+ # @param rule [String] 'mcp_requests' or 'mcp_tool_calls'
122
+ # @param endpoint [String, nil]
123
+ # @return [String]
124
+ def self.throttle_name(rule, endpoint)
125
+ endpoint ? "#{rule}:#{endpoint}" : rule
29
126
  end
30
127
 
31
128
  def self.configure_mcp_rules(config)
32
- # MCP endpoint requests - 60 per minute by default
129
+ # Captured once, outside the blocks: they run per request, long after
130
+ # this configuration hash is gone.
131
+ configured_endpoint = config[:mcp_http_endpoint]
132
+
133
+ # MCP endpoint requests - 60 per minute by default. Only the exact
134
+ # endpoint path counts: the router dispatches the MCP route by literal
135
+ # match, so a prefix match here would let /admin traffic exhaust (and be
136
+ # refused by) the counter for an endpoint at /a. PATH_INFO, not #path:
137
+ # a mount prefix (SCRIPT_NAME) is invisible to the router and must be
138
+ # invisible here too, or a mounted Otto is never throttled.
33
139
  mcp_requests_limit = config[:mcp_requests_per_minute] || 60
34
140
 
35
- Rack::Attack.throttle('mcp_requests', limit: mcp_requests_limit, period: 60) do |request|
36
- endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
37
- request.ip if request.path.start_with?(endpoint)
141
+ Rack::Attack.throttle(throttle_name('mcp_requests', configured_endpoint),
142
+ limit: mcp_requests_limit, period: 60) do |request|
143
+ endpoint = mcp_endpoint_for(configured_endpoint, request.env)
144
+ request.ip if Otto::MCP.endpoint_path?(request.path_info, endpoint)
38
145
  end
39
146
 
40
147
  # Tool calls are more expensive - 20 per minute by default
41
148
  tool_calls_limit = config[:tool_calls_per_minute] || 20
42
149
 
43
- Rack::Attack.throttle('mcp_tool_calls', limit: tool_calls_limit, period: 60) do |request|
44
- endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
45
- if request.path.start_with?(endpoint) && request.post?
150
+ Rack::Attack.throttle(throttle_name('mcp_tool_calls', configured_endpoint),
151
+ limit: tool_calls_limit, period: 60) do |request|
152
+ endpoint = mcp_endpoint_for(configured_endpoint, request.env)
153
+ if Otto::MCP.endpoint_path?(request.path_info, endpoint) && request.post?
46
154
  begin
47
155
  body = request.body.read
48
156
  data = JSON.parse(body)
@@ -56,91 +164,59 @@ class Otto
56
164
  end
57
165
  end
58
166
 
59
- def self.configure_mcp_responses
60
- # Override throttled responder to provide JSON-RPC formatted responses for MCP requests
61
- Rack::Attack.throttled_responder = lambda do |request|
62
- match_data = request.env['rack.attack.match_data']
63
- now = match_data[:epoch_time]
64
-
65
- headers = {
66
- 'content-type' => 'application/json',
67
- 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
68
- }
69
-
70
- # Check if this is an MCP request
71
- endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
72
- if request.path.start_with?(endpoint)
73
- # JSON-RPC error response for MCP
74
- error_response = {
75
- jsonrpc: '2.0',
76
- id: nil,
77
- error: {
78
- code: -32_000,
79
- message: 'Rate limit exceeded',
80
- data: {
81
- retry_after: headers['retry-after'].to_i,
82
- limit: match_data[:limit],
83
- period: match_data[:period],
84
- },
85
- },
86
- }
87
- [429, headers, [JSON.generate(error_response)]]
88
- else
89
- # Use the general rate limiting response for non-MCP requests
90
- # Route's response_type takes precedence over Accept header
91
- route_def = request.env['otto.route_definition']
92
- wants_json = (route_def&.response_type == 'json') ||
93
- request.env['HTTP_ACCEPT'].to_s.include?('application/json')
94
-
95
- if wants_json
96
- error_response = {
97
- error: 'Rate limit exceeded',
98
- message: 'Too many requests',
99
- retry_after: headers['retry-after'].to_i,
100
- limit: match_data[:limit],
101
- period: match_data[:period],
102
- }
103
- [429, headers, [JSON.generate(error_response)]]
104
- else
105
- body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
106
- headers['content-type'] = 'text/plain'
107
- [429, headers, [body]]
108
- end
109
- end
110
- end
167
+ # JSON-RPC formatted 429 for MCP requests; the general Otto response
168
+ # (route response_type, then Accept header) for everything else.
169
+ def self.throttled_response(request)
170
+ return super unless mcp_request?(request)
171
+
172
+ match_data = request.env['rack.attack.match_data']
173
+ headers = throttle_headers(match_data)
174
+
175
+ error_response = {
176
+ jsonrpc: '2.0',
177
+ id: nil,
178
+ error: {
179
+ code: -32_000,
180
+ message: 'Rate limit exceeded',
181
+ data: {
182
+ retry_after: headers['retry-after'].to_i,
183
+ limit: match_data[:limit],
184
+ period: match_data[:period],
185
+ },
186
+ },
187
+ }
188
+ [429, headers, [JSON.generate(error_response)]]
111
189
  end
112
190
 
113
- def self.configure_mcp_logging
191
+ # Keep one MCP-aware subscriber while allowing it to recognize every
192
+ # endpoint registered in this process.
193
+ def self.configure_logging
114
194
  return unless defined?(ActiveSupport::Notifications)
115
195
 
116
- # Masked address only — see the note on the sibling subscriber in
117
- # Otto::Security::RateLimiting.configure_rack_attack! (issue #219).
118
- ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
119
- req = payload[:request]
120
- endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
121
- ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
122
-
123
- if req.path.start_with?(endpoint)
124
- Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
125
- else
126
- Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
127
- end
196
+ ActiveSupport::Notifications.unsubscribe(@log_subscriber) if @log_subscriber
197
+ @log_subscriber = ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
198
+ log_throttled_request(payload)
128
199
  end
129
200
  end
201
+
202
+ # Masked address only — see the note on the base implementation in
203
+ # Otto::Security::RateLimiting (issue #219).
204
+ def self.log_throttled_request(payload)
205
+ req = payload[:request]
206
+ return super unless mcp_request?(req)
207
+
208
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
209
+ Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
210
+ end
130
211
  end
131
212
 
132
213
  # Middleware for applying rate limits to MCP protocol endpoints
133
214
  class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
134
215
  def initialize(app, security_config = nil)
135
- @app = app
136
- @security_config = security_config
137
- @rate_limiter_available = defined?(Rack::Attack)
138
-
139
- if @rate_limiter_available
140
- configure_mcp_rate_limiting
141
- else
142
- Otto.logger.warn '[MCP] rack-attack not available - rate limiting disabled'
143
- end
216
+ @app = app
217
+ @security_config = security_config
218
+
219
+ configure_mcp_rate_limiting
144
220
  end
145
221
 
146
222
  private
@@ -149,11 +225,13 @@ class Otto
149
225
  # Get base configuration from security config
150
226
  base_config = @security_config&.rate_limiting_config || {}
151
227
 
152
- # Add MCP-specific defaults
153
- mcp_config = base_config.merge({
154
- mcp_requests_per_minute: 60,
228
+ # MCP defaults, overridden by anything the security config carries
229
+ # (Server#apply_rate_limits publishes the configured limits and the
230
+ # endpoint there).
231
+ mcp_config = {
232
+ mcp_requests_per_minute: 60,
155
233
  tool_calls_per_minute: 20,
156
- })
234
+ }.merge(base_config)
157
235
 
158
236
  RateLimiter.configure_rack_attack!(mcp_config)
159
237
  end
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../security/constant_resolver'
6
+ require_relative 'errors'
6
7
 
7
8
  class Otto
8
9
  module MCP
@@ -57,24 +58,23 @@ class Otto
57
58
  resource = @resources[uri]
58
59
  return nil unless resource
59
60
 
60
- begin
61
- content = resource[:handler].call
62
- {
63
- contents: [{
64
- uri: uri,
65
- mimeType: resource[:mimeType],
66
- text: content.to_s,
67
- }],
68
- }
69
- rescue StandardError => e
70
- Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.message}"
71
- nil
72
- end
61
+ # A handler that blows up propagates: it is an execution fault
62
+ # (-32603/500), not a missing resource (-32001/404). Returning nil
63
+ # here previously made the two indistinguishable to the protocol,
64
+ # which owns the logging (Protocol#handle_resources_read).
65
+ content = resource[:handler].call
66
+ {
67
+ contents: [{
68
+ uri: uri,
69
+ mimeType: resource[:mimeType],
70
+ text: content.to_s,
71
+ }],
72
+ }
73
73
  end
74
74
 
75
75
  def call_tool(name, arguments, env)
76
76
  tool = @tools[name]
77
- raise "Tool not found: #{name}" unless tool
77
+ raise ToolNotFoundError, "Tool not found: #{name}" unless tool
78
78
 
79
79
  handler = tool[:handler]
80
80
  if handler.respond_to?(:call)
@@ -4,11 +4,8 @@
4
4
 
5
5
  require 'json'
6
6
 
7
- begin
8
- require 'json_schemer'
9
- rescue LoadError
10
- # json_schemer is optional - graceful fallback
11
- end
7
+ require_relative '../optional_dependency'
8
+ require_relative 'endpoint'
12
9
 
13
10
  class Otto
14
11
  module MCP
@@ -16,14 +13,24 @@ class Otto
16
13
 
17
14
  # JSON Schema validator for MCP protocol requests
18
15
  class Validator
16
+ JSON_SCHEMER_REQUIREMENT = '~> 2.0'
17
+
18
+ def self.ensure_available!
19
+ Otto::OptionalDependency.require!(
20
+ 'json_schemer',
21
+ JSON_SCHEMER_REQUIREMENT,
22
+ require_path: 'json_schemer',
23
+ feature: 'MCP JSON Schema validation',
24
+ alternative: 'Or pass `enable_validation: false` when enabling MCP.'
25
+ )
26
+ end
27
+
19
28
  def initialize
20
- @schemas = {}
21
- @json_schemer_available = defined?(JSONSchemer)
29
+ self.class.ensure_available!
30
+ @schemas = {}
22
31
  end
23
32
 
24
33
  def validate_request(data)
25
- return true unless @json_schemer_available
26
-
27
34
  schema = mcp_request_schema
28
35
  validation_errors = schema.validate(data).to_a
29
36
 
@@ -36,7 +43,7 @@ class Otto
36
43
  end
37
44
 
38
45
  def validate_tool_arguments(tool_name, arguments, schema)
39
- return true unless @json_schemer_available && schema
46
+ return true unless schema
40
47
 
41
48
  schemer = JSONSchemer.schema(schema)
42
49
  validation_errors = schemer.validate(arguments).to_a
@@ -100,10 +107,11 @@ class Otto
100
107
 
101
108
  private
102
109
 
110
+ # Exact endpoint only, normalized as the router normalizes it (see
111
+ # Otto::MCP.endpoint_path?); sibling paths are never parsed or rejected.
103
112
  def mcp_endpoint?(env)
104
113
  endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
105
- path = env['PATH_INFO'].to_s
106
- path.start_with?(endpoint)
114
+ Otto::MCP.endpoint_path?(env['PATH_INFO'], endpoint)
107
115
  end
108
116
 
109
117
  def validation_error_response(id, message)