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,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
|
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
|