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,41 @@
1
+ # lib/otto/mcp/endpoint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../utils'
6
+
7
+ class Otto
8
+ # Model Context Protocol support.
9
+ module MCP
10
+ # Whether +path+ is the MCP endpoint +endpoint+: the one request the router
11
+ # dispatches to the MCP handler, and nothing else.
12
+ #
13
+ # The MCP route is a literal route. The router normalizes PATH_INFO with
14
+ # Otto::Utils.normalize_path and looks it up by exact equality, so it never
15
+ # dispatches a sibling path to the MCP handler. The throttles, the throttled
16
+ # responder, the log subscriber, the token middleware and the schema
17
+ # validator all used to classify by prefix (start_with?) instead: with MCP
18
+ # mounted at /a, every /admin request was counted by 'mcp_requests:/a' and
19
+ # could be answered with an MCP JSON-RPC 429, or a 401, for a path the MCP
20
+ # handler can never receive; with MCP at / that was every route in the app.
21
+ #
22
+ # Both sides go through the router's normalize_path, as LocalhostGuard does
23
+ # for the Caddy endpoint, so a trailing slash or percent-encoding on either
24
+ # side cannot make these guards and the router disagree about a request.
25
+ #
26
+ # Callers must pass PATH_INFO (Rack::Request#path_info), never
27
+ # Rack::Request#path, which prepends SCRIPT_NAME. The router matches on
28
+ # PATH_INFO alone, so when the host app mounts Otto under a prefix
29
+ # (`map '/api' { run otto }`) the endpoint /_mcp is dispatched for
30
+ # PATH_INFO=/_mcp while #path reads /api/_mcp and never matches.
31
+ #
32
+ # @param path [String, nil] raw PATH_INFO of the request
33
+ # @param endpoint [String, nil] configured MCP endpoint
34
+ # @return [Boolean]
35
+ def self.endpoint_path?(path, endpoint)
36
+ return false if endpoint.nil?
37
+
38
+ Otto::Utils.normalize_path(path) == Otto::Utils.normalize_path(endpoint)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ # lib/otto/mcp/errors.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../errors'
6
+
7
+ class Otto
8
+ module MCP
9
+ # Raised by {Otto::MCP::Registry#call_tool} when the requested tool name is
10
+ # not registered. Distinct from a tool that exists but fails during
11
+ # execution: the former is a named-entity lookup failure (JSON-RPC -32002,
12
+ # HTTP 404), the latter an execution fault (-32603, HTTP 500).
13
+ class ToolNotFoundError < Otto::NotFoundError; end
14
+ end
15
+ end
@@ -0,0 +1,292 @@
1
+ # lib/otto/mcp/options.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module MCP
7
+ # Normalization of MCP configuration options.
8
+ #
9
+ # Otto.new and Otto#enable_mcp! historically accepted different spellings
10
+ # of the same settings and the constructor dropped all but the endpoint
11
+ # (issue #258). Every path now funnels through {.normalize}.
12
+ #
13
+ # One vocabulary: the canonical keys, their +mcp_+-prefixed variants (the
14
+ # constructor needs a namespace inside an options hash that also
15
+ # configures the rest of Otto), and +tool_calls_per_minute+, which is the
16
+ # name the rate-limiting middleware itself uses. Bare generic names such
17
+ # as +endpoint+, +validation+ and +rate_limiting+ are NOT accepted:
18
+ # +rate_limiting:+ is Otto's own general rate-limiting option (a Hash),
19
+ # and the others were documented before #258 but never read.
20
+ #
21
+ # Two strictness rules, selected with the +scope+ argument:
22
+ #
23
+ # +:constructor+ (used by Otto.new / #configure_mcp)
24
+ # The constructor forwards its ENTIRE options hash here, most of which
25
+ # configures things other than MCP, so unrecognized keys are ignored.
26
+ # Unknown +mcp_+-prefixed keys still fail loud, since such a key can
27
+ # only have been meant for MCP.
28
+ #
29
+ # +:explicit+ (used by Otto#enable_mcp!)
30
+ # The caller is configuring MCP and nothing else, so any unrecognized
31
+ # key raises. That turns +enable_mcp!(auth_token: 'x')+ — a
32
+ # singular-vs-plural typo that silently left the endpoint open — into a
33
+ # boot failure.
34
+ #
35
+ # The +mcp_+-prefixed gating keys (+mcp_enabled+, +mcp_http+, +mcp_stdio+)
36
+ # decide *whether* MCP is enabled and are read by the constructor itself
37
+ # (Otto::Core::Configuration#configure_mcp) through {.gating_options}. The
38
+ # +:constructor+ scope tolerates them; the +:explicit+ scope rejects them,
39
+ # because +enable_mcp!(mcp_http: false)+ would otherwise be accepted and
40
+ # still mount the endpoint. Their values must be exactly +true+ or
41
+ # +false+: the constructor disables the endpoint on +mcp_http == false+,
42
+ # so a String +"false"+ (from +ENV.fetch+ or YAML) or +nil+ (from an
43
+ # unset +ENV[...]+) would otherwise mount it.
44
+ #
45
+ # Keys may be Strings or Symbols; they are symbolized before anything is
46
+ # read, so +"auth_tokens" => [...]+ configures authentication exactly like
47
+ # +auth_tokens:+. A String key and its Symbol twin are two spellings of one
48
+ # option and conflict when their values differ.
49
+ #
50
+ # Both scopes accept the canonical output of {.normalize}, so normalization
51
+ # is idempotent under either.
52
+ module Options
53
+ # Canonical MCP option keys and every accepted alias.
54
+ # @api private
55
+ OPTION_ALIASES = {
56
+ http_endpoint: %i[http_endpoint mcp_endpoint],
57
+ auth_tokens: %i[auth_tokens mcp_auth_tokens],
58
+ enable_validation: %i[enable_validation mcp_validation],
59
+ enable_rate_limiting: %i[enable_rate_limiting mcp_rate_limiting],
60
+ requests_per_minute: %i[requests_per_minute mcp_requests_per_minute],
61
+ tools_per_minute: %i[tools_per_minute tool_calls_per_minute mcp_tool_calls_per_minute],
62
+ allow_unauthenticated: %i[allow_unauthenticated mcp_allow_unauthenticated],
63
+ }.freeze
64
+
65
+ # @api private
66
+ SCOPES = %i[constructor explicit].freeze
67
+
68
+ # Canonical defaults applied when no alias supplies a value.
69
+ # @api private
70
+ OPTION_DEFAULTS = {
71
+ http_endpoint: '/_mcp',
72
+ auth_tokens: [].freeze,
73
+ enable_validation: true,
74
+ enable_rate_limiting: true,
75
+ requests_per_minute: 60,
76
+ tools_per_minute: 20,
77
+ allow_unauthenticated: false,
78
+ }.freeze
79
+
80
+ # +mcp_+-prefixed constructor keys that gate *whether* MCP is enabled
81
+ # rather than configure the server. Otto.new reads them itself, so the
82
+ # :constructor scope tolerates them (and never emits them). #enable_mcp!
83
+ # cannot honour them, so the :explicit scope rejects them.
84
+ # @api private
85
+ GATING_KEYS = %i[mcp_enabled mcp_http mcp_stdio].freeze
86
+
87
+ # Every key {.normalize} recognizes, per scope.
88
+ # @api private
89
+ RECOGNIZED_KEYS = {
90
+ constructor: (OPTION_ALIASES.values.flatten + GATING_KEYS).freeze,
91
+ explicit: OPTION_ALIASES.values.flatten.freeze,
92
+ }.freeze
93
+
94
+ # Normalize a constructor- or #enable_mcp!-style option hash into the
95
+ # single canonical shape consumed by {Otto::MCP::Server#enable!}.
96
+ #
97
+ # +scope+ is positional, not a keyword, so a brace-less hash at the call
98
+ # site (+normalize(auth_tokens: ['t'])+) binds to +opts+ as intended.
99
+ #
100
+ # @param opts [Hash] raw options; String and Symbol keys are equivalent
101
+ # @param scope [Symbol] :explicit (strict; the default) or :constructor
102
+ # (permissive about non-MCP keys)
103
+ # @return [Hash] canonical hash with keys :http_endpoint, :auth_tokens,
104
+ # :enable_validation, :enable_rate_limiting, :requests_per_minute,
105
+ # :tools_per_minute, :allow_unauthenticated
106
+ # @raise [ArgumentError] on an unrecognized key, conflicting aliases or
107
+ # String/Symbol spellings, values of the wrong type, or auth tokens
108
+ # supplied but empty/blank
109
+ def self.normalize(opts = {}, scope = :explicit)
110
+ raise ArgumentError, "Unknown MCP option scope #{scope.inspect}; expected one of #{SCOPES.inspect}" unless SCOPES.include?(scope)
111
+
112
+ opts = symbolize_keys(opts.to_h)
113
+ reject_unrecognized_keys!(opts, scope)
114
+
115
+ canonical = OPTION_DEFAULTS.dup
116
+ OPTION_ALIASES.each do |key, key_aliases|
117
+ supplied = key_aliases.select { |a| opts.key?(a) }
118
+ next if supplied.empty?
119
+
120
+ values = supplied.map { |a| opts[a] }
121
+ raise_conflict!(key, supplied.map { |a| [a, opts[a]] }) if values.uniq.size > 1
122
+
123
+ canonical[key] = coerce_option(key, values.first)
124
+ end
125
+
126
+ canonical
127
+ end
128
+
129
+ # Read the constructor-only gating keys (+mcp_enabled+, +mcp_http+,
130
+ # +mcp_stdio+) with the same String/Symbol tolerance as {.normalize}.
131
+ #
132
+ # Otto.new decides *whether* to enable MCP from these before it
133
+ # normalizes the rest, and it used to read them as raw Symbol keys, so
134
+ # +Otto.new(nil, "mcp_enabled" => true)+ silently did nothing while the
135
+ # String-keyed +"auth_tokens"+ beside it was documented as accepted.
136
+ #
137
+ # Every gating value present must be exactly +true+ or +false+.
138
+ # #configure_mcp disables the HTTP endpoint only on +mcp_http == false+,
139
+ # so +mcp_http: ENV.fetch('MCP_HTTP', 'false')+ (a String, truthy) or
140
+ # +mcp_http: ENV['MCP_HTTP']+ with the variable unset (+nil+, not
141
+ # +false+) would each mount the endpoint the caller meant to disable.
142
+ # An explicit +nil+ is rejected rather than treated as absent for that
143
+ # reason, matching the other boolean options and +auth_tokens: nil+.
144
+ #
145
+ # @param opts [Hash, nil] raw constructor options
146
+ # @return [Hash{Symbol=>Boolean}] the gating keys present in +opts+
147
+ # @raise [ArgumentError] when a String key and its Symbol twin disagree,
148
+ # or when a gating value is anything but +true+ or +false+ (including
149
+ # the Strings +"true"+ / +"false"+, Integers and +nil+)
150
+ def self.gating_options(opts)
151
+ gating = symbolize_keys(opts.to_h).slice(*GATING_KEYS)
152
+ gating.each { |key, value| coerce_boolean!(key, value) }
153
+ gating
154
+ end
155
+
156
+ # Symbolize option keys once, so String-keyed options configure the
157
+ # server instead of passing the unrecognized-key guard (which already
158
+ # symbolized) and then being ignored by normalization — which is how
159
+ # +enable_mcp!("auth_tokens" => [...])+ served the endpoint without
160
+ # authentication.
161
+ #
162
+ # A String key and its Symbol twin are two spellings of one option: equal
163
+ # values collapse, differing values conflict. Keys that cannot be
164
+ # symbolized are kept as-is for the unrecognized-key guard.
165
+ # @api private
166
+ def self.symbolize_keys(opts)
167
+ seen = {} # symbolized key => [original spelling, value]
168
+ opts.each do |key, value|
169
+ sym = key.respond_to?(:to_sym) ? key.to_sym : key
170
+ raise_conflict!(sym, [seen[sym], [key, value]]) if seen.key?(sym) && seen[sym].last != value
171
+
172
+ seen[sym] = [key, value]
173
+ end
174
+
175
+ seen.transform_values(&:last)
176
+ end
177
+ private_class_method :symbolize_keys
178
+
179
+ # @api private
180
+ def self.raise_conflict!(key, spellings)
181
+ raise ArgumentError,
182
+ "Conflicting MCP options for #{key}: " \
183
+ "#{spellings.map { |spelling, value| "#{spelling.inspect}=#{value.inspect}" }.join(', ')}"
184
+ end
185
+ private_class_method :raise_conflict!
186
+
187
+ # Reject keys the given scope cannot accept.
188
+ #
189
+ # :explicit rejects anything unrecognized, including the constructor-only
190
+ # gating keys, which it explains by name. :constructor rejects only
191
+ # unrecognized +mcp_+-prefixed keys, because it is handed Otto's whole
192
+ # options hash and most keys legitimately belong to other subsystems.
193
+ # @api private
194
+ def self.reject_unrecognized_keys!(opts, scope)
195
+ recognized = RECOGNIZED_KEYS.fetch(scope)
196
+ unknown = opts.keys.reject { |k| recognized.include?(k) }
197
+ unknown.select! { |k| k.to_s.start_with?('mcp_') } if scope == :constructor
198
+ return if unknown.empty?
199
+
200
+ message = "Unknown MCP option(s): #{unknown.map(&:inspect).join(', ')}. "
201
+ gating = unknown & GATING_KEYS
202
+ unless gating.empty?
203
+ message += "#{gating.map(&:inspect).join(', ')} gate whether MCP is enabled and are " \
204
+ 'constructor-only: pass them to Otto.new. #enable_mcp! always enables the ' \
205
+ 'HTTP endpoint, so it cannot honour them. '
206
+ end
207
+
208
+ raise ArgumentError,
209
+ message + "Recognized MCP options (#{scope}): #{recognized.map(&:inspect).join(', ')}"
210
+ end
211
+ private_class_method :reject_unrecognized_keys!
212
+
213
+ # @api private
214
+ def self.coerce_option(key, value)
215
+ case key
216
+ when :auth_tokens
217
+ coerce_auth_tokens!(value)
218
+ when :enable_validation, :enable_rate_limiting, :allow_unauthenticated
219
+ coerce_boolean!(key, value)
220
+ when :requests_per_minute, :tools_per_minute
221
+ unless value.is_a?(Integer) && value.positive?
222
+ raise ArgumentError,
223
+ "MCP #{key} must be a positive Integer, got #{value.inspect}"
224
+ end
225
+
226
+ value
227
+ when :http_endpoint
228
+ unless value.is_a?(String) && value.start_with?('/')
229
+ raise ArgumentError,
230
+ "MCP http_endpoint must be a String path starting with '/', got #{value.inspect}"
231
+ end
232
+
233
+ value
234
+ else
235
+ value
236
+ end
237
+ end
238
+ private_class_method :coerce_option
239
+
240
+ # Coerce and validate the configured bearer tokens.
241
+ #
242
+ # A literal empty Array is the one accepted "no tokens" spelling: it is
243
+ # unambiguous, and it is what {.normalize} itself emits when the key is
244
+ # omitted, which keeps normalization idempotent. Every other empty shape
245
+ # (+nil+, +''+, +[nil]+, +['']+) raises, because those are what
246
+ # +auth_tokens: ENV['MCP_TOKEN']+ produces when the variable is unset —
247
+ # silently serving the endpoint to anyone.
248
+ # @api private
249
+ def self.coerce_auth_tokens!(value)
250
+ return [] if value.is_a?(Array) && value.empty?
251
+
252
+ tokens = value.is_a?(String) ? [value] : Array(value)
253
+ invalid = tokens.grep_v(String)
254
+ unless invalid.empty?
255
+ raise ArgumentError,
256
+ "MCP auth_tokens must be Strings, got #{invalid.map(&:class).uniq.join(', ')}"
257
+ end
258
+
259
+ raise_empty_auth_tokens!(value) if tokens.empty?
260
+
261
+ blank = tokens.select { |token| token.strip.empty? }
262
+ unless blank.empty?
263
+ raise ArgumentError,
264
+ "MCP auth_tokens must not be blank, got #{blank.map(&:inspect).join(', ')}. " \
265
+ 'A blank token cannot be presented by a client and would not protect the endpoint.'
266
+ end
267
+
268
+ tokens
269
+ end
270
+ private_class_method :coerce_auth_tokens!
271
+
272
+ # @api private
273
+ def self.raise_empty_auth_tokens!(value)
274
+ raise ArgumentError,
275
+ "MCP auth_tokens was supplied as #{value.inspect} but resolves to no tokens. " \
276
+ "This is what auth_tokens: ENV['MCP_TOKEN'] does when the variable is unset, " \
277
+ 'and it would expose the MCP endpoint to any caller. Supply at least one token, ' \
278
+ 'or omit auth_tokens entirely and pass allow_unauthenticated: true to serve the ' \
279
+ 'endpoint without authentication on purpose.'
280
+ end
281
+ private_class_method :raise_empty_auth_tokens!
282
+
283
+ # @api private
284
+ def self.coerce_boolean!(key, value)
285
+ return value if [true, false].include?(value)
286
+
287
+ raise ArgumentError, "MCP #{key} must be true or false, got #{value.inspect}"
288
+ end
289
+ private_class_method :coerce_boolean!
290
+ end
291
+ end
292
+ end
@@ -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
- resource = @registry.read_resource(uri)
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', e.message)
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 appropriate HTTP status codes
142
- http_status = case code
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