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
@@ -0,0 +1,172 @@
1
+ # lib/otto/core/static_mounts.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'rack/files'
6
+
7
+ class Otto
8
+ module Core
9
+ # Explicit static-file registration (issue #267).
10
+ #
11
+ # A static mount binds a URL prefix to one directory on disk:
12
+ #
13
+ # otto.mount_static('/assets', root: 'public/assets')
14
+ #
15
+ # Requests for GET /assets/<rest> are then resolved against
16
+ # public/assets/<rest> using the same containment policy as the implicit
17
+ # +public:+ directory (Otto::Core::FileSafety): the candidate is
18
+ # canonicalized with File.realpath and must land inside the mount's
19
+ # canonical root, be a regular readable file, and be owned by the process
20
+ # user or group. A mount never authorizes anything outside its own root,
21
+ # so several mounts can point into unrelated directories without exposing
22
+ # their parents or siblings.
23
+ #
24
+ # Registration is a boot-time operation. The root is canonicalized once,
25
+ # when the mount is registered, and every failure mode (missing,
26
+ # unreadable, not a directory, escaping symlink, malformed prefix,
27
+ # duplicate prefix) raises ArgumentError immediately so a misconfigured
28
+ # application does not start. Mounts participate in configuration
29
+ # freezing: mount_static raises FrozenError after freeze_configuration!,
30
+ # and the mount table is an immutable, sorted snapshot that dispatch reads
31
+ # without any per-request mutation.
32
+ #
33
+ # Dispatch precedence is fixed: literal routes, then static mounts (longest
34
+ # prefix first), then the implicit +public:+ directory, then dynamic
35
+ # routes. A mount claims files, not the prefix: when no mount root
36
+ # contains the requested file the request falls through to the next
37
+ # stage exactly as an unregistered path would. See Otto::Core::Router.
38
+ module StaticMounts
39
+ # One registered mount. +prefix+ is the normalized URL prefix ('' for a
40
+ # root mount), +root+ the canonical directory, +files+ the Rack::Files
41
+ # instance rooted there. Instances are frozen at construction.
42
+ StaticMount = Struct.new(:prefix, :root, :files) do
43
+ # Request-relative path under this mount, or nil when +path+ is not
44
+ # beneath the prefix. The prefix itself (the directory) never matches:
45
+ # a mount serves files, not directory listings.
46
+ #
47
+ # @param path [String] normalized dispatch path (leading '/')
48
+ # @return [String, nil]
49
+ def relative_path_for(path)
50
+ if prefix.empty?
51
+ path
52
+ elsif path.start_with?("#{prefix}/")
53
+ path[(prefix.length + 1)..]
54
+ end
55
+ end
56
+
57
+ # Prefix as an operator would write it ('/' for a root mount).
58
+ def display_prefix
59
+ prefix.empty? ? '/' : prefix
60
+ end
61
+ end
62
+
63
+ # Registered mounts, longest prefix first. Frozen snapshot; a new array
64
+ # replaces it on every registration so readers never observe a partial
65
+ # update.
66
+ #
67
+ # @return [Array<StaticMount>]
68
+ def static_mounts
69
+ @static_mounts
70
+ end
71
+
72
+ # Serve the files under +root+ at URLs beneath +prefix+.
73
+ #
74
+ # @param prefix [String] URL prefix starting with '/'. A trailing slash
75
+ # is ignored; '/' mounts the root at the top level.
76
+ # @param root [String] directory path; relative paths resolve against
77
+ # the process working directory and are canonicalized immediately.
78
+ # @return [StaticMount] the registered mount
79
+ # @raise [ArgumentError] on a malformed prefix, a duplicate prefix, or a
80
+ # root that is missing, unreadable, not a directory, not owned by the
81
+ # process user or group, or that cannot be canonicalized.
82
+ # @raise [FrozenError] after configuration freezing
83
+ def mount_static(prefix, root:)
84
+ ensure_not_frozen!
85
+
86
+ clean_prefix = normalize_mount_prefix(prefix)
87
+ if @static_mounts.any? { |mount| mount.prefix == clean_prefix }
88
+ raise ArgumentError,
89
+ "Static mount prefix #{display_mount_prefix(clean_prefix).inspect} is already registered"
90
+ end
91
+
92
+ canonical_root = canonicalize_mount_root(clean_prefix, root)
93
+ mount = StaticMount.new(clean_prefix, canonical_root, Rack::Files.new(canonical_root).freeze).freeze
94
+
95
+ # Longest prefix first so an overlay ('/assets/vendor') is consulted
96
+ # before the mount that contains it ('/assets'). Ties cannot happen:
97
+ # prefixes are unique. Rebuild rather than mutate so in-flight readers
98
+ # keep their snapshot.
99
+ @static_mounts = (@static_mounts + [mount]).sort_by { |m| -m.prefix.length }.freeze
100
+
101
+ Otto.structured_log(:debug, 'Static mount registered',
102
+ { prefix: mount.display_prefix, root: mount.root })
103
+ mount
104
+ end
105
+
106
+ private
107
+
108
+ # Resolve +path+ through the registered mounts, longest prefix first.
109
+ # Read-only: safe to call from concurrent request threads.
110
+ #
111
+ # @param path [String] normalized dispatch path (leading '/')
112
+ # @return [Array(StaticMount, Otto::Core::FileSafety::StaticFile), nil]
113
+ def resolve_mounted_file(path)
114
+ @static_mounts.each do |mount|
115
+ relative = mount.relative_path_for(path)
116
+ next if relative.nil?
117
+
118
+ static_file = resolve_file_under(mount.root, relative)
119
+ return [mount, static_file] if static_file
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Validate and normalize a mount prefix. Returns '' for the root mount
125
+ # and a leading-slash, no-trailing-slash prefix otherwise, matching the
126
+ # normalized request path the router compares against.
127
+ def normalize_mount_prefix(prefix)
128
+ raise ArgumentError, "Static mount prefix must be a String, got #{prefix.class}" unless prefix.is_a?(String)
129
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} contains a NUL byte" if prefix.include?("\0")
130
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} must start with '/'" unless prefix.start_with?('/')
131
+
132
+ clean = prefix.sub(%r{/+\z}, '')
133
+ return '' if clean.empty?
134
+
135
+ segments = clean.split('/', -1).drop(1)
136
+ if segments.any? { |segment| segment.empty? || segment == '.' || segment == '..' }
137
+ raise ArgumentError,
138
+ "Static mount prefix #{prefix.inspect} must not contain empty, '.', or '..' segments"
139
+ end
140
+
141
+ clean.freeze
142
+ end
143
+
144
+ # Canonicalize a mount root under Otto's static-file safety policy and
145
+ # fail loudly on anything that could not be served safely.
146
+ def canonicalize_mount_root(prefix, root)
147
+ label = "Static mount #{display_mount_prefix(prefix).inspect}"
148
+ raise ArgumentError, "#{label} root must be a String, got #{root.class}" unless root.is_a?(String)
149
+ raise ArgumentError, "#{label} root must not be empty" if root.strip.empty?
150
+ raise ArgumentError, "#{label} root #{root.inspect} contains a NUL byte" if root.include?("\0")
151
+
152
+ real = safe_realpath(File.expand_path(root))
153
+ if real.nil?
154
+ raise ArgumentError,
155
+ "#{label} root #{root.inspect} cannot be resolved " \
156
+ '(missing, unreadable component, or symlink loop)'
157
+ end
158
+ raise ArgumentError, "#{label} root #{root.inspect} is not a directory" unless File.directory?(real)
159
+ raise ArgumentError, "#{label} root #{root.inspect} is not readable" unless File.readable?(real)
160
+
161
+ owned = File.owned?(real) || File.grpowned?(real)
162
+ raise ArgumentError, "#{label} root #{root.inspect} is not owned by the process user or group" unless owned
163
+
164
+ real.freeze
165
+ end
166
+
167
+ def display_mount_prefix(prefix)
168
+ prefix.empty? ? '/' : prefix
169
+ end
170
+ end
171
+ end
172
+ end
data/lib/otto/core.rb CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'core/router'
6
6
  require_relative 'core/file_safety'
7
+ require_relative 'core/static_mounts'
7
8
  require_relative 'core/configuration'
8
9
  require_relative 'core/error_handler'
9
10
  require_relative 'core/uri_generator'
data/lib/otto/env_keys.rb CHANGED
@@ -100,13 +100,16 @@ class Otto
100
100
  # Type: Boolean when present; the key may be ABSENT.
101
101
  # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
102
102
  # REMOTE_ADDR is masked — but ONLY when proxy trust is configured
103
- # (Security::Config#proxy_trust_configured?: CIDR matchers or a depth).
103
+ # (Security::Config#proxy_trust_configured?: CIDR matchers, a depth, or
104
+ # the explicit trust-nobody assertion `trusted_proxies: :none`).
104
105
  # True when the peer matches a configured trusted_proxies CIDR (filter
105
106
  # mode), or unconditionally when count-based depth mode is active
106
107
  # (trusted_proxy_depth >= 1) — the modes are mutually exclusive, and
107
108
  # configuring a depth is the operator's assertion that the connecting
108
109
  # peer is their proxy tier (#226). False means trust IS configured and
109
- # this peer failed it — an authoritative deny. When no proxy trust is
110
+ # this peer failed it — an authoritative deny; it is also false for
111
+ # EVERY peer (loopback included) when the operator asserted
112
+ # `trusted_proxies: :none` (#259). When no proxy trust is
110
113
  # configured the key is NOT written, so consumers can distinguish
111
114
  # "denied" from "unconfigured" and apply legacy heuristics only in the
112
115
  # latter case.
@@ -129,6 +132,21 @@ class Otto
129
132
  # in a localhost trust decision.
130
133
  PEER_LOOPBACK = 'otto.peer_loopback'
131
134
 
135
+ # Whether the request arrived relayed by a proxy (any relay marker header
136
+ # present).
137
+ # Type: Boolean
138
+ # Set by: IPPrivacyMiddleware (every request), evaluated on the ORIGINAL
139
+ # headers BEFORE the untrusted-peer scrub may DELETE those carriers
140
+ # (Otto::Utils::RELAY_MARKER_HEADERS cover the forwarded-for family, RFC
141
+ # 7239 Forwarded, and the X-Forwarded-Host/Proto/Scheme/SSL/Port
142
+ # authority carriers — everything the scrub removes). A boolean, so it
143
+ # carries no identifying data.
144
+ # Used by: Otto::CaddyTLS::LocalhostGuard#relayed? — without this record a
145
+ # request relayed over loopback by a proxy emitting only RFC 7239
146
+ # `Forwarded` or only `X-Forwarded-Host` would look like a direct local
147
+ # call after the scrub, and a deny would become an allow.
148
+ PEER_RELAYED = 'otto.peer_relayed'
149
+
132
150
  # =========================================================================
133
151
  # LOCALIZATION (I18N)
134
152
  # =========================================================================
@@ -5,6 +5,8 @@
5
5
  require 'json'
6
6
  require 'rack/utils'
7
7
 
8
+ require_relative '../endpoint'
9
+
8
10
  class Otto
9
11
  module MCP
10
12
  module Auth
@@ -54,19 +56,23 @@ class Otto
54
56
  # Only apply to MCP endpoints
55
57
  return @app.call(env) unless mcp_endpoint?(env)
56
58
 
57
- # Get auth instance from security config
59
+ # Fail closed: this middleware is only mounted when MCP auth was
60
+ # requested, so a missing authenticator is a misconfiguration, not a
61
+ # licence to serve the endpoint unauthenticated (issue #258).
58
62
  auth = @security_config&.mcp_auth
59
- return unauthorized_response if auth && !auth.authenticate(env)
63
+ return unauthorized_response if auth.nil? || !auth.authenticate(env)
60
64
 
61
65
  @app.call(env)
62
66
  end
63
67
 
64
68
  private
65
69
 
70
+ # Exact endpoint only, normalized as the router normalizes it. A prefix
71
+ # match would answer 401 for sibling paths (/admin beside /a) that the
72
+ # MCP handler never receives.
66
73
  def mcp_endpoint?(env)
67
74
  endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
68
- path = env['PATH_INFO'].to_s
69
- path.start_with?(endpoint)
75
+ Otto::MCP.endpoint_path?(env['PATH_INFO'], endpoint)
70
76
  end
71
77
 
72
78
  def unauthorized_response
data/lib/otto/mcp/core.rb CHANGED
@@ -9,17 +9,35 @@ class Otto
9
9
  module Core
10
10
  # Enable MCP (Model Context Protocol) server support
11
11
  #
12
+ # Options are normalized by Otto::MCP::Server.normalize_options under the
13
+ # :explicit scope. That scope accepts the canonical keys below and their
14
+ # mcp_-prefixed spellings (:mcp_endpoint, :mcp_auth_tokens, ...), as
15
+ # String or Symbol keys, so one hash can feed both Otto.new and this
16
+ # method.
17
+ #
18
+ # It is STRICT: any other key raises ArgumentError rather than being
19
+ # ignored, so a typo such as `enable_mcp!(auth_token: 'x')` fails at boot
20
+ # instead of quietly leaving the endpoint unauthenticated. That includes
21
+ # the constructor-only gating keys (:mcp_enabled, :mcp_http, :mcp_stdio):
22
+ # this method always enables the HTTP endpoint, so it cannot honour them.
23
+ #
12
24
  # @param options [Hash] MCP configuration options
13
- # @option options [Boolean] :http Enable HTTP endpoint (default: true)
14
- # @option options [Boolean] :stdio Enable STDIO communication (default: false)
15
- # @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
25
+ # @option options [String] :http_endpoint HTTP endpoint path (default: '/_mcp')
26
+ # @option options [Array<String>, String] :auth_tokens Bearer tokens required
27
+ # on the MCP endpoint (default: none, which logs a warning)
28
+ # @option options [Boolean] :allow_unauthenticated Acknowledge an
29
+ # intentionally unauthenticated endpoint, silencing that warning (default: false)
30
+ # @option options [Boolean] :enable_validation Enable JSON schema validation (default: true)
31
+ # @option options [Boolean] :enable_rate_limiting Enable rate limiting (default: true)
32
+ # @option options [Integer] :requests_per_minute MCP endpoint limit (default: 60)
33
+ # @option options [Integer] :tools_per_minute tools/call limit (default: 20)
16
34
  # @example
17
- # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
35
+ # otto.enable_mcp!(http_endpoint: '/api/mcp', auth_tokens: ['secret'])
18
36
  def enable_mcp!(options = {})
19
37
  ensure_not_frozen!
20
38
  @mcp_server ||= Otto::MCP::Server.new(self)
21
39
 
22
- @mcp_server.enable!(options)
40
+ @mcp_server.enable!(Otto::MCP::Server.normalize_options(options))
23
41
  Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
24
42
  end
25
43
 
@@ -0,0 +1,41 @@
1
+ # lib/otto/mcp/endpoint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../utils'
6
+
7
+ class Otto
8
+ # Model Context Protocol support.
9
+ module MCP
10
+ # Whether +path+ is the MCP endpoint +endpoint+: the one request the router
11
+ # dispatches to the MCP handler, and nothing else.
12
+ #
13
+ # The MCP route is a literal route. The router normalizes PATH_INFO with
14
+ # Otto::Utils.normalize_path and looks it up by exact equality, so it never
15
+ # dispatches a sibling path to the MCP handler. The throttles, the throttled
16
+ # responder, the log subscriber, the token middleware and the schema
17
+ # validator all used to classify by prefix (start_with?) instead: with MCP
18
+ # mounted at /a, every /admin request was counted by 'mcp_requests:/a' and
19
+ # could be answered with an MCP JSON-RPC 429, or a 401, for a path the MCP
20
+ # handler can never receive; with MCP at / that was every route in the app.
21
+ #
22
+ # Both sides go through the router's normalize_path, as LocalhostGuard does
23
+ # for the Caddy endpoint, so a trailing slash or percent-encoding on either
24
+ # side cannot make these guards and the router disagree about a request.
25
+ #
26
+ # Callers must pass PATH_INFO (Rack::Request#path_info), never
27
+ # Rack::Request#path, which prepends SCRIPT_NAME. The router matches on
28
+ # PATH_INFO alone, so when the host app mounts Otto under a prefix
29
+ # (`map '/api' { run otto }`) the endpoint /_mcp is dispatched for
30
+ # PATH_INFO=/_mcp while #path reads /api/_mcp and never matches.
31
+ #
32
+ # @param path [String, nil] raw PATH_INFO of the request
33
+ # @param endpoint [String, nil] configured MCP endpoint
34
+ # @return [Boolean]
35
+ def self.endpoint_path?(path, endpoint)
36
+ return false if endpoint.nil?
37
+
38
+ Otto::Utils.normalize_path(path) == Otto::Utils.normalize_path(endpoint)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ # lib/otto/mcp/errors.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../errors'
6
+
7
+ class Otto
8
+ module MCP
9
+ # Raised by {Otto::MCP::Registry#call_tool} when the requested tool name is
10
+ # not registered. Distinct from a tool that exists but fails during
11
+ # execution: the former is a named-entity lookup failure (JSON-RPC -32002,
12
+ # HTTP 404), the latter an execution fault (-32603, HTTP 500).
13
+ class ToolNotFoundError < Otto::NotFoundError; end
14
+ end
15
+ end
@@ -0,0 +1,292 @@
1
+ # lib/otto/mcp/options.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module MCP
7
+ # Normalization of MCP configuration options.
8
+ #
9
+ # Otto.new and Otto#enable_mcp! historically accepted different spellings
10
+ # of the same settings and the constructor dropped all but the endpoint
11
+ # (issue #258). Every path now funnels through {.normalize}.
12
+ #
13
+ # One vocabulary: the canonical keys, their +mcp_+-prefixed variants (the
14
+ # constructor needs a namespace inside an options hash that also
15
+ # configures the rest of Otto), and +tool_calls_per_minute+, which is the
16
+ # name the rate-limiting middleware itself uses. Bare generic names such
17
+ # as +endpoint+, +validation+ and +rate_limiting+ are NOT accepted:
18
+ # +rate_limiting:+ is Otto's own general rate-limiting option (a Hash),
19
+ # and the others were documented before #258 but never read.
20
+ #
21
+ # Two strictness rules, selected with the +scope+ argument:
22
+ #
23
+ # +:constructor+ (used by Otto.new / #configure_mcp)
24
+ # The constructor forwards its ENTIRE options hash here, most of which
25
+ # configures things other than MCP, so unrecognized keys are ignored.
26
+ # Unknown +mcp_+-prefixed keys still fail loud, since such a key can
27
+ # only have been meant for MCP.
28
+ #
29
+ # +:explicit+ (used by Otto#enable_mcp!)
30
+ # The caller is configuring MCP and nothing else, so any unrecognized
31
+ # key raises. That turns +enable_mcp!(auth_token: 'x')+ — a
32
+ # singular-vs-plural typo that silently left the endpoint open — into a
33
+ # boot failure.
34
+ #
35
+ # The +mcp_+-prefixed gating keys (+mcp_enabled+, +mcp_http+, +mcp_stdio+)
36
+ # decide *whether* MCP is enabled and are read by the constructor itself
37
+ # (Otto::Core::Configuration#configure_mcp) through {.gating_options}. The
38
+ # +:constructor+ scope tolerates them; the +:explicit+ scope rejects them,
39
+ # because +enable_mcp!(mcp_http: false)+ would otherwise be accepted and
40
+ # still mount the endpoint. Their values must be exactly +true+ or
41
+ # +false+: the constructor disables the endpoint on +mcp_http == false+,
42
+ # so a String +"false"+ (from +ENV.fetch+ or YAML) or +nil+ (from an
43
+ # unset +ENV[...]+) would otherwise mount it.
44
+ #
45
+ # Keys may be Strings or Symbols; they are symbolized before anything is
46
+ # read, so +"auth_tokens" => [...]+ configures authentication exactly like
47
+ # +auth_tokens:+. A String key and its Symbol twin are two spellings of one
48
+ # option and conflict when their values differ.
49
+ #
50
+ # Both scopes accept the canonical output of {.normalize}, so normalization
51
+ # is idempotent under either.
52
+ module Options
53
+ # Canonical MCP option keys and every accepted alias.
54
+ # @api private
55
+ OPTION_ALIASES = {
56
+ http_endpoint: %i[http_endpoint mcp_endpoint],
57
+ auth_tokens: %i[auth_tokens mcp_auth_tokens],
58
+ enable_validation: %i[enable_validation mcp_validation],
59
+ enable_rate_limiting: %i[enable_rate_limiting mcp_rate_limiting],
60
+ requests_per_minute: %i[requests_per_minute mcp_requests_per_minute],
61
+ tools_per_minute: %i[tools_per_minute tool_calls_per_minute mcp_tool_calls_per_minute],
62
+ allow_unauthenticated: %i[allow_unauthenticated mcp_allow_unauthenticated],
63
+ }.freeze
64
+
65
+ # @api private
66
+ SCOPES = %i[constructor explicit].freeze
67
+
68
+ # Canonical defaults applied when no alias supplies a value.
69
+ # @api private
70
+ OPTION_DEFAULTS = {
71
+ http_endpoint: '/_mcp',
72
+ auth_tokens: [].freeze,
73
+ enable_validation: true,
74
+ enable_rate_limiting: true,
75
+ requests_per_minute: 60,
76
+ tools_per_minute: 20,
77
+ allow_unauthenticated: false,
78
+ }.freeze
79
+
80
+ # +mcp_+-prefixed constructor keys that gate *whether* MCP is enabled
81
+ # rather than configure the server. Otto.new reads them itself, so the
82
+ # :constructor scope tolerates them (and never emits them). #enable_mcp!
83
+ # cannot honour them, so the :explicit scope rejects them.
84
+ # @api private
85
+ GATING_KEYS = %i[mcp_enabled mcp_http mcp_stdio].freeze
86
+
87
+ # Every key {.normalize} recognizes, per scope.
88
+ # @api private
89
+ RECOGNIZED_KEYS = {
90
+ constructor: (OPTION_ALIASES.values.flatten + GATING_KEYS).freeze,
91
+ explicit: OPTION_ALIASES.values.flatten.freeze,
92
+ }.freeze
93
+
94
+ # Normalize a constructor- or #enable_mcp!-style option hash into the
95
+ # single canonical shape consumed by {Otto::MCP::Server#enable!}.
96
+ #
97
+ # +scope+ is positional, not a keyword, so a brace-less hash at the call
98
+ # site (+normalize(auth_tokens: ['t'])+) binds to +opts+ as intended.
99
+ #
100
+ # @param opts [Hash] raw options; String and Symbol keys are equivalent
101
+ # @param scope [Symbol] :explicit (strict; the default) or :constructor
102
+ # (permissive about non-MCP keys)
103
+ # @return [Hash] canonical hash with keys :http_endpoint, :auth_tokens,
104
+ # :enable_validation, :enable_rate_limiting, :requests_per_minute,
105
+ # :tools_per_minute, :allow_unauthenticated
106
+ # @raise [ArgumentError] on an unrecognized key, conflicting aliases or
107
+ # String/Symbol spellings, values of the wrong type, or auth tokens
108
+ # supplied but empty/blank
109
+ def self.normalize(opts = {}, scope = :explicit)
110
+ raise ArgumentError, "Unknown MCP option scope #{scope.inspect}; expected one of #{SCOPES.inspect}" unless SCOPES.include?(scope)
111
+
112
+ opts = symbolize_keys(opts.to_h)
113
+ reject_unrecognized_keys!(opts, scope)
114
+
115
+ canonical = OPTION_DEFAULTS.dup
116
+ OPTION_ALIASES.each do |key, key_aliases|
117
+ supplied = key_aliases.select { |a| opts.key?(a) }
118
+ next if supplied.empty?
119
+
120
+ values = supplied.map { |a| opts[a] }
121
+ raise_conflict!(key, supplied.map { |a| [a, opts[a]] }) if values.uniq.size > 1
122
+
123
+ canonical[key] = coerce_option(key, values.first)
124
+ end
125
+
126
+ canonical
127
+ end
128
+
129
+ # Read the constructor-only gating keys (+mcp_enabled+, +mcp_http+,
130
+ # +mcp_stdio+) with the same String/Symbol tolerance as {.normalize}.
131
+ #
132
+ # Otto.new decides *whether* to enable MCP from these before it
133
+ # normalizes the rest, and it used to read them as raw Symbol keys, so
134
+ # +Otto.new(nil, "mcp_enabled" => true)+ silently did nothing while the
135
+ # String-keyed +"auth_tokens"+ beside it was documented as accepted.
136
+ #
137
+ # Every gating value present must be exactly +true+ or +false+.
138
+ # #configure_mcp disables the HTTP endpoint only on +mcp_http == false+,
139
+ # so +mcp_http: ENV.fetch('MCP_HTTP', 'false')+ (a String, truthy) or
140
+ # +mcp_http: ENV['MCP_HTTP']+ with the variable unset (+nil+, not
141
+ # +false+) would each mount the endpoint the caller meant to disable.
142
+ # An explicit +nil+ is rejected rather than treated as absent for that
143
+ # reason, matching the other boolean options and +auth_tokens: nil+.
144
+ #
145
+ # @param opts [Hash, nil] raw constructor options
146
+ # @return [Hash{Symbol=>Boolean}] the gating keys present in +opts+
147
+ # @raise [ArgumentError] when a String key and its Symbol twin disagree,
148
+ # or when a gating value is anything but +true+ or +false+ (including
149
+ # the Strings +"true"+ / +"false"+, Integers and +nil+)
150
+ def self.gating_options(opts)
151
+ gating = symbolize_keys(opts.to_h).slice(*GATING_KEYS)
152
+ gating.each { |key, value| coerce_boolean!(key, value) }
153
+ gating
154
+ end
155
+
156
+ # Symbolize option keys once, so String-keyed options configure the
157
+ # server instead of passing the unrecognized-key guard (which already
158
+ # symbolized) and then being ignored by normalization — which is how
159
+ # +enable_mcp!("auth_tokens" => [...])+ served the endpoint without
160
+ # authentication.
161
+ #
162
+ # A String key and its Symbol twin are two spellings of one option: equal
163
+ # values collapse, differing values conflict. Keys that cannot be
164
+ # symbolized are kept as-is for the unrecognized-key guard.
165
+ # @api private
166
+ def self.symbolize_keys(opts)
167
+ seen = {} # symbolized key => [original spelling, value]
168
+ opts.each do |key, value|
169
+ sym = key.respond_to?(:to_sym) ? key.to_sym : key
170
+ raise_conflict!(sym, [seen[sym], [key, value]]) if seen.key?(sym) && seen[sym].last != value
171
+
172
+ seen[sym] = [key, value]
173
+ end
174
+
175
+ seen.transform_values(&:last)
176
+ end
177
+ private_class_method :symbolize_keys
178
+
179
+ # @api private
180
+ def self.raise_conflict!(key, spellings)
181
+ raise ArgumentError,
182
+ "Conflicting MCP options for #{key}: " \
183
+ "#{spellings.map { |spelling, value| "#{spelling.inspect}=#{value.inspect}" }.join(', ')}"
184
+ end
185
+ private_class_method :raise_conflict!
186
+
187
+ # Reject keys the given scope cannot accept.
188
+ #
189
+ # :explicit rejects anything unrecognized, including the constructor-only
190
+ # gating keys, which it explains by name. :constructor rejects only
191
+ # unrecognized +mcp_+-prefixed keys, because it is handed Otto's whole
192
+ # options hash and most keys legitimately belong to other subsystems.
193
+ # @api private
194
+ def self.reject_unrecognized_keys!(opts, scope)
195
+ recognized = RECOGNIZED_KEYS.fetch(scope)
196
+ unknown = opts.keys.reject { |k| recognized.include?(k) }
197
+ unknown.select! { |k| k.to_s.start_with?('mcp_') } if scope == :constructor
198
+ return if unknown.empty?
199
+
200
+ message = "Unknown MCP option(s): #{unknown.map(&:inspect).join(', ')}. "
201
+ gating = unknown & GATING_KEYS
202
+ unless gating.empty?
203
+ message += "#{gating.map(&:inspect).join(', ')} gate whether MCP is enabled and are " \
204
+ 'constructor-only: pass them to Otto.new. #enable_mcp! always enables the ' \
205
+ 'HTTP endpoint, so it cannot honour them. '
206
+ end
207
+
208
+ raise ArgumentError,
209
+ message + "Recognized MCP options (#{scope}): #{recognized.map(&:inspect).join(', ')}"
210
+ end
211
+ private_class_method :reject_unrecognized_keys!
212
+
213
+ # @api private
214
+ def self.coerce_option(key, value)
215
+ case key
216
+ when :auth_tokens
217
+ coerce_auth_tokens!(value)
218
+ when :enable_validation, :enable_rate_limiting, :allow_unauthenticated
219
+ coerce_boolean!(key, value)
220
+ when :requests_per_minute, :tools_per_minute
221
+ unless value.is_a?(Integer) && value.positive?
222
+ raise ArgumentError,
223
+ "MCP #{key} must be a positive Integer, got #{value.inspect}"
224
+ end
225
+
226
+ value
227
+ when :http_endpoint
228
+ unless value.is_a?(String) && value.start_with?('/')
229
+ raise ArgumentError,
230
+ "MCP http_endpoint must be a String path starting with '/', got #{value.inspect}"
231
+ end
232
+
233
+ value
234
+ else
235
+ value
236
+ end
237
+ end
238
+ private_class_method :coerce_option
239
+
240
+ # Coerce and validate the configured bearer tokens.
241
+ #
242
+ # A literal empty Array is the one accepted "no tokens" spelling: it is
243
+ # unambiguous, and it is what {.normalize} itself emits when the key is
244
+ # omitted, which keeps normalization idempotent. Every other empty shape
245
+ # (+nil+, +''+, +[nil]+, +['']+) raises, because those are what
246
+ # +auth_tokens: ENV['MCP_TOKEN']+ produces when the variable is unset —
247
+ # silently serving the endpoint to anyone.
248
+ # @api private
249
+ def self.coerce_auth_tokens!(value)
250
+ return [] if value.is_a?(Array) && value.empty?
251
+
252
+ tokens = value.is_a?(String) ? [value] : Array(value)
253
+ invalid = tokens.grep_v(String)
254
+ unless invalid.empty?
255
+ raise ArgumentError,
256
+ "MCP auth_tokens must be Strings, got #{invalid.map(&:class).uniq.join(', ')}"
257
+ end
258
+
259
+ raise_empty_auth_tokens!(value) if tokens.empty?
260
+
261
+ blank = tokens.select { |token| token.strip.empty? }
262
+ unless blank.empty?
263
+ raise ArgumentError,
264
+ "MCP auth_tokens must not be blank, got #{blank.map(&:inspect).join(', ')}. " \
265
+ 'A blank token cannot be presented by a client and would not protect the endpoint.'
266
+ end
267
+
268
+ tokens
269
+ end
270
+ private_class_method :coerce_auth_tokens!
271
+
272
+ # @api private
273
+ def self.raise_empty_auth_tokens!(value)
274
+ raise ArgumentError,
275
+ "MCP auth_tokens was supplied as #{value.inspect} but resolves to no tokens. " \
276
+ "This is what auth_tokens: ENV['MCP_TOKEN'] does when the variable is unset, " \
277
+ 'and it would expose the MCP endpoint to any caller. Supply at least one token, ' \
278
+ 'or omit auth_tokens entirely and pass allow_unauthenticated: true to serve the ' \
279
+ 'endpoint without authentication on purpose.'
280
+ end
281
+ private_class_method :raise_empty_auth_tokens!
282
+
283
+ # @api private
284
+ def self.coerce_boolean!(key, value)
285
+ return value if [true, false].include?(value)
286
+
287
+ raise ArgumentError, "MCP #{key} must be true or false, got #{value.inspect}"
288
+ end
289
+ private_class_method :coerce_boolean!
290
+ end
291
+ end
292
+ end