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
@@ -0,0 +1,168 @@
1
+ # Geo-country resolution
2
+
3
+ Otto exposes a country-level ISO 3166-1 alpha-2 code as `req.geo_country` and
4
+ `env['otto.privacy.geo_country']`. It does not expose city or region data. For a
5
+ request that enters the privacy fingerprint, enabled but unresolved geo returns
6
+ `**`. The value is `nil` when IP privacy or geo resolution is disabled, or when
7
+ a private/loopback request is exempt from the fingerprint.
8
+
9
+ For the surrounding privacy profiles and environment-key contracts, see
10
+ [Privacy-preserving request data](privacy.md). ASN and anonymizer signals are
11
+ covered separately in [ASN and anonymizer enrichment](enrichment.md).
12
+
13
+ ## Resolution order
14
+
15
+ For requests handled by Otto, the first valid result wins:
16
+
17
+ 1. the application-configured `geo_header`;
18
+ 2. built-in provider headers for Cloudflare, AWS CloudFront, Fastly, Akamai,
19
+ Azure Front Door, Vercel, and several semi-standard country headers;
20
+ 3. the process-wide `Otto::Privacy::GeoResolver.custom_resolver` callable;
21
+ 4. a local MMDB reader configured with `geo_db_path` or `geo_db_reader`;
22
+ 5. `**` when no source resolves a country.
23
+
24
+ Header sources are consulted only when the request arrived through a configured
25
+ CIDR trusted proxy. See [Trust geo headers](#trust-geo-headers).
26
+
27
+ The middleware gives custom resolvers a copy of the Rack environment with the
28
+ client address masked or removed. The local database lookup also masks the IP
29
+ again before calling its reader. A custom resolver called directly outside the
30
+ middleware does not receive that additional environment protection.
31
+
32
+ ## Configure a trusted header or local database
33
+
34
+ All configuration is boot-time only and must be complete before the first
35
+ request:
36
+
37
+ ```ruby
38
+ otto.configure_ip_privacy(
39
+ geo: true, # default
40
+ geo_header: 'X-Client-Country', # optional trusted header
41
+ geo_db_path: 'data/user-country.mmdb' # optional local fallback
42
+ )
43
+ ```
44
+
45
+ - `geo: false` disables all country resolution and releases the loaded reader.
46
+ - `geo_header:` accepts an HTTP header name such as `X-Client-Country` or its
47
+ Rack key, `HTTP_X_CLIENT_COUNTRY`. A blank string clears the setting.
48
+ - `geo_db_path:` opens the database in memory during configuration. A blank
49
+ string clears the path. An unreadable or invalid file raises `ArgumentError`;
50
+ a missing or incompatible `maxmind-db` gem raises
51
+ `Otto::OptionalDependencyError`.
52
+ - `geo_db_reader:` accepts an object responding to `#get(ip)`. It wins over a
53
+ path supplied in the same call and lets applications use another MMDB reader
54
+ or a test double.
55
+
56
+ Omitting a keyword leaves its current value unchanged. Supplying a new path
57
+ without a reader clears an earlier reader override.
58
+
59
+ `Otto::Privacy::GeoResolver.custom_resolver` is different from the options
60
+ above: it is shared by every Otto instance in the process. Set it once during
61
+ single-threaded initialization. For per-application behavior, prefer
62
+ `geo_db_reader:`.
63
+
64
+ ## Install the MMDB reader
65
+
66
+ The [`maxmind-db`](https://rubygems.org/gems/maxmind-db) gem is optional. Otto
67
+ accepts versions 1.2 or newer within the 1.x series. Add it to the application:
68
+
69
+ ```sh
70
+ bundle add maxmind-db --version '~> 1.2'
71
+ ```
72
+
73
+ Applications that manage `Gemfile` entries manually can instead add
74
+ `gem 'maxmind-db', '~> 1.2'` and run `bundle install`.
75
+
76
+ ## Download a current country database
77
+
78
+ Otto does not bundle country data. The current
79
+ [`user-country`](https://github.com/sapics/ip-location-db/tree/main/user-country/)
80
+ dataset from `sapics/ip-location-db` is an IPv4-and-IPv6 country MMDB updated
81
+ daily. Upstream recommends it for general end-user country lookup and publishes
82
+ it under PDDL 1.0. Review the upstream methodology and license for the version
83
+ you deploy.
84
+
85
+ The older `geo-whois-asn-country.mmdb` asset is no longer published. Use the
86
+ current release asset and checksum URLs:
87
+
88
+ ```sh
89
+ mkdir -p data
90
+ curl -fL --retry 3 \
91
+ -o data/user-country.mmdb \
92
+ https://github.com/sapics/ip-location-db/releases/download/latest/user-country.mmdb
93
+ curl -fL --retry 3 \
94
+ -o data/user-country.mmdb.sha256 \
95
+ https://github.com/sapics/ip-location-db/releases/download/checksum/user-country.mmdb.sha256
96
+ ```
97
+
98
+ Verify the download from the directory containing both files:
99
+
100
+ ```sh
101
+ (cd data && shasum -a 256 -c user-country.mmdb.sha256) # macOS/BSD
102
+ # or
103
+ (cd data && sha256sum --check user-country.mmdb.sha256) # GNU/Linux
104
+ ```
105
+
106
+ Inspect a lookup before configuring Otto:
107
+
108
+ ```sh
109
+ bundle exec ruby -rmaxmind/db -e \
110
+ "p MaxMind::DB.new('data/user-country.mmdb', mode: MaxMind::DB::MODE_MEMORY).get('8.8.8.8')"
111
+ ```
112
+
113
+ The result should be a hash containing a two-letter `country_code`. Otto also
114
+ accepts GeoLite2-style nested `country.iso_code` records and a bare string in
115
+ the `country` field. Other MMDB datasets can therefore work, but their licenses,
116
+ update requirements, schemas, and accuracy remain the operator's responsibility.
117
+
118
+ Refresh the data on an operational schedule and verify the checksum before
119
+ replacing the active file. Because Otto opens the file at boot, restart the
120
+ application after replacement.
121
+
122
+ ## Understand masking and accuracy
123
+
124
+ Database and custom-resolver lookups in the middleware receive a masked address.
125
+ For IPv4, the default `octet_precision: 1` keeps a `/24`; `octet_precision: 2`
126
+ keeps a `/16`. For IPv6, those settings keep `/48` and `/32`, respectively.
127
+ Masking can therefore reduce lookup accuracy when a database has more-specific
128
+ country ranges, especially for IPv6 and the coarser precision setting. Such a
129
+ miss falls through to `**`.
130
+
131
+ Header results do not depend on the masked address. Keep the default precision
132
+ and test representative IPv4 and IPv6 ranges if the MMDB fallback is important
133
+ to the application.
134
+
135
+ ## Trust geo headers
136
+
137
+ Country headers are client-spoofable unless Otto can verify the connecting
138
+ proxy. Configure the CDN or reverse proxy addresses as trusted CIDRs:
139
+
140
+ ```ruby
141
+ otto = Otto.new(
142
+ 'routes',
143
+ trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
144
+ )
145
+ ```
146
+
147
+ Otto ignores configured and built-in geo headers when:
148
+
149
+ - proxy trust is not configured;
150
+ - `trusted_proxies: :none` is configured;
151
+ - the connecting peer does not match a configured trusted proxy; or
152
+ - count-based `trusted_proxy_depth` mode is used.
153
+
154
+ A configured `geo_header` and `trusted_proxy_depth` are rejected together at
155
+ configuration time. In depth-mode deployments, use `geo_db_path` or
156
+ `geo_db_reader` instead. For the broader forwarded-header boundary, see
157
+ [Forwarded host authority](forwarded-authority.md).
158
+
159
+ ## Behavior summary
160
+
161
+ | Scenario | Result |
162
+ | --- | --- |
163
+ | Trusted configured header contains a valid code | Wins over provider headers |
164
+ | Geo headers are not trusted | Headers are skipped; resolver/database fallback continues |
165
+ | Local database lookup | Receives only the masked IP |
166
+ | `geo: false`, IP privacy disabled, or privacy-exempt request | `nil` |
167
+ | Enabled, but nothing resolves | `**` |
168
+ | Invalid path or database | Configuration fails at boot |
@@ -0,0 +1,39 @@
1
+ # Configure IP privacy
2
+
3
+ This page is a short entry point for existing links and searches. The canonical
4
+ [privacy guide](privacy.md) documents Otto's current profiles, request helpers,
5
+ environment keys, proxy trust boundary, and middleware placement.
6
+
7
+ ## Choose a profile
8
+
9
+ Configure privacy before the first request:
10
+
11
+ ```ruby
12
+ otto = Otto.new('routes')
13
+ otto.configure_ip_privacy(profile: :masked) # default: mask public IPs
14
+ otto.configure_ip_privacy(profile: :anonymous) # also mask private and loopback IPs
15
+ ```
16
+
17
+ Use `profile: :audit` only when the application must retain resolved client IPs
18
+ and the deployment has its own access, logging, and retention controls. See
19
+ [Profiles](privacy.md#profiles) for the exact behavior of each profile.
20
+
21
+ These profiles are technical data-minimization controls. They do not determine
22
+ whether an application complies with GDPR, CCPA, or another legal regime.
23
+ Compliance also depends on the application's purposes, notices, retention,
24
+ access controls, vendors, and jurisdiction.
25
+
26
+ ## Complete common tasks
27
+
28
+ - Read the current request helpers and Rack environment keys in
29
+ [Privacy-safe request values](privacy.md#privacy-safe-request-values).
30
+ - Put privacy ahead of logging and monitoring middleware outside Otto's own
31
+ stack by following [Middleware placement](privacy.md#middleware-placement).
32
+ - Configure forwarded client-IP trust in
33
+ [Trusted proxy and matching boundaries](privacy.md#trusted-proxy-and-matching-boundaries).
34
+ - Configure country lookup in [Geo-country resolution](geo-country.md).
35
+ - Configure opt-in ASN or anonymizer lookup in
36
+ [ASN and anonymizer enrichment](enrichment.md).
37
+
38
+ Do not copy configuration or environment-key examples from older versions of
39
+ this page; use the linked canonical sections instead.
@@ -0,0 +1,56 @@
1
+ # Implementation note: `IPAddr#to_s` string encoding
2
+
3
+ This note documents why Otto normalizes IP strings produced by its masking
4
+ helpers. It is an implementation detail, not a claim about every Ruby 3 release
5
+ or every version of the `ipaddr` default gem.
6
+
7
+ ## Verified behavior
8
+
9
+ Otto's blocking compatibility targets are Ruby 3.2, 3.3, and 3.4. The following
10
+ behavior was reproduced locally with representative installed patch releases:
11
+
12
+ | Ruby | `ipaddr` | IPv4 `IPAddr#to_s` | IPv6 `IPAddr#to_s` |
13
+ | --- | --- | --- | --- |
14
+ | 3.2.4 | 1.2.5 | `US-ASCII` | `UTF-8` |
15
+ | 3.3.5 | 1.2.7 | `US-ASCII` | `UTF-8` |
16
+ | 3.4.10 | 1.2.7 | `US-ASCII` | `UTF-8` |
17
+
18
+ Ruby 4.0.6 with `ipaddr` 1.2.8 showed the same result, but Ruby 4.0 is a
19
+ provisional, non-blocking Otto target. See the
20
+ [runtime and dependency security policy](../reference/runtime-and-dependency-security.md)
21
+ for the current support matrix.
22
+
23
+ You can check the active runtime directly:
24
+
25
+ ```ruby
26
+ require 'ipaddr'
27
+
28
+ puts RUBY_DESCRIPTION
29
+ puts IPAddr::VERSION if defined?(IPAddr::VERSION)
30
+ p IPAddr.new('192.168.1.1').to_s.encoding
31
+ p IPAddr.new('::1').to_s.encoding
32
+ ```
33
+
34
+ The result comes from the `ipaddr` implementation's separate IPv4 and IPv6
35
+ formatting paths. Treat it as version-specific behavior and rerun the check when
36
+ changing Ruby or overriding the default `ipaddr` gem.
37
+
38
+ ## Otto's normalization
39
+
40
+ An IP address string contains only ASCII bytes, so relabeling a generated
41
+ `US-ASCII` address as `UTF-8` does not change its bytes. Otto uses this at the
42
+ boundary where `IPPrivacy.mask_ip` creates IPv4 and IPv6 strings:
43
+
44
+ ```ruby
45
+ IPAddr.new(masked_integer, address_family).to_s.force_encoding(Encoding::UTF_8)
46
+ ```
47
+
48
+ This gives downstream Rack code one encoding for Otto-generated masked
49
+ addresses. A `US-ASCII` string is normally compatible with UTF-8 text; the
50
+ encoding difference alone does not imply an error. Normalization prevents code
51
+ that requires an explicit UTF-8 label from receiving different labels for IPv4
52
+ and IPv6.
53
+
54
+ Use `force_encoding` here only because Otto constructed the value from an IP
55
+ address and therefore knows every byte is ASCII. Do not apply the same operation
56
+ to arbitrary external bytes without validating or transcoding them first.
@@ -0,0 +1,282 @@
1
+ # Model Context Protocol (MCP)
2
+
3
+ `Otto::MCP` adds one JSON-RPC 2.0 HTTP endpoint to an Otto application. An MCP
4
+ client can use that endpoint to initialize a connection, list registered
5
+ resources and tools, read resources, and call tools. The default endpoint is
6
+ `POST /_mcp`.
7
+
8
+ MCP is opt-in. It can invoke the handlers that you register, so require bearer
9
+ tokens before exposing the endpoint outside a trusted local environment.
10
+
11
+ ## Before you start
12
+
13
+ MCP validation and rate limiting are enabled by default. Add their optional
14
+ dependencies to the application's `Gemfile` before enabling MCP:
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem 'json_schemer', '~> 2.0'
19
+ gem 'rack-attack', '~> 6.7'
20
+ ```
21
+
22
+ Mount `Rack::Attack` in the Rack application. Without it, the configured MCP
23
+ rate limits are not enforced.
24
+
25
+ ```ruby
26
+ # config.ru
27
+ use Rack::Attack
28
+ run otto
29
+ ```
30
+
31
+ If a dependency is missing, enabling its corresponding feature raises
32
+ `Otto::OptionalDependencyError`. You may disable validation or rate limiting
33
+ with `enable_validation: false` or `enable_rate_limiting: false`, but doing so
34
+ removes that protection.
35
+
36
+ Configure MCP during boot, before Otto serves its first request. See
37
+ [configuration freezing](configuration_freezing.md) for the lifecycle rule.
38
+
39
+ ## Enable a protected endpoint
40
+
41
+ Use `mcp_enabled: true` when constructing the application. Use
42
+ `ENV.fetch('MCP_TOKEN')` without a default so a missing deployment secret stops
43
+ boot instead of creating an open endpoint.
44
+
45
+ ```ruby
46
+ # config.ru
47
+ require 'otto'
48
+ require_relative 'app'
49
+
50
+ otto = Otto.new('routes',
51
+ mcp_enabled: true,
52
+ mcp_auth_tokens: [ENV.fetch('MCP_TOKEN')],
53
+ mcp_requests_per_minute: 120,
54
+ mcp_tool_calls_per_minute: 30,
55
+ )
56
+
57
+ use Rack::Attack
58
+ run otto
59
+ ```
60
+
61
+ The endpoint is `POST /_mcp` unless `mcp_endpoint:` (or `http_endpoint:`) sets
62
+ another slash-prefixed path. `mcp_enabled?` returns `true` after MCP has been
63
+ enabled.
64
+
65
+ For multi-step boot configuration, call `enable_mcp!` instead:
66
+
67
+ ```ruby
68
+ otto = Otto.new('routes')
69
+ otto.enable_mcp!(
70
+ http_endpoint: '/api/mcp',
71
+ auth_tokens: [ENV.fetch('MCP_TOKEN')],
72
+ )
73
+ ```
74
+
75
+ Enable MCP only once per `Otto` instance. A second call raises `ArgumentError`;
76
+ provide all MCP settings in the first call.
77
+
78
+ ## Register resources and tools
79
+
80
+ Declare resources and tools in the normal Otto routes file. The initial verb
81
+ and path are required by the route-file grammar, but they do not create HTTP
82
+ routes for these declarations. Otto registers the MCP definition that follows
83
+ them. The single MCP HTTP endpoint remains the only transport route.
84
+
85
+ ```text
86
+ # routes
87
+ GET /mcp/users MCP users AppMCP.users
88
+ POST /mcp/create-user TOOL create_user AppMCP.create_user
89
+ ```
90
+
91
+ `MCP` registers a resource. Its resource URI is `users`: Otto removes one
92
+ leading slash from the declaration. The handler must be a zero-argument class
93
+ method. Otto returns its value as text with the `text/plain` MIME type.
94
+
95
+ `TOOL` registers a tool. Its handler is a class method that receives
96
+ `arguments` and the Rack `env`:
97
+
98
+ ```ruby
99
+ # app.rb
100
+ require 'json'
101
+
102
+ class AppMCP
103
+ def self.users
104
+ JSON.generate(users: [{ id: 1, name: 'Ada' }])
105
+ end
106
+
107
+ def self.create_user(arguments, _env)
108
+ "Created user: #{arguments.fetch('name')}"
109
+ end
110
+ end
111
+ ```
112
+
113
+ A resource declaration currently supplies its name, description, and MIME type
114
+ automatically: the resource URI determines the name, descriptions are generated
115
+ from the URI or tool name, and resources use `text/plain`. Tool declarations
116
+ currently advertise an empty input schema. A tool still receives the
117
+ `params.arguments` object supplied by the client, so validate its fields in the
118
+ handler before using them.
119
+
120
+ ## Call the endpoint
121
+
122
+ Every request must be a JSON-RPC 2.0 `POST` with
123
+ `Content-Type: application/json`. A request needs `jsonrpc`, `id`, and
124
+ `method`; `params`, when present, must be an object.
125
+
126
+ Pass a configured token in either header. `Authorization` is checked first.
127
+
128
+ ```text
129
+ Authorization: Bearer <token>
130
+ X-MCP-Token: <token>
131
+ ```
132
+
133
+ Initialize the connection:
134
+
135
+ ```sh
136
+ curl -sS -X POST http://localhost:9292/_mcp \
137
+ -H 'Content-Type: application/json' \
138
+ -H 'Authorization: Bearer s3cret' \
139
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
140
+ ```
141
+
142
+ Replace `s3cret` in these requests with a configured token. A successful
143
+ response has the same `id` and a `result` containing the protocol version,
144
+ supported capabilities, and server information.
145
+
146
+ After registering the preceding routes, list the available resources and tools:
147
+
148
+ ```sh
149
+ curl -sS -X POST http://localhost:9292/_mcp \
150
+ -H 'Content-Type: application/json' \
151
+ -H 'Authorization: Bearer s3cret' \
152
+ -d '{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{}}'
153
+
154
+ curl -sS -X POST http://localhost:9292/_mcp \
155
+ -H 'Content-Type: application/json' \
156
+ -H 'Authorization: Bearer s3cret' \
157
+ -d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}'
158
+ ```
159
+
160
+ Read the `users` resource or call the `create_user` tool:
161
+
162
+ ```sh
163
+ curl -sS -X POST http://localhost:9292/_mcp \
164
+ -H 'Content-Type: application/json' \
165
+ -H 'Authorization: Bearer s3cret' \
166
+ -d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"users"}}'
167
+
168
+ curl -sS -X POST http://localhost:9292/_mcp \
169
+ -H 'Content-Type: application/json' \
170
+ -H 'Authorization: Bearer s3cret' \
171
+ -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_user","arguments":{"name":"Ada"}}}'
172
+ ```
173
+
174
+ ## Authentication
175
+
176
+ `auth_tokens:` accepts a string or an array of strings. The endpoint accepts a
177
+ request when the token matches any configured token. Missing, malformed, or
178
+ unknown credentials return HTTP `401` and this JSON-RPC error:
179
+
180
+ ```json
181
+ {"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Unauthorized","data":"Valid token required"}}
182
+ ```
183
+
184
+ Supplying `nil`, a blank string, or an otherwise empty token value raises
185
+ `ArgumentError`. An explicit empty array means no token authentication is
186
+ mounted, so do not use it as a way to disable access.
187
+
188
+ For a deliberate localhost-only endpoint, omit `auth_tokens:` and acknowledge
189
+ that choice explicitly:
190
+
191
+ ```ruby
192
+ otto.enable_mcp!(allow_unauthenticated: true)
193
+ ```
194
+
195
+ This option only suppresses Otto's unauthenticated-endpoint warning; it does
196
+ not add or remove authentication. An open endpoint lets any caller list and
197
+ invoke every registered MCP resource and tool.
198
+
199
+ ## Configuration reference
200
+
201
+ Both enablement forms accept the canonical option names below and the listed
202
+ `mcp_` aliases. Keys may be Symbols or Strings. Providing two spellings of one
203
+ option with different values raises `ArgumentError`.
204
+
205
+ | Option | Alias | Default | Effect |
206
+ | --- | --- | --- | --- |
207
+ | `http_endpoint` | `mcp_endpoint` | `'/_mcp'` | Slash-prefixed endpoint path. |
208
+ | `auth_tokens` | `mcp_auth_tokens` | `[]` | String or array of bearer tokens. |
209
+ | `enable_validation` | `mcp_validation` | `true` | Validates the JSON-RPC request envelope. |
210
+ | `enable_rate_limiting` | `mcp_rate_limiting` | `true` | Enables MCP rate-limit configuration. |
211
+ | `requests_per_minute` | `mcp_requests_per_minute` | `60` | Per-client-IP limit for all MCP endpoint requests. |
212
+ | `tools_per_minute` | `tool_calls_per_minute`, `mcp_tool_calls_per_minute` | `20` | Additional per-client-IP limit for `tools/call`. |
213
+ | `allow_unauthenticated` | `mcp_allow_unauthenticated` | `false` | Acknowledges an intentionally open endpoint. |
214
+
215
+ Limits must be positive integers. Endpoint paths must be strings beginning with
216
+ `/`; tokens must be non-blank strings; and all flags must be exactly `true` or
217
+ `false`. Invalid values fail at boot with `ArgumentError`.
218
+
219
+ `mcp_enabled`, `mcp_http`, and `mcp_stdio` are constructor-only gating options:
220
+
221
+ | Constructor option | Effect |
222
+ | --- | --- |
223
+ | `mcp_enabled: true` | Creates the MCP server and enables its HTTP endpoint. |
224
+ | `mcp_http: false` | Does not register the HTTP endpoint or its middleware. It is useful only with another MCP gate such as `mcp_enabled: true`. |
225
+ | `mcp_stdio: true` | Creates the MCP server, but Otto does not provide an stdio transport. Because HTTP is enabled by default, also set `mcp_http: false` to avoid enabling HTTP. |
226
+
227
+ `enable_mcp!` always enables HTTP and rejects these gating options. It is strict:
228
+ unknown keys, including common near-misses such as `auth_token:`, raise
229
+ `ArgumentError`. The `Otto.new` constructor ignores unknown non-MCP options but
230
+ rejects unknown `mcp_`-prefixed options.
231
+
232
+ ## Rate limiting and validation
233
+
234
+ Rate limits use a rolling 60-second period. `tools_per_minute` is additional to
235
+ `requests_per_minute`, not a replacement. The guards run in this order:
236
+
237
+ 1. Rate limiting
238
+ 2. Token authentication
239
+ 3. JSON-schema validation
240
+
241
+ Therefore, a malformed request without a valid token receives `401` instead of
242
+ a validation response. Set either feature to `false` only when you accept the
243
+ resulting exposure.
244
+
245
+ MCP guards match the configured endpoint exactly, using Otto's normalized path.
246
+ A sibling such as `/admin` beside an endpoint at `/a` is not challenged,
247
+ validated, or counted. A configured trailing slash is accepted with or without
248
+ the trailing slash.
249
+
250
+ When Otto is mounted under a Rack path prefix, mount `Rack::Attack` inside the
251
+ same `map` block so it receives the same `PATH_INFO` that Otto routes:
252
+
253
+ ```ruby
254
+ # config.ru
255
+ map '/api' do
256
+ use Rack::Attack
257
+ run otto # An endpoint at /_mcp is reached at POST /api/_mcp.
258
+ end
259
+ ```
260
+
261
+ `Rack::Attack` configuration is process-global. Separate Otto applications in
262
+ the same process get independent MCP counters when their endpoint paths differ.
263
+ Applications that share the same endpoint path also share its throttle
264
+ configuration and counters; use distinct endpoint paths when isolation matters.
265
+
266
+ If general Otto rate limiting is also enabled, its `Rack::Attack` responder
267
+ replaces MCP's JSON-RPC-specific `429` response. Do not rely on the MCP error
268
+ body in that combined configuration.
269
+
270
+ ## Errors and limits
271
+
272
+ | Situation | HTTP status and JSON-RPC code |
273
+ | --- | --- |
274
+ | Missing or invalid bearer token | `401`, `-32000` (`Unauthorized`) |
275
+ | Invalid JSON, request envelope, HTTP method, or content type | `400`, `-32700` or `-32600` |
276
+ | Unknown protocol method or invalid method parameters | `400`, `-32601` or `-32602` |
277
+ | Unknown resource or tool | `404`, `-32001` or `-32002` |
278
+ | Resource or tool handler raises | `500`, `-32603`; details are logged, not returned to the client |
279
+ | MCP rate limit exceeded | `429`, `-32000` unless general Otto rate limiting overrides the response body |
280
+
281
+ Otto currently provides HTTP transport only. It does not implement an stdio
282
+ transport, resource subscriptions, or resource-list change notifications.