otto 2.9.0 → 2.10.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 (96) 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 +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -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 +146 -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 +181 -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 +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -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)
@@ -8,6 +8,7 @@ require_relative 'route_parser'
8
8
  require_relative 'auth/token'
9
9
  require_relative 'schema_validation'
10
10
  require_relative 'rate_limiting'
11
+ require_relative 'options'
11
12
  require_relative '../security/constant_resolver'
12
13
 
13
14
  class Otto
@@ -16,18 +17,67 @@ class Otto
16
17
  class Server
17
18
  attr_reader :protocol, :otto_instance
18
19
 
20
+ # Normalize raw options into the canonical MCP option hash.
21
+ #
22
+ # @param opts [Hash] raw options
23
+ # @param scope [Symbol] :explicit (strict, for #enable_mcp!) or
24
+ # :constructor (ignores non-MCP keys, for Otto.new)
25
+ # @see Otto::MCP::Options.normalize
26
+ def self.normalize_options(opts = {}, scope = :explicit)
27
+ Otto::MCP::Options.normalize(opts, scope)
28
+ end
29
+
19
30
  def initialize(otto_instance)
20
31
  @otto_instance = otto_instance
21
32
  @protocol = Protocol.new(otto_instance)
22
33
  @enabled = false
23
34
  end
24
35
 
36
+ # Warning emitted when the MCP HTTP endpoint is exposed with no token
37
+ # authentication. Unconditional (not gated on Otto.debug): an
38
+ # unauthenticated MCP endpoint lets any caller invoke every registered
39
+ # tool, so it must be visible in normal boot output.
40
+ UNAUTHENTICATED_WARNING = <<~MSG.gsub(/\s+/, ' ').strip.freeze
41
+ [MCP] HTTP endpoint %s is enabled without authentication:
42
+ any caller can list and invoke MCP tools and resources.
43
+ Pass auth_tokens: ['<token>'] to require a bearer token, or
44
+ allow_unauthenticated: true to acknowledge this intentionally.
45
+ MSG
46
+
47
+ # Enable the MCP server.
48
+ #
49
+ # Enabling is one-shot. Each call appends a route, an endpoint-setting
50
+ # proc and the MCP middleware to the Otto instance without removing the
51
+ # previous set, so a second call with a different endpoint would leave
52
+ # the first endpoint routed but guarded by nothing (the auth middleware
53
+ # only matches the newest endpoint). Rather than try to unwind that,
54
+ # a second call raises.
55
+ #
56
+ # @param options [Hash] canonical or aliased options; normalized via
57
+ # {.normalize_options}, so both the canonical keys (:http_endpoint,
58
+ # :auth_tokens, ...) and their mcp_-prefixed spellings
59
+ # (:mcp_endpoint, :mcp_auth_tokens, ...) are accepted.
60
+ # @raise [ArgumentError] if the server is already enabled
25
61
  def enable!(options = {})
26
- @enabled = true
27
- @http_endpoint = options.fetch(:http_endpoint, '/_mcp')
28
- @auth_tokens = options[:auth_tokens] || []
29
- @enable_validation = options.fetch(:enable_validation, true)
30
- @enable_rate_limiting = options.fetch(:enable_rate_limiting, true)
62
+ if @enabled
63
+ raise ArgumentError,
64
+ "MCP server is already enabled on #{@http_endpoint}; pass all MCP options " \
65
+ 'in a single Otto.new or enable_mcp! call'
66
+ end
67
+
68
+ options = self.class.normalize_options(options)
69
+
70
+ Validator.ensure_available! if options[:enable_validation]
71
+ Otto::Security::RateLimiting.ensure_available! if options[:enable_rate_limiting]
72
+
73
+ @enabled = true
74
+ @http_endpoint = options[:http_endpoint]
75
+ @auth_tokens = options[:auth_tokens]
76
+ @enable_validation = options[:enable_validation]
77
+ @enable_rate_limiting = options[:enable_rate_limiting]
78
+ @allow_unauthenticated = options[:allow_unauthenticated]
79
+
80
+ apply_rate_limits(options)
31
81
 
32
82
  # Configure middleware
33
83
  configure_middleware(options)
@@ -35,6 +85,8 @@ class Otto
35
85
  # Add MCP endpoint route to Otto
36
86
  add_mcp_endpoint_route
37
87
 
88
+ warn_if_unauthenticated!
89
+
38
90
  Otto.logger.info "[MCP] Server enabled with HTTP endpoint: #{@http_endpoint}" if Otto.debug
39
91
  end
40
92
 
@@ -53,42 +105,82 @@ class Otto
53
105
 
54
106
  private
55
107
 
108
+ # Publish the per-minute limits under the keys RateLimitMiddleware /
109
+ # RateLimiter.configure_rack_attack! already read, via Otto's sanctioned
110
+ # rate-limiting configuration entry point. Before this, the values passed
111
+ # to enable! were dead and the hardcoded 60/20 always won.
112
+ #
113
+ # The endpoint travels with them. Rack::Attack is mounted by the host app
114
+ # OUTSIDE Otto and runs before any of Otto's middleware, so the
115
+ # env['otto.mcp_http_endpoint'] set by add_mcp_endpoint_route is not yet
116
+ # present when the throttles are evaluated; without this, a custom
117
+ # endpoint was compared against the '/_mcp' fallback and never throttled.
118
+ def apply_rate_limits(options)
119
+ return unless @enable_rate_limiting
120
+
121
+ @otto_instance.configure_rate_limiting(
122
+ mcp_http_endpoint: @http_endpoint,
123
+ mcp_requests_per_minute: options[:requests_per_minute],
124
+ tool_calls_per_minute: options[:tools_per_minute]
125
+ )
126
+ end
127
+
128
+ def warn_if_unauthenticated!
129
+ return if @auth_tokens.any? || @allow_unauthenticated
130
+
131
+ Otto.logger.warn format(UNAUTHENTICATED_WARNING, @http_endpoint)
132
+ end
133
+
56
134
  def configure_middleware(_options)
57
- # Configure middleware in security-optimal order using explicit positioning:
58
- # 1. Rate limiting (reject excessive requests early) - position: :first
59
- # 2. Authentication (validate credentials before parsing) - default append
60
- # 3. Validation (expensive JSON schema validation last) - position: :last
135
+ # Target EXECUTION order (outermost first, i.e. what a request meets in
136
+ # turn), security-optimal:
137
+ # 1. Rate limiting — shed excessive load before spending any work
138
+ # 2. Authentication reject anonymous callers before parsing bodies
139
+ # 3. Validation — expensive JSON schema check only on requests
140
+ # that already proved themselves
141
+ #
142
+ # MiddlewareStack stores entries in the REVERSE of execution order
143
+ # (#wrap folds with reduce, so a later array entry is a further-out
144
+ # wrapper). Registration therefore runs innermost-first: validation is
145
+ # pinned :innermost, then auth is appended, then rate limiting. The
146
+ # previous code read the positions as execution order and produced the
147
+ # exact inverse — validation ran ahead of auth, so unauthenticated
148
+ # callers reached the schema validator.
61
149
 
62
150
  middleware = @otto_instance.instance_variable_get(:@middleware)
63
151
 
64
- # Configure rate limiting first (explicit position for clarity)
65
- if @enable_rate_limiting
152
+ # Innermost (last to execute): schema validation, closest to the app.
153
+ if @enable_validation
66
154
  middleware.add_with_position(
67
- Otto::MCP::RateLimitMiddleware,
68
- @otto_instance.security_config,
69
- position: :first
155
+ Otto::MCP::SchemaValidationMiddleware,
156
+ position: :innermost
70
157
  )
71
- Otto.logger.debug '[MCP] Rate limiting enabled (position: first)' if Otto.debug
158
+ Otto.logger.debug '[MCP] Schema validation enabled (executes last)' if Otto.debug
72
159
  end
73
160
 
74
- # Configure authentication second (default append order)
161
+ # Middle: authentication, outside validation and inside rate limiting.
75
162
  if @auth_tokens.any?
76
163
  @auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
77
164
  @otto_instance.security_config.mcp_auth = @auth
78
- @otto_instance.use Otto::MCP::Auth::TokenMiddleware
79
- Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
165
+ # Pass security_config explicitly: TokenMiddleware is not in
166
+ # MiddlewareStack#middleware_needs_config?, so #wrap would build it
167
+ # with a nil config and — now that the middleware fails closed —
168
+ # reject every request, valid token included.
169
+ @otto_instance.use Otto::MCP::Auth::TokenMiddleware, @otto_instance.security_config
170
+ Otto.logger.debug '[MCP] Token authentication enabled (executes after rate limiting)' if Otto.debug
80
171
  end
81
172
 
82
- # Configure validation last (explicit position for clarity)
83
- return unless @enable_validation
84
-
85
- middleware.add_with_position(
86
- Otto::MCP::SchemaValidationMiddleware,
87
- position: :last
88
- )
89
- Otto.logger.debug '[MCP] Schema validation enabled (position: last)' if Otto.debug
173
+ # Outermost of the three (first to execute): rate limiting.
174
+ if @enable_rate_limiting
175
+ middleware.add_with_position(
176
+ Otto::MCP::RateLimitMiddleware,
177
+ @otto_instance.security_config,
178
+ position: :last
179
+ )
180
+ Otto.logger.debug '[MCP] Rate limiting enabled (executes first)' if Otto.debug
181
+ end
90
182
 
91
- # Validate middleware order (should pass with explicit positioning)
183
+ # Validate execution order (should pass with the positioning above).
92
184
  warnings = middleware.validate_mcp_middleware_order
93
185
  warnings.each { |warning| Otto.logger.warn warning }
94
186
  end
@@ -102,13 +194,20 @@ class Otto
102
194
  @otto_instance.routes[:POST] ||= []
103
195
  @otto_instance.routes[:POST] << mcp_route
104
196
 
105
- @otto_instance.routes_literal[:POST] ||= {}
106
- @otto_instance.routes_literal[:POST][@http_endpoint] = mcp_route
107
-
108
- # Ensure env carries endpoint for middlewares
197
+ # Keyed by the normalized path, as the router keys every literal route
198
+ # and normalizes PATH_INFO before the lookup: a configured trailing
199
+ # slash ('/a/') or the root ('/') otherwise registers a key the lookup
200
+ # can never hit, while Otto::MCP.endpoint_path? still claims the path.
201
+ @otto_instance.routes_literal[:POST] ||= {}
202
+ @otto_instance.routes_literal[:POST][Otto::Utils.normalize_path(@http_endpoint)] = mcp_route
203
+
204
+ # Ensure env carries endpoint for middlewares. Close over a local copy:
205
+ # the proc must keep announcing the endpoint it was registered for even
206
+ # if the ivar is ever reassigned.
207
+ endpoint = @http_endpoint
109
208
  @otto_instance.use proc { |app|
110
209
  lambda { |env|
111
- env['otto.mcp_http_endpoint'] = @http_endpoint
210
+ env['otto.mcp_http_endpoint'] = endpoint
112
211
  app.call(env)
113
212
  }
114
213
  }