otto 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -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 +157 -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 +293 -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 +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- 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/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- 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
data/lib/otto/mcp/protocol.rb
CHANGED
|
@@ -4,11 +4,47 @@
|
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require_relative 'registry'
|
|
7
|
+
require_relative 'errors'
|
|
7
8
|
|
|
8
9
|
class Otto
|
|
9
10
|
module MCP
|
|
10
|
-
# MCP protocol handler providing Model Context Protocol functionality
|
|
11
|
+
# MCP protocol handler providing Model Context Protocol functionality.
|
|
12
|
+
#
|
|
13
|
+
# == JSON-RPC error code to HTTP status mapping
|
|
14
|
+
#
|
|
15
|
+
# Code Meaning HTTP
|
|
16
|
+
# -32700 Parse error 400
|
|
17
|
+
# -32600 Invalid Request 400
|
|
18
|
+
# -32601 Method not found 400
|
|
19
|
+
# -32602 Invalid params 400
|
|
20
|
+
# -32001 Resource not found 404
|
|
21
|
+
# -32002 Tool not found 404
|
|
22
|
+
# -32603 Internal error 500
|
|
23
|
+
# -32099..-32000 server errors 500
|
|
24
|
+
#
|
|
25
|
+
# Protocol-level faults (a malformed or unsupported payload against a valid
|
|
26
|
+
# endpoint) are 400, including method-not-found: 404 there would conflate a
|
|
27
|
+
# bad payload with "no such route", which the MCP endpoint handler already
|
|
28
|
+
# returns when MCP is disabled. 404 is reserved for a well-formed request
|
|
29
|
+
# naming an entity that is not registered, and 500 for handler execution
|
|
30
|
+
# faults. Handler exception messages are logged, never returned: -32603
|
|
31
|
+
# responses carry a fixed message.
|
|
11
32
|
class Protocol
|
|
33
|
+
# @see Protocol for the rationale behind this mapping.
|
|
34
|
+
ERROR_STATUS_MAP = {
|
|
35
|
+
-32_700 => 400, # Parse error
|
|
36
|
+
-32_600 => 400, # Invalid Request
|
|
37
|
+
-32_601 => 400, # Method not found
|
|
38
|
+
-32_602 => 400, # Invalid params
|
|
39
|
+
-32_001 => 404, # Resource not found
|
|
40
|
+
-32_002 => 404, # Tool not found
|
|
41
|
+
-32_603 => 500, # Internal error
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Implementation-defined server error range; anything unmapped inside it
|
|
45
|
+
# is an execution fault.
|
|
46
|
+
SERVER_ERROR_RANGE = (-32_099..-32_000)
|
|
47
|
+
|
|
12
48
|
attr_reader :registry
|
|
13
49
|
|
|
14
50
|
def initialize(otto_instance)
|
|
@@ -89,7 +125,15 @@ class Otto
|
|
|
89
125
|
|
|
90
126
|
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
|
|
91
127
|
|
|
92
|
-
|
|
128
|
+
begin
|
|
129
|
+
resource = @registry.read_resource(uri)
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
# Detail stays in the log: handler exceptions (Errno::*, constant
|
|
132
|
+
# resolution) can carry absolute paths and internals.
|
|
133
|
+
Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.class}: #{e.message}"
|
|
134
|
+
return error_response(data['id'], -32_603, 'Internal error', 'Resource read failed')
|
|
135
|
+
end
|
|
136
|
+
|
|
93
137
|
if resource
|
|
94
138
|
success_response(data['id'], resource)
|
|
95
139
|
else
|
|
@@ -112,9 +156,11 @@ class Otto
|
|
|
112
156
|
begin
|
|
113
157
|
result = @registry.call_tool(name, arguments, env)
|
|
114
158
|
success_response(data['id'], result)
|
|
159
|
+
rescue Otto::MCP::ToolNotFoundError => e
|
|
160
|
+
error_response(data['id'], -32_002, 'Tool not found', e.message)
|
|
115
161
|
rescue StandardError => e
|
|
116
|
-
Otto.logger.error "[MCP] Tool call error: #{e.message}"
|
|
117
|
-
error_response(data['id'], -32_603, 'Internal error',
|
|
162
|
+
Otto.logger.error "[MCP] Tool call error for #{name}: #{e.class}: #{e.message}"
|
|
163
|
+
error_response(data['id'], -32_603, 'Internal error', 'Tool execution failed')
|
|
118
164
|
end
|
|
119
165
|
end
|
|
120
166
|
|
|
@@ -138,24 +184,8 @@ class Otto
|
|
|
138
184
|
error: error,
|
|
139
185
|
})
|
|
140
186
|
|
|
141
|
-
# Map JSON-RPC error codes to
|
|
142
|
-
http_status =
|
|
143
|
-
when -32_700..-32_600 # Parse error, Invalid Request, Method not found
|
|
144
|
-
400
|
|
145
|
-
when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
|
|
146
|
-
500
|
|
147
|
-
when -32_001 # Resource not found
|
|
148
|
-
404
|
|
149
|
-
when -32_002 # Tool not found
|
|
150
|
-
404
|
|
151
|
-
when -32_601 # Method not found
|
|
152
|
-
404
|
|
153
|
-
when -32_602 # Invalid params
|
|
154
|
-
400
|
|
155
|
-
else
|
|
156
|
-
# Default client error for unknown non-server codes; treat server-range as 500
|
|
157
|
-
(-32_099..-32_000).cover?(code) ? 500 : 400
|
|
158
|
-
end
|
|
187
|
+
# Map JSON-RPC error codes to HTTP status codes (see ERROR_STATUS_MAP).
|
|
188
|
+
http_status = ERROR_STATUS_MAP[code] || (SERVER_ERROR_RANGE.cover?(code) ? 500 : 400)
|
|
159
189
|
|
|
160
190
|
[http_status, { 'content-type' => 'application/json' }, [body]]
|
|
161
191
|
end
|
|
@@ -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)
|