otto 1.6.0 → 2.0.0.pre1

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/router.rb
4
+
5
+ require_relative '../mcp/route_parser'
6
+
7
+ class Otto
8
+ module Core
9
+ # Router module providing route loading and request dispatching functionality
10
+ module Router
11
+ def load(path)
12
+ path = File.expand_path(path)
13
+ raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
14
+
15
+ raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
16
+ raw.each do |entry|
17
+ # Enhanced parsing: split only on first two whitespace boundaries
18
+ # This preserves parameters in the definition part
19
+ parts = entry.split(/\s+/, 3)
20
+ next if parts.size < 3 # Skip malformed entries
21
+
22
+ verb = parts[0]
23
+ path = parts[1]
24
+ definition = parts[2]
25
+
26
+ # Check for MCP routes
27
+ if Otto::MCP::RouteParser.is_mcp_route?(definition)
28
+ raise '[MCP] MCP server not enabled' unless @mcp_server
29
+
30
+ handle_mcp_route(verb, path, definition)
31
+ next
32
+ elsif Otto::MCP::RouteParser.is_tool_route?(definition)
33
+ raise '[MCP] MCP server not enabled' unless @mcp_server
34
+
35
+ handle_tool_route(verb, path, definition)
36
+ next
37
+ end
38
+
39
+ route = Otto::Route.new verb, path, definition
40
+ route.otto = self
41
+ path_clean = path.gsub(%r{/$}, '')
42
+ @route_definitions[route.definition] = route
43
+ Otto.logger.debug "route: #{route.pattern}" if Otto.debug
44
+ @routes[route.verb] ||= []
45
+ @routes[route.verb] << route
46
+ @routes_literal[route.verb] ||= {}
47
+ @routes_literal[route.verb][path_clean] = route
48
+ rescue StandardError => e
49
+ Otto.logger.error "Bad route in #{path}: #{entry} (Error: #{e.message})"
50
+ end
51
+ self
52
+ end
53
+
54
+ def handle_request(env)
55
+ locale = determine_locale env
56
+ env['rack.locale'] = locale
57
+ env['otto.locale_config'] = @locale_config if @locale_config
58
+ @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
59
+ path_info = Rack::Utils.unescape(env['PATH_INFO'])
60
+ path_info = '/' if path_info.to_s.empty?
61
+
62
+ begin
63
+ path_info_clean = path_info
64
+ .encode(
65
+ 'UTF-8', # Target encoding
66
+ invalid: :replace, # Replace invalid byte sequences
67
+ undef: :replace, # Replace characters undefined in UTF-8
68
+ replace: '' # Use empty string for replacement
69
+ )
70
+ .gsub(%r{/$}, '') # Remove trailing slash, if present
71
+ rescue ArgumentError => e
72
+ # Log the error but don't expose details
73
+ Otto.logger.error '[Otto.handle_request] Path encoding error'
74
+ Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
75
+ # Set a default value or use the original path_info
76
+ path_info_clean = path_info
77
+ end
78
+
79
+ base_path = File.split(path_info).first
80
+ # Files in the root directory can refer to themselves
81
+ base_path = path_info if base_path == '/'
82
+ http_verb = env['REQUEST_METHOD'].upcase.to_sym
83
+ literal_routes = routes_literal[http_verb] || {}
84
+ literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
85
+
86
+ if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
87
+ # Otto.logger.debug " request: #{path_info} (static)"
88
+ static_route.call(env)
89
+ elsif literal_routes.has_key?(path_info_clean)
90
+ route = literal_routes[path_info_clean]
91
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
92
+ route.call(env)
93
+ elsif static_route && http_verb == :GET && safe_file?(path_info)
94
+ Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
95
+ routes_static[:GET][base_path] = base_path
96
+ static_route.call(env)
97
+ else
98
+ match_dynamic_route(env, path_info, http_verb, literal_routes)
99
+ end
100
+ end
101
+
102
+ def determine_locale(env)
103
+ accept_langs = env['HTTP_ACCEPT_LANGUAGE']
104
+ accept_langs = option[:locale] if accept_langs.to_s.empty?
105
+ locales = []
106
+ unless accept_langs.empty?
107
+ locales = accept_langs.split(',').map do |l|
108
+ l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
109
+ l.split(';q=')
110
+ end.sort_by do |_locale, qvalue|
111
+ qvalue.to_f
112
+ end.collect do |locale, _qvalue|
113
+ locale
114
+ end.reverse
115
+ end
116
+ Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
117
+ locales.empty? ? nil : locales
118
+ end
119
+
120
+ private
121
+
122
+ def match_dynamic_route(env, path_info, http_verb, literal_routes)
123
+ extra_params = {}
124
+ found_route = nil
125
+ valid_routes = routes[http_verb] || []
126
+ valid_routes.push(*routes[:GET]) if http_verb == :HEAD
127
+
128
+ valid_routes.each do |route|
129
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
130
+ next unless (match = route.pattern.match(path_info))
131
+
132
+ values = match.captures.to_a
133
+ # The first capture returned is the entire matched string b/c
134
+ # we wrapped the entire regex in parens. We don't need it to
135
+ # the full match.
136
+ values.shift
137
+ extra_params = build_route_params(route, values)
138
+ found_route = route
139
+ break
140
+ end
141
+
142
+ found_route ||= literal_routes['/404']
143
+ if found_route
144
+ found_route.call env, extra_params
145
+ else
146
+ @not_found || Otto::Static.not_found
147
+ end
148
+ end
149
+
150
+ def build_route_params(route, values)
151
+ if route.keys.any?
152
+ route.keys.zip(values).each_with_object({}) do |(k, v), hash|
153
+ if k == 'splat'
154
+ (hash[k] ||= []) << v
155
+ else
156
+ hash[k] = v
157
+ end
158
+ end
159
+ elsif values.any?
160
+ { 'captures' => values }
161
+ else
162
+ {}
163
+ end
164
+ end
165
+
166
+ def handle_mcp_route(verb, path, definition)
167
+ route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
168
+ @mcp_server.register_mcp_route(route_info)
169
+ Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
170
+ rescue StandardError => e
171
+ Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
172
+ end
173
+
174
+ def handle_tool_route(verb, path, definition)
175
+ route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
176
+ @mcp_server.register_mcp_route(route_info)
177
+ Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
178
+ rescue StandardError => e
179
+ Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/uri_generator.rb
4
+
5
+ require 'uri'
6
+
7
+ class Otto
8
+ module Core
9
+ # URI generation module providing path and URL generation for route definitions
10
+ module UriGenerator
11
+ # Return the URI path for the given +route_definition+
12
+ # e.g.
13
+ #
14
+ # Otto.default.path 'YourClass.somemethod' #=> /some/path
15
+ #
16
+ def uri(route_definition, params = {})
17
+ # raise RuntimeError, "Not working"
18
+ route = @route_definitions[route_definition]
19
+ return if route.nil?
20
+
21
+ local_params = params.clone
22
+ local_path = route.path.clone
23
+
24
+ keys_to_remove = []
25
+ local_params.each_pair do |k, v|
26
+ next unless local_path.match(":#{k}")
27
+
28
+ local_path.gsub!(":#{k}", v.to_s)
29
+ keys_to_remove << k
30
+ end
31
+ keys_to_remove.each { |k| local_params.delete(k) }
32
+
33
+ uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
34
+ unless local_params.empty?
35
+ query_string = local_params.map do |k, v|
36
+ "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}"
37
+ end.join('&')
38
+ uri.query = query_string
39
+ end
40
+ uri.to_s
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/design_system.rb
2
4
 
3
5
  class Otto
@@ -112,11 +114,11 @@ class Otto
112
114
  return '' if text.nil?
113
115
 
114
116
  text.to_s
115
- .gsub('&', '&amp;')
116
- .gsub('<', '&lt;')
117
- .gsub('>', '&gt;')
118
- .gsub('"', '&quot;')
119
- .gsub("'", '&#x27;')
117
+ .gsub('&', '&amp;')
118
+ .gsub('<', '&lt;')
119
+ .gsub('>', '&gt;')
120
+ .gsub('"', '&quot;')
121
+ .gsub("'", '&#x27;')
120
122
  end
121
123
 
122
124
  def otto_styles
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/helpers/base.rb
2
4
 
3
5
  class Otto
6
+ # Base helper methods providing core functionality for Otto applications
4
7
  module BaseHelpers
5
8
  # Build application path by joining path segments
6
9
  #
@@ -1,8 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/helpers/request.rb
2
4
 
3
5
  require_relative 'base'
4
6
 
5
7
  class Otto
8
+ # Request helper methods providing HTTP request handling utilities
6
9
  module RequestHelpers
7
10
  include Otto::BaseHelpers
8
11
 
@@ -14,9 +17,7 @@ class Otto
14
17
  remote_addr = env['REMOTE_ADDR']
15
18
 
16
19
  # If we don't have a security config or trusted proxies, use direct connection
17
- if !otto_security_config || !trusted_proxy?(remote_addr)
18
- return validate_ip_address(remote_addr)
19
- end
20
+ return validate_ip_address(remote_addr) if !otto_security_config || !trusted_proxy?(remote_addr)
20
21
 
21
22
  # Check forwarded headers from trusted proxies
22
23
  forwarded_ips = [
@@ -152,12 +153,12 @@ class Otto
152
153
 
153
154
  # RFC 1918 private ranges and loopback
154
155
  private_ranges = [
155
- /\A10\./, # 10.0.0.0/8
156
+ /\A10\./, # 10.0.0.0/8
156
157
  /\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
157
158
  /\A192\.168\./, # 192.168.0.0/16
158
159
  /\A169\.254\./, # 169.254.0.0/16 (link-local)
159
160
  /\A224\./, # 224.0.0.0/4 (multicast)
160
- /\A0\./, # 0.0.0.0/8
161
+ /\A0\./, # 0.0.0.0/8
161
162
  ]
162
163
 
163
164
  private_ranges.any? { |range| ip.match?(range) }
@@ -207,7 +208,7 @@ class Otto
207
208
 
208
209
  # Add any header that begins with the specified prefix
209
210
  if header_prefix
210
- prefix_keys = env.keys.select { |key| key.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
211
+ prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
211
212
  keys.concat(prefix_keys)
212
213
  end
213
214
 
@@ -354,8 +355,9 @@ class Otto
354
355
  debug_enabled = opts[:debug] || false
355
356
 
356
357
  # Guard clause - required configuration must be present
357
- unless available_locales && default_locale
358
- raise ArgumentError, 'available_locales and default_locale are required (provide via opts or Otto configuration)'
358
+ unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
359
+ raise ArgumentError,
360
+ 'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
359
361
  end
360
362
 
361
363
  # Check sources in order of precedence
@@ -1,8 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/helpers/response.rb
2
4
 
3
5
  require_relative 'base'
4
6
 
5
7
  class Otto
8
+ # Response helper methods providing HTTP response handling utilities
6
9
  module ResponseHelpers
7
10
  include Otto::BaseHelpers
8
11
 
@@ -48,7 +51,7 @@ class Otto
48
51
  session_opts = opts.merge(
49
52
  secure: true,
50
53
  httponly: true,
51
- samesite: :strict,
54
+ samesite: :strict
52
55
  )
53
56
 
54
57
  # Remove expiration-related options for session cookies
@@ -109,9 +112,7 @@ class Otto
109
112
  headers['content-type'] ||= content_type
110
113
 
111
114
  # Warn if CSP header already exists but don't skip
112
- if headers['content-security-policy']
113
- warn 'CSP header already set, overriding with nonce-based policy'
114
- end
115
+ warn 'CSP header already set, overriding with nonce-based policy' if headers['content-security-policy']
115
116
 
116
117
  # Get security configuration
117
118
  security_config = opts[:security_config] ||
@@ -1,7 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/helpers/validation.rb
2
4
 
5
+ require 'loofah'
6
+ require 'facets/file'
7
+
3
8
  class Otto
4
9
  module Security
10
+ # Validation helper methods providing input validation and sanitization
5
11
  module ValidationHelpers
6
12
  def validate_input(input, max_length: 1000, allow_html: false)
7
13
  return input if input.nil?
@@ -17,9 +23,7 @@ class Otto
17
23
  # Use Loofah for HTML sanitization and validation
18
24
  unless allow_html
19
25
  # Check for script injection first (these should always be rejected)
20
- if looks_like_script_injection?(input_str)
21
- raise Otto::Security::ValidationError, 'Dangerous content detected'
22
- end
26
+ raise Otto::Security::ValidationError, 'Dangerous content detected' if looks_like_script_injection?(input_str)
23
27
 
24
28
  # Use Loofah to sanitize less dangerous HTML content
25
29
  sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
@@ -28,9 +32,7 @@ class Otto
28
32
 
29
33
  # Always check for SQL injection
30
34
  ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
31
- if input_str.match?(pattern)
32
- raise Otto::Security::ValidationError, 'Potential SQL injection detected'
33
- end
35
+ raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
34
36
  end
35
37
 
36
38
  input_str
@@ -71,7 +73,7 @@ class Otto
71
73
  dangerous_patterns = [
72
74
  /javascript:/i,
73
75
  /<script[^>]*>/i,
74
- /on\w+\s*=/i, # event handlers like onclick=
76
+ /on\w+\s*=/i, # event handlers like onclick=
75
77
  /expression\s*\(/i,
76
78
  /data:.*base64/i,
77
79
  ]
@@ -1,8 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/auth/token.rb
4
+
1
5
  require 'json'
2
6
 
3
7
  class Otto
4
8
  module MCP
5
9
  module Auth
10
+ # Token-based authentication for MCP protocol endpoints
6
11
  class TokenAuth
7
12
  def initialize(tokens)
8
13
  @tokens = Array(tokens).to_set
@@ -20,15 +25,14 @@ class Otto
20
25
  def extract_token(env)
21
26
  # Try Authorization header first (Bearer token)
22
27
  auth_header = env['HTTP_AUTHORIZATION']
23
- if auth_header&.start_with?('Bearer ')
24
- return auth_header[7..]
25
- end
28
+ return auth_header[7..] if auth_header&.start_with?('Bearer ')
26
29
 
27
30
  # Try X-MCP-Token header
28
31
  env['HTTP_X_MCP_TOKEN']
29
32
  end
30
33
  end
31
34
 
35
+ # Middleware for token authentication in MCP protocol
32
36
  class TokenMiddleware
33
37
  def initialize(app, security_config = nil)
34
38
  @app = app
@@ -41,9 +45,7 @@ class Otto
41
45
 
42
46
  # Get auth instance from security config
43
47
  auth = @security_config&.mcp_auth
44
- if auth && !auth.authenticate(env)
45
- return unauthorized_response
46
- end
48
+ return unauthorized_response if auth && !auth.authenticate(env)
47
49
 
48
50
  @app.call(env)
49
51
  end
@@ -58,15 +60,14 @@ class Otto
58
60
 
59
61
  def unauthorized_response
60
62
  body = JSON.generate({
61
- jsonrpc: '2.0',
63
+ jsonrpc: '2.0',
62
64
  id: nil,
63
65
  error: {
64
66
  code: -32_000,
65
67
  message: 'Unauthorized',
66
68
  data: 'Valid token required',
67
69
  },
68
- },
69
- )
70
+ })
70
71
 
71
72
  [401, { 'content-type' => 'application/json' }, [body]]
72
73
  end
@@ -1,8 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/protocol.rb
4
+
1
5
  require 'json'
2
6
  require_relative 'registry'
3
7
 
4
8
  class Otto
5
9
  module MCP
10
+ # MCP protocol handler providing Model Context Protocol functionality
6
11
  class Protocol
7
12
  attr_reader :registry
8
13
 
@@ -64,14 +69,13 @@ class Otto
64
69
  }
65
70
 
66
71
  success_response(data['id'], {
67
- protocolVersion: '2024-11-05',
72
+ protocolVersion: '2024-11-05',
68
73
  capabilities: capabilities,
69
74
  serverInfo: {
70
75
  name: 'Otto MCP Server',
71
76
  version: Otto::VERSION,
72
77
  },
73
- }
74
- )
78
+ })
75
79
  end
76
80
 
77
81
  def handle_resources_list(data)
@@ -83,9 +87,7 @@ class Otto
83
87
  params = data['params'] || {}
84
88
  uri = params['uri']
85
89
 
86
- unless uri
87
- return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter')
88
- end
90
+ return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
89
91
 
90
92
  resource = @registry.read_resource(uri)
91
93
  if resource
@@ -105,26 +107,23 @@ class Otto
105
107
  name = params['name']
106
108
  arguments = params['arguments'] || {}
107
109
 
108
- unless name
109
- return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter')
110
- end
110
+ return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter') unless name
111
111
 
112
112
  begin
113
113
  result = @registry.call_tool(name, arguments, env)
114
114
  success_response(data['id'], result)
115
- rescue StandardError => ex
116
- Otto.logger.error "[MCP] Tool call error: #{ex.message}"
117
- error_response(data['id'], -32_603, 'Internal error', ex.message)
115
+ rescue StandardError => e
116
+ Otto.logger.error "[MCP] Tool call error: #{e.message}"
117
+ error_response(data['id'], -32_603, 'Internal error', e.message)
118
118
  end
119
119
  end
120
120
 
121
121
  def success_response(id, result)
122
122
  body = JSON.generate({
123
- jsonrpc: '2.0',
123
+ jsonrpc: '2.0',
124
124
  id: id,
125
125
  result: result,
126
- },
127
- )
126
+ })
128
127
 
129
128
  [200, { 'content-type' => 'application/json' }, [body]]
130
129
  end
@@ -134,30 +133,28 @@ class Otto
134
133
  error[:data] = data if data
135
134
 
136
135
  body = JSON.generate({
137
- jsonrpc: '2.0',
136
+ jsonrpc: '2.0',
138
137
  id: id,
139
138
  error: error,
140
- },
141
- )
139
+ })
142
140
 
143
141
  # Map JSON-RPC error codes to appropriate HTTP status codes
144
142
  http_status = case code
145
- when -32700..-32600 # Parse error, Invalid Request, Method not found
143
+ when -32_700..-32_600 # Parse error, Invalid Request, Method not found
146
144
  400
147
- when -32000 # Server error (generic)
145
+ when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
148
146
  500
149
- when -32001 # Resource not found
147
+ when -32_001 # Resource not found
150
148
  404
151
- when -32002 # Tool not found
149
+ when -32_002 # Tool not found
152
150
  404
153
- when -32601 # Method not found
151
+ when -32_601 # Method not found
154
152
  404
155
- when -32602 # Invalid params
153
+ when -32_602 # Invalid params
156
154
  400
157
- when -32603 # Internal error
158
- 500
159
155
  else
160
- 400 # Default to 400 for other client errors
156
+ # Default client error for unknown non-server codes; treat server-range as 500
157
+ (-32_099..-32_000).cover?(code) ? 500 : 400
161
158
  end
162
159
 
163
160
  [http_status, { 'content-type' => 'application/json' }, [body]]
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/rate_limiting.rb
4
+
1
5
  require 'json'
2
6
 
3
7
  require_relative '../security/rate_limiting'
@@ -10,6 +14,7 @@ end
10
14
 
11
15
  class Otto
12
16
  module MCP
17
+ # Rate limiter for MCP protocol endpoints
13
18
  class RateLimiter < Otto::Security::RateLimiting
14
19
  def self.configure_rack_attack!(config = {})
15
20
  return unless defined?(Rack::Attack)
@@ -117,6 +122,7 @@ class Otto
117
122
  end
118
123
  end
119
124
 
125
+ # Middleware for applying rate limits to MCP protocol endpoints
120
126
  class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
121
127
  def initialize(app, security_config = nil)
122
128
  @app = app
@@ -138,10 +144,9 @@ class Otto
138
144
 
139
145
  # Add MCP-specific defaults
140
146
  mcp_config = base_config.merge({
141
- mcp_requests_per_minute: 60,
147
+ mcp_requests_per_minute: 60,
142
148
  tool_calls_per_minute: 20,
143
- },
144
- )
149
+ })
145
150
 
146
151
  RateLimiter.configure_rack_attack!(mcp_config)
147
152
  end
@@ -1,5 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/registry.rb
4
+
1
5
  class Otto
2
6
  module MCP
7
+ # Registry for managing MCP resources and tools
3
8
  class Registry
4
9
  def initialize
5
10
  @resources = {}
@@ -59,8 +64,8 @@ class Otto
59
64
  text: content.to_s,
60
65
  }],
61
66
  }
62
- rescue StandardError => ex
63
- Otto.logger.error "[MCP] Resource read error for #{uri}: #{ex.message}"
67
+ rescue StandardError => e
68
+ Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.message}"
64
69
  nil
65
70
  end
66
71
  end