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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# lib/otto/core/static_mounts.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'rack/files'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# Explicit static-file registration (issue #267).
|
|
10
|
+
#
|
|
11
|
+
# A static mount binds a URL prefix to one directory on disk:
|
|
12
|
+
#
|
|
13
|
+
# otto.mount_static('/assets', root: 'public/assets')
|
|
14
|
+
#
|
|
15
|
+
# Requests for GET /assets/<rest> are then resolved against
|
|
16
|
+
# public/assets/<rest> using the same containment policy as the implicit
|
|
17
|
+
# +public:+ directory (Otto::Core::FileSafety): the candidate is
|
|
18
|
+
# canonicalized with File.realpath and must land inside the mount's
|
|
19
|
+
# canonical root, be a regular readable file, and be owned by the process
|
|
20
|
+
# user or group. A mount never authorizes anything outside its own root,
|
|
21
|
+
# so several mounts can point into unrelated directories without exposing
|
|
22
|
+
# their parents or siblings.
|
|
23
|
+
#
|
|
24
|
+
# Registration is a boot-time operation. The root is canonicalized once,
|
|
25
|
+
# when the mount is registered, and every failure mode (missing,
|
|
26
|
+
# unreadable, not a directory, escaping symlink, malformed prefix,
|
|
27
|
+
# duplicate prefix) raises ArgumentError immediately so a misconfigured
|
|
28
|
+
# application does not start. Mounts participate in configuration
|
|
29
|
+
# freezing: mount_static raises FrozenError after freeze_configuration!,
|
|
30
|
+
# and the mount table is an immutable, sorted snapshot that dispatch reads
|
|
31
|
+
# without any per-request mutation.
|
|
32
|
+
#
|
|
33
|
+
# Dispatch precedence is fixed: literal routes, then static mounts (longest
|
|
34
|
+
# prefix first), then the implicit +public:+ directory, then dynamic
|
|
35
|
+
# routes. A mount claims files, not the prefix: when no mount root
|
|
36
|
+
# contains the requested file the request falls through to the next
|
|
37
|
+
# stage exactly as an unregistered path would. See Otto::Core::Router.
|
|
38
|
+
module StaticMounts
|
|
39
|
+
# One registered mount. +prefix+ is the normalized URL prefix ('' for a
|
|
40
|
+
# root mount), +root+ the canonical directory, +files+ the Rack::Files
|
|
41
|
+
# instance rooted there. Instances are frozen at construction.
|
|
42
|
+
StaticMount = Struct.new(:prefix, :root, :files) do
|
|
43
|
+
# Request-relative path under this mount, or nil when +path+ is not
|
|
44
|
+
# beneath the prefix. The prefix itself (the directory) never matches:
|
|
45
|
+
# a mount serves files, not directory listings.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String] normalized dispatch path (leading '/')
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def relative_path_for(path)
|
|
50
|
+
if prefix.empty?
|
|
51
|
+
path
|
|
52
|
+
elsif path.start_with?("#{prefix}/")
|
|
53
|
+
path[(prefix.length + 1)..]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Prefix as an operator would write it ('/' for a root mount).
|
|
58
|
+
def display_prefix
|
|
59
|
+
prefix.empty? ? '/' : prefix
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registered mounts, longest prefix first. Frozen snapshot; a new array
|
|
64
|
+
# replaces it on every registration so readers never observe a partial
|
|
65
|
+
# update.
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<StaticMount>]
|
|
68
|
+
def static_mounts
|
|
69
|
+
@static_mounts
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Serve the files under +root+ at URLs beneath +prefix+.
|
|
73
|
+
#
|
|
74
|
+
# @param prefix [String] URL prefix starting with '/'. A trailing slash
|
|
75
|
+
# is ignored; '/' mounts the root at the top level.
|
|
76
|
+
# @param root [String] directory path; relative paths resolve against
|
|
77
|
+
# the process working directory and are canonicalized immediately.
|
|
78
|
+
# @return [StaticMount] the registered mount
|
|
79
|
+
# @raise [ArgumentError] on a malformed prefix, a duplicate prefix, or a
|
|
80
|
+
# root that is missing, unreadable, not a directory, not owned by the
|
|
81
|
+
# process user or group, or that cannot be canonicalized.
|
|
82
|
+
# @raise [FrozenError] after configuration freezing
|
|
83
|
+
def mount_static(prefix, root:)
|
|
84
|
+
ensure_not_frozen!
|
|
85
|
+
|
|
86
|
+
clean_prefix = normalize_mount_prefix(prefix)
|
|
87
|
+
if @static_mounts.any? { |mount| mount.prefix == clean_prefix }
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"Static mount prefix #{display_mount_prefix(clean_prefix).inspect} is already registered"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
canonical_root = canonicalize_mount_root(clean_prefix, root)
|
|
93
|
+
mount = StaticMount.new(clean_prefix, canonical_root, Rack::Files.new(canonical_root).freeze).freeze
|
|
94
|
+
|
|
95
|
+
# Longest prefix first so an overlay ('/assets/vendor') is consulted
|
|
96
|
+
# before the mount that contains it ('/assets'). Ties cannot happen:
|
|
97
|
+
# prefixes are unique. Rebuild rather than mutate so in-flight readers
|
|
98
|
+
# keep their snapshot.
|
|
99
|
+
@static_mounts = (@static_mounts + [mount]).sort_by { |m| -m.prefix.length }.freeze
|
|
100
|
+
|
|
101
|
+
Otto.structured_log(:debug, 'Static mount registered',
|
|
102
|
+
{ prefix: mount.display_prefix, root: mount.root })
|
|
103
|
+
mount
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Resolve +path+ through the registered mounts, longest prefix first.
|
|
109
|
+
# Read-only: safe to call from concurrent request threads.
|
|
110
|
+
#
|
|
111
|
+
# @param path [String] normalized dispatch path (leading '/')
|
|
112
|
+
# @return [Array(StaticMount, Otto::Core::FileSafety::StaticFile), nil]
|
|
113
|
+
def resolve_mounted_file(path)
|
|
114
|
+
@static_mounts.each do |mount|
|
|
115
|
+
relative = mount.relative_path_for(path)
|
|
116
|
+
next if relative.nil?
|
|
117
|
+
|
|
118
|
+
static_file = resolve_file_under(mount.root, relative)
|
|
119
|
+
return [mount, static_file] if static_file
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Validate and normalize a mount prefix. Returns '' for the root mount
|
|
125
|
+
# and a leading-slash, no-trailing-slash prefix otherwise, matching the
|
|
126
|
+
# normalized request path the router compares against.
|
|
127
|
+
def normalize_mount_prefix(prefix)
|
|
128
|
+
raise ArgumentError, "Static mount prefix must be a String, got #{prefix.class}" unless prefix.is_a?(String)
|
|
129
|
+
raise ArgumentError, "Static mount prefix #{prefix.inspect} contains a NUL byte" if prefix.include?("\0")
|
|
130
|
+
raise ArgumentError, "Static mount prefix #{prefix.inspect} must start with '/'" unless prefix.start_with?('/')
|
|
131
|
+
|
|
132
|
+
clean = prefix.sub(%r{/+\z}, '')
|
|
133
|
+
return '' if clean.empty?
|
|
134
|
+
|
|
135
|
+
segments = clean.split('/', -1).drop(1)
|
|
136
|
+
if segments.any? { |segment| segment.empty? || segment == '.' || segment == '..' }
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Static mount prefix #{prefix.inspect} must not contain empty, '.', or '..' segments"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
clean.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Canonicalize a mount root under Otto's static-file safety policy and
|
|
145
|
+
# fail loudly on anything that could not be served safely.
|
|
146
|
+
def canonicalize_mount_root(prefix, root)
|
|
147
|
+
label = "Static mount #{display_mount_prefix(prefix).inspect}"
|
|
148
|
+
raise ArgumentError, "#{label} root must be a String, got #{root.class}" unless root.is_a?(String)
|
|
149
|
+
raise ArgumentError, "#{label} root must not be empty" if root.strip.empty?
|
|
150
|
+
raise ArgumentError, "#{label} root #{root.inspect} contains a NUL byte" if root.include?("\0")
|
|
151
|
+
|
|
152
|
+
real = safe_realpath(File.expand_path(root))
|
|
153
|
+
if real.nil?
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"#{label} root #{root.inspect} cannot be resolved " \
|
|
156
|
+
'(missing, unreadable component, or symlink loop)'
|
|
157
|
+
end
|
|
158
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not a directory" unless File.directory?(real)
|
|
159
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not readable" unless File.readable?(real)
|
|
160
|
+
|
|
161
|
+
owned = File.owned?(real) || File.grpowned?(real)
|
|
162
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not owned by the process user or group" unless owned
|
|
163
|
+
|
|
164
|
+
real.freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def display_mount_prefix(prefix)
|
|
168
|
+
prefix.empty? ? '/' : prefix
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/otto/core.rb
CHANGED
data/lib/otto/env_keys.rb
CHANGED
|
@@ -100,13 +100,16 @@ class Otto
|
|
|
100
100
|
# Type: Boolean when present; the key may be ABSENT.
|
|
101
101
|
# Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
|
|
102
102
|
# REMOTE_ADDR is masked — but ONLY when proxy trust is configured
|
|
103
|
-
# (Security::Config#proxy_trust_configured?: CIDR matchers
|
|
103
|
+
# (Security::Config#proxy_trust_configured?: CIDR matchers, a depth, or
|
|
104
|
+
# the explicit trust-nobody assertion `trusted_proxies: :none`).
|
|
104
105
|
# True when the peer matches a configured trusted_proxies CIDR (filter
|
|
105
106
|
# mode), or unconditionally when count-based depth mode is active
|
|
106
107
|
# (trusted_proxy_depth >= 1) — the modes are mutually exclusive, and
|
|
107
108
|
# configuring a depth is the operator's assertion that the connecting
|
|
108
109
|
# peer is their proxy tier (#226). False means trust IS configured and
|
|
109
|
-
# this peer failed it — an authoritative deny
|
|
110
|
+
# this peer failed it — an authoritative deny; it is also false for
|
|
111
|
+
# EVERY peer (loopback included) when the operator asserted
|
|
112
|
+
# `trusted_proxies: :none` (#259). When no proxy trust is
|
|
110
113
|
# configured the key is NOT written, so consumers can distinguish
|
|
111
114
|
# "denied" from "unconfigured" and apply legacy heuristics only in the
|
|
112
115
|
# latter case.
|
|
@@ -129,6 +132,21 @@ class Otto
|
|
|
129
132
|
# in a localhost trust decision.
|
|
130
133
|
PEER_LOOPBACK = 'otto.peer_loopback'
|
|
131
134
|
|
|
135
|
+
# Whether the request arrived relayed by a proxy (any relay marker header
|
|
136
|
+
# present).
|
|
137
|
+
# Type: Boolean
|
|
138
|
+
# Set by: IPPrivacyMiddleware (every request), evaluated on the ORIGINAL
|
|
139
|
+
# headers BEFORE the untrusted-peer scrub may DELETE those carriers
|
|
140
|
+
# (Otto::Utils::RELAY_MARKER_HEADERS cover the forwarded-for family, RFC
|
|
141
|
+
# 7239 Forwarded, and the X-Forwarded-Host/Proto/Scheme/SSL/Port
|
|
142
|
+
# authority carriers — everything the scrub removes). A boolean, so it
|
|
143
|
+
# carries no identifying data.
|
|
144
|
+
# Used by: Otto::CaddyTLS::LocalhostGuard#relayed? — without this record a
|
|
145
|
+
# request relayed over loopback by a proxy emitting only RFC 7239
|
|
146
|
+
# `Forwarded` or only `X-Forwarded-Host` would look like a direct local
|
|
147
|
+
# call after the scrub, and a deny would become an allow.
|
|
148
|
+
PEER_RELAYED = 'otto.peer_relayed'
|
|
149
|
+
|
|
132
150
|
# =========================================================================
|
|
133
151
|
# LOCALIZATION (I18N)
|
|
134
152
|
# =========================================================================
|
data/lib/otto/mcp/auth/token.rb
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'rack/utils'
|
|
7
7
|
|
|
8
|
+
require_relative '../endpoint'
|
|
9
|
+
|
|
8
10
|
class Otto
|
|
9
11
|
module MCP
|
|
10
12
|
module Auth
|
|
@@ -54,19 +56,23 @@ class Otto
|
|
|
54
56
|
# Only apply to MCP endpoints
|
|
55
57
|
return @app.call(env) unless mcp_endpoint?(env)
|
|
56
58
|
|
|
57
|
-
#
|
|
59
|
+
# Fail closed: this middleware is only mounted when MCP auth was
|
|
60
|
+
# requested, so a missing authenticator is a misconfiguration, not a
|
|
61
|
+
# licence to serve the endpoint unauthenticated (issue #258).
|
|
58
62
|
auth = @security_config&.mcp_auth
|
|
59
|
-
return unauthorized_response if auth
|
|
63
|
+
return unauthorized_response if auth.nil? || !auth.authenticate(env)
|
|
60
64
|
|
|
61
65
|
@app.call(env)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
private
|
|
65
69
|
|
|
70
|
+
# Exact endpoint only, normalized as the router normalizes it. A prefix
|
|
71
|
+
# match would answer 401 for sibling paths (/admin beside /a) that the
|
|
72
|
+
# MCP handler never receives.
|
|
66
73
|
def mcp_endpoint?(env)
|
|
67
74
|
endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
|
|
68
|
-
|
|
69
|
-
path.start_with?(endpoint)
|
|
75
|
+
Otto::MCP.endpoint_path?(env['PATH_INFO'], endpoint)
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
def unauthorized_response
|
data/lib/otto/mcp/core.rb
CHANGED
|
@@ -9,17 +9,35 @@ class Otto
|
|
|
9
9
|
module Core
|
|
10
10
|
# Enable MCP (Model Context Protocol) server support
|
|
11
11
|
#
|
|
12
|
+
# Options are normalized by Otto::MCP::Server.normalize_options under the
|
|
13
|
+
# :explicit scope. That scope accepts the canonical keys below and their
|
|
14
|
+
# mcp_-prefixed spellings (:mcp_endpoint, :mcp_auth_tokens, ...), as
|
|
15
|
+
# String or Symbol keys, so one hash can feed both Otto.new and this
|
|
16
|
+
# method.
|
|
17
|
+
#
|
|
18
|
+
# It is STRICT: any other key raises ArgumentError rather than being
|
|
19
|
+
# ignored, so a typo such as `enable_mcp!(auth_token: 'x')` fails at boot
|
|
20
|
+
# instead of quietly leaving the endpoint unauthenticated. That includes
|
|
21
|
+
# the constructor-only gating keys (:mcp_enabled, :mcp_http, :mcp_stdio):
|
|
22
|
+
# this method always enables the HTTP endpoint, so it cannot honour them.
|
|
23
|
+
#
|
|
12
24
|
# @param options [Hash] MCP configuration options
|
|
13
|
-
# @option options [
|
|
14
|
-
# @option options [
|
|
15
|
-
#
|
|
25
|
+
# @option options [String] :http_endpoint HTTP endpoint path (default: '/_mcp')
|
|
26
|
+
# @option options [Array<String>, String] :auth_tokens Bearer tokens required
|
|
27
|
+
# on the MCP endpoint (default: none, which logs a warning)
|
|
28
|
+
# @option options [Boolean] :allow_unauthenticated Acknowledge an
|
|
29
|
+
# intentionally unauthenticated endpoint, silencing that warning (default: false)
|
|
30
|
+
# @option options [Boolean] :enable_validation Enable JSON schema validation (default: true)
|
|
31
|
+
# @option options [Boolean] :enable_rate_limiting Enable rate limiting (default: true)
|
|
32
|
+
# @option options [Integer] :requests_per_minute MCP endpoint limit (default: 60)
|
|
33
|
+
# @option options [Integer] :tools_per_minute tools/call limit (default: 20)
|
|
16
34
|
# @example
|
|
17
|
-
# otto.enable_mcp!(
|
|
35
|
+
# otto.enable_mcp!(http_endpoint: '/api/mcp', auth_tokens: ['secret'])
|
|
18
36
|
def enable_mcp!(options = {})
|
|
19
37
|
ensure_not_frozen!
|
|
20
38
|
@mcp_server ||= Otto::MCP::Server.new(self)
|
|
21
39
|
|
|
22
|
-
@mcp_server.enable!(options)
|
|
40
|
+
@mcp_server.enable!(Otto::MCP::Server.normalize_options(options))
|
|
23
41
|
Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
|
|
24
42
|
end
|
|
25
43
|
|
|
@@ -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
|