otto 1.5.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 +44 -5
  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 +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  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 +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
@@ -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] ||
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/helpers/validation.rb
4
+
5
+ require 'loofah'
6
+ require 'facets/file'
7
+
8
+ class Otto
9
+ module Security
10
+ # Validation helper methods providing input validation and sanitization
11
+ module ValidationHelpers
12
+ def validate_input(input, max_length: 1000, allow_html: false)
13
+ return input if input.nil?
14
+
15
+ input_str = input.to_s
16
+ return input_str if input_str.empty?
17
+
18
+ # Check length
19
+ if input_str.length > max_length
20
+ raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
21
+ end
22
+
23
+ # Use Loofah for HTML sanitization and validation
24
+ unless allow_html
25
+ # Check for script injection first (these should always be rejected)
26
+ raise Otto::Security::ValidationError, 'Dangerous content detected' if looks_like_script_injection?(input_str)
27
+
28
+ # Use Loofah to sanitize less dangerous HTML content
29
+ sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
30
+ input_str = sanitized_input
31
+ end
32
+
33
+ # Always check for SQL injection
34
+ ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
35
+ raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
36
+ end
37
+
38
+ input_str
39
+ end
40
+
41
+ def sanitize_filename(filename)
42
+ return nil if filename.nil?
43
+ return 'file' if filename.empty?
44
+
45
+ # Use Facets File.sanitize for basic filesystem-safe filename
46
+ clean_name = File.sanitize(filename.to_s)
47
+
48
+ # Handle edge cases and improve on Facets behavior to match test expectations
49
+ if clean_name.nil? || clean_name.empty?
50
+ clean_name = 'file'
51
+ else
52
+ # Additional cleanup that Facets doesn't do but our tests expect
53
+ clean_name = clean_name.gsub(/_{2,}/, '_') # Collapse multiple underscores
54
+ clean_name = clean_name.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
55
+ clean_name = 'file' if clean_name.empty? # Handle case where only underscores remain
56
+ end
57
+
58
+ # Ensure reasonable length (255 is filesystem limit, leave some padding)
59
+ clean_name = clean_name[0..99] if clean_name.length > 100
60
+
61
+ clean_name
62
+ end
63
+
64
+ private
65
+
66
+ # Check if content looks like it contains HTML tags or entities
67
+ def contains_html_like_content?(content)
68
+ content.match?(/[<>&]/) || content.match?(/&\w+;/)
69
+ end
70
+
71
+ # Detect likely script injection attempts that should be rejected
72
+ def looks_like_script_injection?(content)
73
+ dangerous_patterns = [
74
+ /javascript:/i,
75
+ /<script[^>]*>/i,
76
+ /on\w+\s*=/i, # event handlers like onclick=
77
+ /expression\s*\(/i,
78
+ /data:.*base64/i,
79
+ ]
80
+
81
+ dangerous_patterns.any? { |pattern| content.match?(pattern) }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/auth/token.rb
4
+
5
+ require 'json'
6
+
7
+ class Otto
8
+ module MCP
9
+ module Auth
10
+ # Token-based authentication for MCP protocol endpoints
11
+ class TokenAuth
12
+ def initialize(tokens)
13
+ @tokens = Array(tokens).to_set
14
+ end
15
+
16
+ def authenticate(env)
17
+ token = extract_token(env)
18
+ return false unless token
19
+
20
+ @tokens.include?(token)
21
+ end
22
+
23
+ private
24
+
25
+ def extract_token(env)
26
+ # Try Authorization header first (Bearer token)
27
+ auth_header = env['HTTP_AUTHORIZATION']
28
+ return auth_header[7..] if auth_header&.start_with?('Bearer ')
29
+
30
+ # Try X-MCP-Token header
31
+ env['HTTP_X_MCP_TOKEN']
32
+ end
33
+ end
34
+
35
+ # Middleware for token authentication in MCP protocol
36
+ class TokenMiddleware
37
+ def initialize(app, security_config = nil)
38
+ @app = app
39
+ @security_config = security_config
40
+ end
41
+
42
+ def call(env)
43
+ # Only apply to MCP endpoints
44
+ return @app.call(env) unless mcp_endpoint?(env)
45
+
46
+ # Get auth instance from security config
47
+ auth = @security_config&.mcp_auth
48
+ return unauthorized_response if auth && !auth.authenticate(env)
49
+
50
+ @app.call(env)
51
+ end
52
+
53
+ private
54
+
55
+ def mcp_endpoint?(env)
56
+ endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
57
+ path = env['PATH_INFO'].to_s
58
+ path.start_with?(endpoint)
59
+ end
60
+
61
+ def unauthorized_response
62
+ body = JSON.generate({
63
+ jsonrpc: '2.0',
64
+ id: nil,
65
+ error: {
66
+ code: -32_000,
67
+ message: 'Unauthorized',
68
+ data: 'Valid token required',
69
+ },
70
+ })
71
+
72
+ [401, { 'content-type' => 'application/json' }, [body]]
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/protocol.rb
4
+
5
+ require 'json'
6
+ require_relative 'registry'
7
+
8
+ class Otto
9
+ module MCP
10
+ # MCP protocol handler providing Model Context Protocol functionality
11
+ class Protocol
12
+ attr_reader :registry
13
+
14
+ def initialize(otto_instance)
15
+ @otto = otto_instance
16
+ @registry = Registry.new
17
+ end
18
+
19
+ def handle_request(env)
20
+ request = Rack::Request.new(env)
21
+
22
+ unless request.post? && request.content_type&.include?('application/json')
23
+ return error_response(nil, -32_600, 'Invalid Request', 'Only JSON-RPC POST requests supported')
24
+ end
25
+
26
+ begin
27
+ body = request.body.read
28
+ data = JSON.parse(body)
29
+ rescue JSON::ParserError
30
+ return error_response(nil, -32_700, 'Parse error', 'Invalid JSON')
31
+ end
32
+
33
+ unless valid_jsonrpc_request?(data)
34
+ return error_response(data['id'], -32_600, 'Invalid Request', 'Missing jsonrpc, method, or id fields')
35
+ end
36
+
37
+ case data['method']
38
+ when 'initialize'
39
+ handle_initialize(data)
40
+ when 'resources/list'
41
+ handle_resources_list(data)
42
+ when 'resources/read'
43
+ handle_resources_read(data)
44
+ when 'tools/list'
45
+ handle_tools_list(data)
46
+ when 'tools/call'
47
+ handle_tools_call(data, env)
48
+ else
49
+ error_response(data['id'], -32_601, 'Method not found', "Unknown method: #{data['method']}")
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def valid_jsonrpc_request?(data)
56
+ data.is_a?(Hash) &&
57
+ data['jsonrpc'] == '2.0' &&
58
+ data['method'].is_a?(String) &&
59
+ data.key?('id')
60
+ end
61
+
62
+ def handle_initialize(data)
63
+ capabilities = {
64
+ resources: {
65
+ subscribe: false,
66
+ listChanged: false,
67
+ },
68
+ tools: {},
69
+ }
70
+
71
+ success_response(data['id'], {
72
+ protocolVersion: '2024-11-05',
73
+ capabilities: capabilities,
74
+ serverInfo: {
75
+ name: 'Otto MCP Server',
76
+ version: Otto::VERSION,
77
+ },
78
+ })
79
+ end
80
+
81
+ def handle_resources_list(data)
82
+ resources = @registry.list_resources
83
+ success_response(data['id'], { resources: resources })
84
+ end
85
+
86
+ def handle_resources_read(data)
87
+ params = data['params'] || {}
88
+ uri = params['uri']
89
+
90
+ return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
91
+
92
+ resource = @registry.read_resource(uri)
93
+ if resource
94
+ success_response(data['id'], resource)
95
+ else
96
+ error_response(data['id'], -32_001, 'Resource not found', "Resource not found: #{uri}")
97
+ end
98
+ end
99
+
100
+ def handle_tools_list(data)
101
+ tools = @registry.list_tools
102
+ success_response(data['id'], { tools: tools })
103
+ end
104
+
105
+ def handle_tools_call(data, env)
106
+ params = data['params'] || {}
107
+ name = params['name']
108
+ arguments = params['arguments'] || {}
109
+
110
+ return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter') unless name
111
+
112
+ begin
113
+ result = @registry.call_tool(name, arguments, env)
114
+ success_response(data['id'], result)
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
+ end
119
+ end
120
+
121
+ def success_response(id, result)
122
+ body = JSON.generate({
123
+ jsonrpc: '2.0',
124
+ id: id,
125
+ result: result,
126
+ })
127
+
128
+ [200, { 'content-type' => 'application/json' }, [body]]
129
+ end
130
+
131
+ def error_response(id, code, message, data = nil)
132
+ error = { code: code, message: message }
133
+ error[:data] = data if data
134
+
135
+ body = JSON.generate({
136
+ jsonrpc: '2.0',
137
+ id: id,
138
+ error: error,
139
+ })
140
+
141
+ # Map JSON-RPC error codes to appropriate HTTP status codes
142
+ http_status = case code
143
+ when -32_700..-32_600 # Parse error, Invalid Request, Method not found
144
+ 400
145
+ when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
146
+ 500
147
+ when -32_001 # Resource not found
148
+ 404
149
+ when -32_002 # Tool not found
150
+ 404
151
+ when -32_601 # Method not found
152
+ 404
153
+ when -32_602 # Invalid params
154
+ 400
155
+ else
156
+ # Default client error for unknown non-server codes; treat server-range as 500
157
+ (-32_099..-32_000).cover?(code) ? 500 : 400
158
+ end
159
+
160
+ [http_status, { 'content-type' => 'application/json' }, [body]]
161
+ end
162
+ end
163
+ end
164
+ end