otto 2.8.1 → 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 +239 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +139 -38
- 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 +42 -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/response.rb +7 -1
- 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 +379 -20
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +159 -7
- data/lib/otto/security/csp/request_extras.rb +256 -0
- data/lib/otto/security/csp/writer.rb +153 -12
- data/lib/otto/security/csp.rb +5 -0
- 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 +32 -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
|
@@ -5,44 +5,152 @@
|
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
7
|
require_relative '../security/rate_limiting'
|
|
8
|
-
|
|
9
|
-
begin
|
|
10
|
-
require 'rack/attack'
|
|
11
|
-
rescue LoadError
|
|
12
|
-
# rack-attack is optional - graceful fallback
|
|
13
|
-
end
|
|
8
|
+
require_relative 'endpoint'
|
|
14
9
|
|
|
15
10
|
class Otto
|
|
16
11
|
module MCP
|
|
17
12
|
# Rate limiter for MCP protocol endpoints
|
|
13
|
+
#
|
|
14
|
+
# Rack::Attack configuration is process-global and a throttle is keyed by
|
|
15
|
+
# name alone, so registering 'mcp_requests' for a second MCP app in the
|
|
16
|
+
# same process used to REPLACE the first app's throttle: the captured
|
|
17
|
+
# endpoint moved from /a to /b and /a stopped being rate limited. Each
|
|
18
|
+
# configured endpoint now gets its own throttle pair
|
|
19
|
+
# ('mcp_requests:/a', 'mcp_tool_calls:/a', ...) with its own limits, and
|
|
20
|
+
# every endpoint configured in the process is remembered in
|
|
21
|
+
# {.registered_endpoints} so the (single, global) throttled responder and
|
|
22
|
+
# log subscriber recognise all of them, not just the most recent.
|
|
23
|
+
#
|
|
24
|
+
# Known limitation: throttles are keyed by endpoint path, not by Otto
|
|
25
|
+
# instance. Two apps in one process that mount MCP on the SAME path (for
|
|
26
|
+
# example both on /_mcp behind a host or tenant dispatcher) share one
|
|
27
|
+
# throttle definition, so the limits configured last apply to both, and
|
|
28
|
+
# one set of per-client-IP counters. Use distinct endpoint paths per app.
|
|
18
29
|
class RateLimiter < Otto::Security::RateLimiting
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
DEFAULT_HTTP_ENDPOINT = '/_mcp'
|
|
31
|
+
|
|
32
|
+
@endpoints = Set.new
|
|
33
|
+
@endpoints_mutex = Mutex.new
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Every MCP endpoint configured via {.configure_rack_attack!} in this
|
|
37
|
+
# process, in configuration order.
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
def registered_endpoints
|
|
40
|
+
@endpoints_mutex.synchronize { @endpoints.to_a }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Forget every registered endpoint. Test isolation only: production
|
|
44
|
+
# configuration is additive for the life of the process, like
|
|
45
|
+
# Rack::Attack's own.
|
|
46
|
+
# @api private
|
|
47
|
+
def reset_endpoints!
|
|
48
|
+
@endpoints_mutex.synchronize { @endpoints.clear }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @api private
|
|
52
|
+
def register_endpoint(endpoint)
|
|
53
|
+
return if endpoint.nil?
|
|
21
54
|
|
|
22
|
-
|
|
55
|
+
@endpoints_mutex.synchronize { @endpoints << endpoint }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.configure_rack_attack!(config = {})
|
|
60
|
+
# Start with base configuration from general rate limiting. The base
|
|
61
|
+
# configure_rack_attack! assigns the single Rack::Attack
|
|
62
|
+
# throttled_responder (dispatching to .throttled_response, overridden
|
|
63
|
+
# below) and calls .configure_logging (also overridden below), so this
|
|
64
|
+
# class registers no responder or subscriber of its own after super.
|
|
23
65
|
super
|
|
24
66
|
|
|
25
|
-
|
|
67
|
+
register_endpoint(config[:mcp_http_endpoint])
|
|
26
68
|
configure_mcp_rules(config)
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resolve the MCP endpoint a Rack::Attack throttle should match against.
|
|
72
|
+
#
|
|
73
|
+
# Rack::Attack is mounted by the hosting app OUTSIDE Otto and runs before
|
|
74
|
+
# every Otto middleware, including the proc that sets
|
|
75
|
+
# env['otto.mcp_http_endpoint'], so inside a throttle that env key is
|
|
76
|
+
# absent. The endpoint therefore has to arrive with the configuration
|
|
77
|
+
# (Server#apply_rate_limits publishes it as :mcp_http_endpoint). The env
|
|
78
|
+
# key is kept as a fallback for callers that run Rack::Attack inside
|
|
79
|
+
# Otto's own stack, and the documented default closes the chain.
|
|
80
|
+
#
|
|
81
|
+
# @param configured [String, nil] endpoint from the rate limiting config
|
|
82
|
+
# @param env [Hash] the request env
|
|
83
|
+
# @return [String]
|
|
84
|
+
def self.mcp_endpoint_for(configured, env)
|
|
85
|
+
configured || env['otto.mcp_http_endpoint'] || DEFAULT_HTTP_ENDPOINT
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether a request targets ANY MCP endpoint configured in this process.
|
|
89
|
+
#
|
|
90
|
+
# Used by the throttled responder and the log subscriber, which are
|
|
91
|
+
# global (the last configuration wins) and so cannot capture a single
|
|
92
|
+
# endpoint the way a per-endpoint throttle can. Consults the registry at
|
|
93
|
+
# call time, then the env key, then the default when nothing is
|
|
94
|
+
# configured at all. A request matches only the exact endpoint path the
|
|
95
|
+
# router would dispatch to the MCP handler (see Otto::MCP.endpoint_path?),
|
|
96
|
+
# never a sibling that merely shares the prefix.
|
|
97
|
+
#
|
|
98
|
+
# Compares PATH_INFO, not Rack::Request#path (SCRIPT_NAME + PATH_INFO):
|
|
99
|
+
# the router dispatches on PATH_INFO, so under `map '/api' { run otto }`
|
|
100
|
+
# the MCP request for an endpoint at /_mcp arrives as SCRIPT_NAME=/api,
|
|
101
|
+
# PATH_INFO=/_mcp and the full path /api/_mcp never equals the endpoint.
|
|
102
|
+
#
|
|
103
|
+
# @param request [Rack::Attack::Request, #path_info, #env]
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def self.mcp_request?(request)
|
|
106
|
+
candidates = registered_endpoints
|
|
107
|
+
env_endpoint = request.env['otto.mcp_http_endpoint']
|
|
108
|
+
candidates << env_endpoint if env_endpoint
|
|
109
|
+
candidates << DEFAULT_HTTP_ENDPOINT if candidates.empty?
|
|
110
|
+
|
|
111
|
+
candidates.any? { |endpoint| Otto::MCP.endpoint_path?(request.path_info, endpoint) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Name of the Rack::Attack throttle for +rule+ on +endpoint+.
|
|
115
|
+
#
|
|
116
|
+
# The endpoint is part of the name so that two MCP apps in one process
|
|
117
|
+
# get independent throttles (and independent counters: Rack::Attack keys
|
|
118
|
+
# the cache by throttle name). Without a configured endpoint the bare
|
|
119
|
+
# rule name is used and the block resolves the endpoint per request.
|
|
120
|
+
#
|
|
121
|
+
# @param rule [String] 'mcp_requests' or 'mcp_tool_calls'
|
|
122
|
+
# @param endpoint [String, nil]
|
|
123
|
+
# @return [String]
|
|
124
|
+
def self.throttle_name(rule, endpoint)
|
|
125
|
+
endpoint ? "#{rule}:#{endpoint}" : rule
|
|
29
126
|
end
|
|
30
127
|
|
|
31
128
|
def self.configure_mcp_rules(config)
|
|
32
|
-
#
|
|
129
|
+
# Captured once, outside the blocks: they run per request, long after
|
|
130
|
+
# this configuration hash is gone.
|
|
131
|
+
configured_endpoint = config[:mcp_http_endpoint]
|
|
132
|
+
|
|
133
|
+
# MCP endpoint requests - 60 per minute by default. Only the exact
|
|
134
|
+
# endpoint path counts: the router dispatches the MCP route by literal
|
|
135
|
+
# match, so a prefix match here would let /admin traffic exhaust (and be
|
|
136
|
+
# refused by) the counter for an endpoint at /a. PATH_INFO, not #path:
|
|
137
|
+
# a mount prefix (SCRIPT_NAME) is invisible to the router and must be
|
|
138
|
+
# invisible here too, or a mounted Otto is never throttled.
|
|
33
139
|
mcp_requests_limit = config[:mcp_requests_per_minute] || 60
|
|
34
140
|
|
|
35
|
-
Rack::Attack.throttle('mcp_requests',
|
|
36
|
-
|
|
37
|
-
|
|
141
|
+
Rack::Attack.throttle(throttle_name('mcp_requests', configured_endpoint),
|
|
142
|
+
limit: mcp_requests_limit, period: 60) do |request|
|
|
143
|
+
endpoint = mcp_endpoint_for(configured_endpoint, request.env)
|
|
144
|
+
request.ip if Otto::MCP.endpoint_path?(request.path_info, endpoint)
|
|
38
145
|
end
|
|
39
146
|
|
|
40
147
|
# Tool calls are more expensive - 20 per minute by default
|
|
41
148
|
tool_calls_limit = config[:tool_calls_per_minute] || 20
|
|
42
149
|
|
|
43
|
-
Rack::Attack.throttle('mcp_tool_calls',
|
|
44
|
-
|
|
45
|
-
|
|
150
|
+
Rack::Attack.throttle(throttle_name('mcp_tool_calls', configured_endpoint),
|
|
151
|
+
limit: tool_calls_limit, period: 60) do |request|
|
|
152
|
+
endpoint = mcp_endpoint_for(configured_endpoint, request.env)
|
|
153
|
+
if Otto::MCP.endpoint_path?(request.path_info, endpoint) && request.post?
|
|
46
154
|
begin
|
|
47
155
|
body = request.body.read
|
|
48
156
|
data = JSON.parse(body)
|
|
@@ -56,91 +164,59 @@ class Otto
|
|
|
56
164
|
end
|
|
57
165
|
end
|
|
58
166
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
retry_after: headers['retry-after'].to_i,
|
|
82
|
-
limit: match_data[:limit],
|
|
83
|
-
period: match_data[:period],
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
}
|
|
87
|
-
[429, headers, [JSON.generate(error_response)]]
|
|
88
|
-
else
|
|
89
|
-
# Use the general rate limiting response for non-MCP requests
|
|
90
|
-
# Route's response_type takes precedence over Accept header
|
|
91
|
-
route_def = request.env['otto.route_definition']
|
|
92
|
-
wants_json = (route_def&.response_type == 'json') ||
|
|
93
|
-
request.env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
94
|
-
|
|
95
|
-
if wants_json
|
|
96
|
-
error_response = {
|
|
97
|
-
error: 'Rate limit exceeded',
|
|
98
|
-
message: 'Too many requests',
|
|
99
|
-
retry_after: headers['retry-after'].to_i,
|
|
100
|
-
limit: match_data[:limit],
|
|
101
|
-
period: match_data[:period],
|
|
102
|
-
}
|
|
103
|
-
[429, headers, [JSON.generate(error_response)]]
|
|
104
|
-
else
|
|
105
|
-
body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
|
|
106
|
-
headers['content-type'] = 'text/plain'
|
|
107
|
-
[429, headers, [body]]
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
167
|
+
# JSON-RPC formatted 429 for MCP requests; the general Otto response
|
|
168
|
+
# (route response_type, then Accept header) for everything else.
|
|
169
|
+
def self.throttled_response(request)
|
|
170
|
+
return super unless mcp_request?(request)
|
|
171
|
+
|
|
172
|
+
match_data = request.env['rack.attack.match_data']
|
|
173
|
+
headers = throttle_headers(match_data)
|
|
174
|
+
|
|
175
|
+
error_response = {
|
|
176
|
+
jsonrpc: '2.0',
|
|
177
|
+
id: nil,
|
|
178
|
+
error: {
|
|
179
|
+
code: -32_000,
|
|
180
|
+
message: 'Rate limit exceeded',
|
|
181
|
+
data: {
|
|
182
|
+
retry_after: headers['retry-after'].to_i,
|
|
183
|
+
limit: match_data[:limit],
|
|
184
|
+
period: match_data[:period],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
[429, headers, [JSON.generate(error_response)]]
|
|
111
189
|
end
|
|
112
190
|
|
|
113
|
-
|
|
191
|
+
# Keep one MCP-aware subscriber while allowing it to recognize every
|
|
192
|
+
# endpoint registered in this process.
|
|
193
|
+
def self.configure_logging
|
|
114
194
|
return unless defined?(ActiveSupport::Notifications)
|
|
115
195
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
req = payload[:request]
|
|
120
|
-
endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
|
|
121
|
-
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
122
|
-
|
|
123
|
-
if req.path.start_with?(endpoint)
|
|
124
|
-
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
125
|
-
else
|
|
126
|
-
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
127
|
-
end
|
|
196
|
+
ActiveSupport::Notifications.unsubscribe(@log_subscriber) if @log_subscriber
|
|
197
|
+
@log_subscriber = ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
198
|
+
log_throttled_request(payload)
|
|
128
199
|
end
|
|
129
200
|
end
|
|
201
|
+
|
|
202
|
+
# Masked address only — see the note on the base implementation in
|
|
203
|
+
# Otto::Security::RateLimiting (issue #219).
|
|
204
|
+
def self.log_throttled_request(payload)
|
|
205
|
+
req = payload[:request]
|
|
206
|
+
return super unless mcp_request?(req)
|
|
207
|
+
|
|
208
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
209
|
+
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
210
|
+
end
|
|
130
211
|
end
|
|
131
212
|
|
|
132
213
|
# Middleware for applying rate limits to MCP protocol endpoints
|
|
133
214
|
class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
|
|
134
215
|
def initialize(app, security_config = nil)
|
|
135
|
-
@app
|
|
136
|
-
@security_config
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if @rate_limiter_available
|
|
140
|
-
configure_mcp_rate_limiting
|
|
141
|
-
else
|
|
142
|
-
Otto.logger.warn '[MCP] rack-attack not available - rate limiting disabled'
|
|
143
|
-
end
|
|
216
|
+
@app = app
|
|
217
|
+
@security_config = security_config
|
|
218
|
+
|
|
219
|
+
configure_mcp_rate_limiting
|
|
144
220
|
end
|
|
145
221
|
|
|
146
222
|
private
|
|
@@ -149,11 +225,13 @@ class Otto
|
|
|
149
225
|
# Get base configuration from security config
|
|
150
226
|
base_config = @security_config&.rate_limiting_config || {}
|
|
151
227
|
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
228
|
+
# MCP defaults, overridden by anything the security config carries
|
|
229
|
+
# (Server#apply_rate_limits publishes the configured limits and the
|
|
230
|
+
# endpoint there).
|
|
231
|
+
mcp_config = {
|
|
232
|
+
mcp_requests_per_minute: 60,
|
|
155
233
|
tool_calls_per_minute: 20,
|
|
156
|
-
|
|
234
|
+
}.merge(base_config)
|
|
157
235
|
|
|
158
236
|
RateLimiter.configure_rack_attack!(mcp_config)
|
|
159
237
|
end
|
data/lib/otto/mcp/registry.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../security/constant_resolver'
|
|
6
|
+
require_relative 'errors'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module MCP
|
|
@@ -57,24 +58,23 @@ class Otto
|
|
|
57
58
|
resource = @resources[uri]
|
|
58
59
|
return nil unless resource
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
61
|
+
# A handler that blows up propagates: it is an execution fault
|
|
62
|
+
# (-32603/500), not a missing resource (-32001/404). Returning nil
|
|
63
|
+
# here previously made the two indistinguishable to the protocol,
|
|
64
|
+
# which owns the logging (Protocol#handle_resources_read).
|
|
65
|
+
content = resource[:handler].call
|
|
66
|
+
{
|
|
67
|
+
contents: [{
|
|
68
|
+
uri: uri,
|
|
69
|
+
mimeType: resource[:mimeType],
|
|
70
|
+
text: content.to_s,
|
|
71
|
+
}],
|
|
72
|
+
}
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def call_tool(name, arguments, env)
|
|
76
76
|
tool = @tools[name]
|
|
77
|
-
raise "Tool not found: #{name}" unless tool
|
|
77
|
+
raise ToolNotFoundError, "Tool not found: #{name}" unless tool
|
|
78
78
|
|
|
79
79
|
handler = tool[:handler]
|
|
80
80
|
if handler.respond_to?(:call)
|
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
rescue LoadError
|
|
10
|
-
# json_schemer is optional - graceful fallback
|
|
11
|
-
end
|
|
7
|
+
require_relative '../optional_dependency'
|
|
8
|
+
require_relative 'endpoint'
|
|
12
9
|
|
|
13
10
|
class Otto
|
|
14
11
|
module MCP
|
|
@@ -16,14 +13,24 @@ class Otto
|
|
|
16
13
|
|
|
17
14
|
# JSON Schema validator for MCP protocol requests
|
|
18
15
|
class Validator
|
|
16
|
+
JSON_SCHEMER_REQUIREMENT = '~> 2.0'
|
|
17
|
+
|
|
18
|
+
def self.ensure_available!
|
|
19
|
+
Otto::OptionalDependency.require!(
|
|
20
|
+
'json_schemer',
|
|
21
|
+
JSON_SCHEMER_REQUIREMENT,
|
|
22
|
+
require_path: 'json_schemer',
|
|
23
|
+
feature: 'MCP JSON Schema validation',
|
|
24
|
+
alternative: 'Or pass `enable_validation: false` when enabling MCP.'
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def initialize
|
|
20
|
-
|
|
21
|
-
@
|
|
29
|
+
self.class.ensure_available!
|
|
30
|
+
@schemas = {}
|
|
22
31
|
end
|
|
23
32
|
|
|
24
33
|
def validate_request(data)
|
|
25
|
-
return true unless @json_schemer_available
|
|
26
|
-
|
|
27
34
|
schema = mcp_request_schema
|
|
28
35
|
validation_errors = schema.validate(data).to_a
|
|
29
36
|
|
|
@@ -36,7 +43,7 @@ class Otto
|
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
def validate_tool_arguments(tool_name, arguments, schema)
|
|
39
|
-
return true unless
|
|
46
|
+
return true unless schema
|
|
40
47
|
|
|
41
48
|
schemer = JSONSchemer.schema(schema)
|
|
42
49
|
validation_errors = schemer.validate(arguments).to_a
|
|
@@ -100,10 +107,11 @@ class Otto
|
|
|
100
107
|
|
|
101
108
|
private
|
|
102
109
|
|
|
110
|
+
# Exact endpoint only, normalized as the router normalizes it (see
|
|
111
|
+
# Otto::MCP.endpoint_path?); sibling paths are never parsed or rejected.
|
|
103
112
|
def mcp_endpoint?(env)
|
|
104
113
|
endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
|
|
105
|
-
|
|
106
|
-
path.start_with?(endpoint)
|
|
114
|
+
Otto::MCP.endpoint_path?(env['PATH_INFO'], endpoint)
|
|
107
115
|
end
|
|
108
116
|
|
|
109
117
|
def validation_error_response(id, message)
|
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'route_parser'
|
|
|
8
8
|
require_relative 'auth/token'
|
|
9
9
|
require_relative 'schema_validation'
|
|
10
10
|
require_relative 'rate_limiting'
|
|
11
|
+
require_relative 'options'
|
|
11
12
|
require_relative '../security/constant_resolver'
|
|
12
13
|
|
|
13
14
|
class Otto
|
|
@@ -16,18 +17,67 @@ class Otto
|
|
|
16
17
|
class Server
|
|
17
18
|
attr_reader :protocol, :otto_instance
|
|
18
19
|
|
|
20
|
+
# Normalize raw options into the canonical MCP option hash.
|
|
21
|
+
#
|
|
22
|
+
# @param opts [Hash] raw options
|
|
23
|
+
# @param scope [Symbol] :explicit (strict, for #enable_mcp!) or
|
|
24
|
+
# :constructor (ignores non-MCP keys, for Otto.new)
|
|
25
|
+
# @see Otto::MCP::Options.normalize
|
|
26
|
+
def self.normalize_options(opts = {}, scope = :explicit)
|
|
27
|
+
Otto::MCP::Options.normalize(opts, scope)
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
def initialize(otto_instance)
|
|
20
31
|
@otto_instance = otto_instance
|
|
21
32
|
@protocol = Protocol.new(otto_instance)
|
|
22
33
|
@enabled = false
|
|
23
34
|
end
|
|
24
35
|
|
|
36
|
+
# Warning emitted when the MCP HTTP endpoint is exposed with no token
|
|
37
|
+
# authentication. Unconditional (not gated on Otto.debug): an
|
|
38
|
+
# unauthenticated MCP endpoint lets any caller invoke every registered
|
|
39
|
+
# tool, so it must be visible in normal boot output.
|
|
40
|
+
UNAUTHENTICATED_WARNING = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
41
|
+
[MCP] HTTP endpoint %s is enabled without authentication:
|
|
42
|
+
any caller can list and invoke MCP tools and resources.
|
|
43
|
+
Pass auth_tokens: ['<token>'] to require a bearer token, or
|
|
44
|
+
allow_unauthenticated: true to acknowledge this intentionally.
|
|
45
|
+
MSG
|
|
46
|
+
|
|
47
|
+
# Enable the MCP server.
|
|
48
|
+
#
|
|
49
|
+
# Enabling is one-shot. Each call appends a route, an endpoint-setting
|
|
50
|
+
# proc and the MCP middleware to the Otto instance without removing the
|
|
51
|
+
# previous set, so a second call with a different endpoint would leave
|
|
52
|
+
# the first endpoint routed but guarded by nothing (the auth middleware
|
|
53
|
+
# only matches the newest endpoint). Rather than try to unwind that,
|
|
54
|
+
# a second call raises.
|
|
55
|
+
#
|
|
56
|
+
# @param options [Hash] canonical or aliased options; normalized via
|
|
57
|
+
# {.normalize_options}, so both the canonical keys (:http_endpoint,
|
|
58
|
+
# :auth_tokens, ...) and their mcp_-prefixed spellings
|
|
59
|
+
# (:mcp_endpoint, :mcp_auth_tokens, ...) are accepted.
|
|
60
|
+
# @raise [ArgumentError] if the server is already enabled
|
|
25
61
|
def enable!(options = {})
|
|
26
|
-
@enabled
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
62
|
+
if @enabled
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"MCP server is already enabled on #{@http_endpoint}; pass all MCP options " \
|
|
65
|
+
'in a single Otto.new or enable_mcp! call'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
options = self.class.normalize_options(options)
|
|
69
|
+
|
|
70
|
+
Validator.ensure_available! if options[:enable_validation]
|
|
71
|
+
Otto::Security::RateLimiting.ensure_available! if options[:enable_rate_limiting]
|
|
72
|
+
|
|
73
|
+
@enabled = true
|
|
74
|
+
@http_endpoint = options[:http_endpoint]
|
|
75
|
+
@auth_tokens = options[:auth_tokens]
|
|
76
|
+
@enable_validation = options[:enable_validation]
|
|
77
|
+
@enable_rate_limiting = options[:enable_rate_limiting]
|
|
78
|
+
@allow_unauthenticated = options[:allow_unauthenticated]
|
|
79
|
+
|
|
80
|
+
apply_rate_limits(options)
|
|
31
81
|
|
|
32
82
|
# Configure middleware
|
|
33
83
|
configure_middleware(options)
|
|
@@ -35,6 +85,8 @@ class Otto
|
|
|
35
85
|
# Add MCP endpoint route to Otto
|
|
36
86
|
add_mcp_endpoint_route
|
|
37
87
|
|
|
88
|
+
warn_if_unauthenticated!
|
|
89
|
+
|
|
38
90
|
Otto.logger.info "[MCP] Server enabled with HTTP endpoint: #{@http_endpoint}" if Otto.debug
|
|
39
91
|
end
|
|
40
92
|
|
|
@@ -53,42 +105,82 @@ class Otto
|
|
|
53
105
|
|
|
54
106
|
private
|
|
55
107
|
|
|
108
|
+
# Publish the per-minute limits under the keys RateLimitMiddleware /
|
|
109
|
+
# RateLimiter.configure_rack_attack! already read, via Otto's sanctioned
|
|
110
|
+
# rate-limiting configuration entry point. Before this, the values passed
|
|
111
|
+
# to enable! were dead and the hardcoded 60/20 always won.
|
|
112
|
+
#
|
|
113
|
+
# The endpoint travels with them. Rack::Attack is mounted by the host app
|
|
114
|
+
# OUTSIDE Otto and runs before any of Otto's middleware, so the
|
|
115
|
+
# env['otto.mcp_http_endpoint'] set by add_mcp_endpoint_route is not yet
|
|
116
|
+
# present when the throttles are evaluated; without this, a custom
|
|
117
|
+
# endpoint was compared against the '/_mcp' fallback and never throttled.
|
|
118
|
+
def apply_rate_limits(options)
|
|
119
|
+
return unless @enable_rate_limiting
|
|
120
|
+
|
|
121
|
+
@otto_instance.configure_rate_limiting(
|
|
122
|
+
mcp_http_endpoint: @http_endpoint,
|
|
123
|
+
mcp_requests_per_minute: options[:requests_per_minute],
|
|
124
|
+
tool_calls_per_minute: options[:tools_per_minute]
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def warn_if_unauthenticated!
|
|
129
|
+
return if @auth_tokens.any? || @allow_unauthenticated
|
|
130
|
+
|
|
131
|
+
Otto.logger.warn format(UNAUTHENTICATED_WARNING, @http_endpoint)
|
|
132
|
+
end
|
|
133
|
+
|
|
56
134
|
def configure_middleware(_options)
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
135
|
+
# Target EXECUTION order (outermost first, i.e. what a request meets in
|
|
136
|
+
# turn), security-optimal:
|
|
137
|
+
# 1. Rate limiting — shed excessive load before spending any work
|
|
138
|
+
# 2. Authentication — reject anonymous callers before parsing bodies
|
|
139
|
+
# 3. Validation — expensive JSON schema check only on requests
|
|
140
|
+
# that already proved themselves
|
|
141
|
+
#
|
|
142
|
+
# MiddlewareStack stores entries in the REVERSE of execution order
|
|
143
|
+
# (#wrap folds with reduce, so a later array entry is a further-out
|
|
144
|
+
# wrapper). Registration therefore runs innermost-first: validation is
|
|
145
|
+
# pinned :innermost, then auth is appended, then rate limiting. The
|
|
146
|
+
# previous code read the positions as execution order and produced the
|
|
147
|
+
# exact inverse — validation ran ahead of auth, so unauthenticated
|
|
148
|
+
# callers reached the schema validator.
|
|
61
149
|
|
|
62
150
|
middleware = @otto_instance.instance_variable_get(:@middleware)
|
|
63
151
|
|
|
64
|
-
#
|
|
65
|
-
if @
|
|
152
|
+
# Innermost (last to execute): schema validation, closest to the app.
|
|
153
|
+
if @enable_validation
|
|
66
154
|
middleware.add_with_position(
|
|
67
|
-
Otto::MCP::
|
|
68
|
-
|
|
69
|
-
position: :first
|
|
155
|
+
Otto::MCP::SchemaValidationMiddleware,
|
|
156
|
+
position: :innermost
|
|
70
157
|
)
|
|
71
|
-
Otto.logger.debug '[MCP]
|
|
158
|
+
Otto.logger.debug '[MCP] Schema validation enabled (executes last)' if Otto.debug
|
|
72
159
|
end
|
|
73
160
|
|
|
74
|
-
#
|
|
161
|
+
# Middle: authentication, outside validation and inside rate limiting.
|
|
75
162
|
if @auth_tokens.any?
|
|
76
163
|
@auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
|
|
77
164
|
@otto_instance.security_config.mcp_auth = @auth
|
|
78
|
-
|
|
79
|
-
|
|
165
|
+
# Pass security_config explicitly: TokenMiddleware is not in
|
|
166
|
+
# MiddlewareStack#middleware_needs_config?, so #wrap would build it
|
|
167
|
+
# with a nil config and — now that the middleware fails closed —
|
|
168
|
+
# reject every request, valid token included.
|
|
169
|
+
@otto_instance.use Otto::MCP::Auth::TokenMiddleware, @otto_instance.security_config
|
|
170
|
+
Otto.logger.debug '[MCP] Token authentication enabled (executes after rate limiting)' if Otto.debug
|
|
80
171
|
end
|
|
81
172
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
173
|
+
# Outermost of the three (first to execute): rate limiting.
|
|
174
|
+
if @enable_rate_limiting
|
|
175
|
+
middleware.add_with_position(
|
|
176
|
+
Otto::MCP::RateLimitMiddleware,
|
|
177
|
+
@otto_instance.security_config,
|
|
178
|
+
position: :last
|
|
179
|
+
)
|
|
180
|
+
Otto.logger.debug '[MCP] Rate limiting enabled (executes first)' if Otto.debug
|
|
181
|
+
end
|
|
90
182
|
|
|
91
|
-
# Validate
|
|
183
|
+
# Validate execution order (should pass with the positioning above).
|
|
92
184
|
warnings = middleware.validate_mcp_middleware_order
|
|
93
185
|
warnings.each { |warning| Otto.logger.warn warning }
|
|
94
186
|
end
|
|
@@ -102,13 +194,20 @@ class Otto
|
|
|
102
194
|
@otto_instance.routes[:POST] ||= []
|
|
103
195
|
@otto_instance.routes[:POST] << mcp_route
|
|
104
196
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
197
|
+
# Keyed by the normalized path, as the router keys every literal route
|
|
198
|
+
# and normalizes PATH_INFO before the lookup: a configured trailing
|
|
199
|
+
# slash ('/a/') or the root ('/') otherwise registers a key the lookup
|
|
200
|
+
# can never hit, while Otto::MCP.endpoint_path? still claims the path.
|
|
201
|
+
@otto_instance.routes_literal[:POST] ||= {}
|
|
202
|
+
@otto_instance.routes_literal[:POST][Otto::Utils.normalize_path(@http_endpoint)] = mcp_route
|
|
203
|
+
|
|
204
|
+
# Ensure env carries endpoint for middlewares. Close over a local copy:
|
|
205
|
+
# the proc must keep announcing the endpoint it was registered for even
|
|
206
|
+
# if the ivar is ever reassigned.
|
|
207
|
+
endpoint = @http_endpoint
|
|
109
208
|
@otto_instance.use proc { |app|
|
|
110
209
|
lambda { |env|
|
|
111
|
-
env['otto.mcp_http_endpoint'] =
|
|
210
|
+
env['otto.mcp_http_endpoint'] = endpoint
|
|
112
211
|
app.call(env)
|
|
113
212
|
}
|
|
114
213
|
}
|