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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- 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.
|
data/docs/guides/mcp.md
ADDED
|
@@ -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.
|