otto 1.6.0 → 2.0.0.pre2

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 +3 -2
  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 +26 -344
  7. data/CHANGELOG.rst +131 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +11 -4
  10. data/Gemfile.lock +38 -42
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/README.md +120 -0
  14. data/changelog.d/scriv.ini +5 -0
  15. data/docs/.gitignore +2 -0
  16. data/docs/migrating/v2.0.0-pre1.md +276 -0
  17. data/docs/migrating/v2.0.0-pre2.md +345 -0
  18. data/examples/.gitignore +1 -0
  19. data/examples/advanced_routes/README.md +33 -0
  20. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  21. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  22. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  27. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  29. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  30. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  31. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  32. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  33. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  34. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  35. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  36. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  37. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  38. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  39. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  40. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  41. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  42. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  43. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  45. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  46. data/examples/advanced_routes/app.rb +33 -0
  47. data/examples/advanced_routes/config.rb +23 -0
  48. data/examples/advanced_routes/config.ru +7 -0
  49. data/examples/advanced_routes/puma.rb +20 -0
  50. data/examples/advanced_routes/routes +167 -0
  51. data/examples/advanced_routes/run.rb +39 -0
  52. data/examples/advanced_routes/test.rb +58 -0
  53. data/examples/authentication_strategies/README.md +32 -0
  54. data/examples/authentication_strategies/app/auth.rb +68 -0
  55. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  56. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  57. data/examples/authentication_strategies/config.ru +24 -0
  58. data/examples/authentication_strategies/routes +37 -0
  59. data/examples/basic/README.md +29 -0
  60. data/examples/basic/app.rb +7 -35
  61. data/examples/basic/routes +0 -9
  62. data/examples/mcp_demo/README.md +87 -0
  63. data/examples/mcp_demo/app.rb +29 -34
  64. data/examples/mcp_demo/config.ru +9 -60
  65. data/examples/security_features/README.md +46 -0
  66. data/examples/security_features/app.rb +23 -24
  67. data/examples/security_features/config.ru +8 -10
  68. data/lib/otto/core/configuration.rb +167 -0
  69. data/lib/otto/core/error_handler.rb +86 -0
  70. data/lib/otto/core/file_safety.rb +61 -0
  71. data/lib/otto/core/middleware_stack.rb +237 -0
  72. data/lib/otto/core/router.rb +184 -0
  73. data/lib/otto/core/uri_generator.rb +44 -0
  74. data/lib/otto/design_system.rb +7 -5
  75. data/lib/otto/env_keys.rb +114 -0
  76. data/lib/otto/helpers/base.rb +5 -21
  77. data/lib/otto/helpers/request.rb +10 -8
  78. data/lib/otto/helpers/response.rb +27 -4
  79. data/lib/otto/helpers/validation.rb +9 -7
  80. data/lib/otto/mcp/auth/token.rb +10 -9
  81. data/lib/otto/mcp/protocol.rb +24 -27
  82. data/lib/otto/mcp/rate_limiting.rb +8 -3
  83. data/lib/otto/mcp/registry.rb +7 -2
  84. data/lib/otto/mcp/route_parser.rb +10 -15
  85. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
  86. data/lib/otto/mcp/server.rb +45 -22
  87. data/lib/otto/response_handlers/auto.rb +39 -0
  88. data/lib/otto/response_handlers/base.rb +16 -0
  89. data/lib/otto/response_handlers/default.rb +16 -0
  90. data/lib/otto/response_handlers/factory.rb +39 -0
  91. data/lib/otto/response_handlers/json.rb +34 -0
  92. data/lib/otto/response_handlers/redirect.rb +25 -0
  93. data/lib/otto/response_handlers/view.rb +24 -0
  94. data/lib/otto/response_handlers.rb +9 -135
  95. data/lib/otto/route.rb +51 -55
  96. data/lib/otto/route_definition.rb +15 -18
  97. data/lib/otto/route_handlers/base.rb +121 -0
  98. data/lib/otto/route_handlers/class_method.rb +89 -0
  99. data/lib/otto/route_handlers/factory.rb +42 -0
  100. data/lib/otto/route_handlers/instance_method.rb +69 -0
  101. data/lib/otto/route_handlers/lambda.rb +59 -0
  102. data/lib/otto/route_handlers/logic_class.rb +93 -0
  103. data/lib/otto/route_handlers.rb +10 -405
  104. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  105. data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
  106. data/lib/otto/security/authentication/failure_result.rb +44 -0
  107. data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -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 +337 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -23
  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 +54 -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 +141 -498
  128. data/otto.gemspec +4 -2
  129. metadata +99 -18
  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,114 @@
1
+ # lib/otto/env_keys.rb
2
+ #
3
+ # Central registry of all env['otto.*'] keys used throughout Otto framework.
4
+ # This documentation helps prevent key conflicts and aids multi-app integration.
5
+ #
6
+ # DOCUMENTATION-ONLY MODULE: The constants defined here are intentionally NOT used
7
+ # in the codebase. Otto uses string literals (e.g., env['otto.strategy_result'])
8
+ # for readibility/simplicity. This module exists as reference documentation but
9
+ # may be considered for future use if needed.
10
+ #
11
+ class Otto
12
+ # Rack environment keys used by Otto framework
13
+ #
14
+ # All Otto-specific keys are namespaced under 'otto.*' to avoid conflicts
15
+ # with other Rack middleware or applications.
16
+ module EnvKeys
17
+ # =========================================================================
18
+ # ROUTING & REQUEST FLOW
19
+ # =========================================================================
20
+
21
+ # Route definition parsed from routes file
22
+ # Type: Otto::RouteDefinition
23
+ # Set by: Otto::Core::Router#parse_routes
24
+ # Used by: AuthenticationMiddleware, RouteHandlers, LogicClassHandler
25
+ ROUTE_DEFINITION = 'otto.route_definition'
26
+
27
+ # Route-specific options parsed from route string
28
+ # Type: Hash (e.g., { response: 'json', csrf: 'exempt', auth: 'authenticated' })
29
+ # Set by: Otto::RouteDefinition#initialize
30
+ # Used by: CSRFMiddleware, RouteHandlers
31
+ ROUTE_OPTIONS = 'otto.route_options'
32
+
33
+ # =========================================================================
34
+ # AUTHENTICATION & AUTHORIZATION
35
+ # =========================================================================
36
+
37
+ # Authentication strategy result containing session/user state
38
+ # Type: Otto::Security::Authentication::StrategyResult
39
+ # Set by: AuthenticationMiddleware
40
+ # Used by: RouteHandlers, LogicClasses, Controllers
41
+ # Note: Always present (anonymous or authenticated)
42
+ STRATEGY_RESULT = 'otto.strategy_result'
43
+
44
+ # Authenticated user object (convenience accessor)
45
+ # Type: Hash, Custom User Object, or nil
46
+ # Set by: AuthenticationMiddleware (from strategy_result.user)
47
+ # Used by: Controllers, RouteHandlers
48
+ USER = 'otto.user'
49
+
50
+ # User-specific context (session, roles, permissions, etc.)
51
+ # Type: Hash
52
+ # Set by: AuthenticationMiddleware (from strategy_result.user_context)
53
+ # Used by: Controllers, Analytics
54
+ USER_CONTEXT = 'otto.user_context'
55
+
56
+ # =========================================================================
57
+ # SECURITY & CONFIGURATION
58
+ # =========================================================================
59
+
60
+ # Security configuration object
61
+ # Type: Otto::Security::Config
62
+ # Set by: Otto#initialize, SecurityConfig
63
+ # Used by: All security middleware (CSRF, Headers, Validation)
64
+ SECURITY_CONFIG = 'otto.security_config'
65
+
66
+ # =========================================================================
67
+ # LOCALIZATION (I18N)
68
+ # =========================================================================
69
+
70
+ # Resolved locale for current request
71
+ # Type: String (e.g., 'en', 'es', 'fr')
72
+ # Set by: LocaleMiddleware
73
+ # Used by: RouteHandlers, LogicClasses, Views
74
+ LOCALE = 'otto.locale'
75
+
76
+ # Locale configuration object
77
+ # Type: Otto::LocaleConfig
78
+ # Set by: LocaleMiddleware
79
+ # Used by: Locale resolution logic
80
+ LOCALE_CONFIG = 'otto.locale_config'
81
+
82
+ # Available locales for the application
83
+ # Type: Array<String>
84
+ # Set by: LocaleConfig
85
+ # Used by: Locale middleware, language switchers
86
+ AVAILABLE_LOCALES = 'otto.available_locales'
87
+
88
+ # Default/fallback locale
89
+ # Type: String
90
+ # Set by: LocaleConfig
91
+ # Used by: Locale middleware when resolution fails
92
+ DEFAULT_LOCALE = 'otto.default_locale'
93
+
94
+ # =========================================================================
95
+ # ERROR HANDLING
96
+ # =========================================================================
97
+
98
+ # Unique error ID for tracking/logging
99
+ # Type: String (hex format, e.g., '4ac47cb3a6d177ef')
100
+ # Set by: ErrorHandler, RouteHandlers
101
+ # Used by: Error responses, logging, support
102
+ ERROR_ID = 'otto.error_id'
103
+
104
+ # =========================================================================
105
+ # MCP (MODEL CONTEXT PROTOCOL)
106
+ # =========================================================================
107
+
108
+ # MCP HTTP endpoint path
109
+ # Type: String (default: '/_mcp')
110
+ # Set by: Otto::MCP::Server#enable!
111
+ # Used by: MCP middleware, SchemaValidationMiddleware
112
+ MCP_HTTP_ENDPOINT = 'otto.mcp_http_endpoint'
113
+ end
114
+ end
@@ -1,27 +1,11 @@
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
- # Build application path by joining path segments
6
- #
7
- # This method safely joins multiple path segments, handling
8
- # duplicate slashes and ensuring proper path formatting.
9
- # Includes the script name (mount point) as the first segment.
10
- #
11
- # @param paths [Array<String>] Path segments to join
12
- # @return [String] Properly formatted path
13
- #
14
- # @example
15
- # app_path('api', 'v1', 'users')
16
- # # => "/myapp/api/v1/users"
17
- #
18
- # @example
19
- # app_path(['admin', 'settings'])
20
- # # => "/myapp/admin/settings"
21
- def app_path(*paths)
22
- paths = paths.flatten.compact
23
- paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
24
- paths.join('/').gsub('//', '/')
25
- end
8
+ # Keep only truly context-independent shared functionality here
9
+ # Methods requiring env access should be implemented in the specific helper modules
26
10
  end
27
11
  end
@@ -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] ||
@@ -151,5 +152,27 @@ class Otto
151
152
  headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
152
153
  headers['pragma'] = 'no-cache'
153
154
  end
155
+
156
+ # Build application path by joining path segments
157
+ #
158
+ # This method safely joins multiple path segments, handling
159
+ # duplicate slashes and ensuring proper path formatting.
160
+ # Includes the script name (mount point) as the first segment.
161
+ #
162
+ # @param paths [Array<String>] Path segments to join
163
+ # @return [String] Properly formatted path
164
+ #
165
+ # @example
166
+ # app_path('api', 'v1', 'users')
167
+ # # => "/myapp/api/v1/users"
168
+ #
169
+ # @example
170
+ # app_path(['admin', 'settings'])
171
+ # # => "/myapp/admin/settings"
172
+ def app_path(*paths)
173
+ paths = paths.flatten.compact
174
+ paths.unshift(request.env['SCRIPT_NAME']) if request.env['SCRIPT_NAME']
175
+ paths.join('/').gsub('//', '/')
176
+ end
154
177
  end
155
178
  end
@@ -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
@@ -1,21 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/route_parser.rb
4
+
1
5
  class Otto
2
6
  module MCP
7
+ # Parser for MCP route definitions and resource URIs
3
8
  class RouteParser
4
9
  def self.parse_mcp_route(_verb, _path, definition)
5
10
  # MCP route format: MCP resource_uri HandlerClass.method_name
6
11
  # Note: The path parameter is ignored for MCP routes - resource_uri comes from definition
7
12
  parts = definition.split(/\s+/, 3)
8
13
 
9
- if parts[0] != 'MCP'
10
- raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}"
11
- end
14
+ raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}" if parts[0] != 'MCP'
12
15
 
13
16
  resource_uri = parts[1]
14
17
  handler_definition = parts[2]
15
18
 
16
- unless resource_uri && handler_definition
17
- raise ArgumentError, "Invalid MCP route format: #{definition}"
18
- end
19
+ raise ArgumentError, "Invalid MCP route format: #{definition}" unless resource_uri && handler_definition
19
20
 
20
21
  # Clean up URI - remove leading slash if present since MCP URIs are relative
21
22
  resource_uri = resource_uri.sub(%r{^/}, '')
@@ -33,16 +34,12 @@ class Otto
33
34
  # Note: The path parameter is ignored for TOOL routes - tool_name comes from definition
34
35
  parts = definition.split(/\s+/, 3)
35
36
 
36
- if parts[0] != 'TOOL'
37
- raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}"
38
- end
37
+ raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}" if parts[0] != 'TOOL'
39
38
 
40
39
  tool_name = parts[1]
41
40
  handler_definition = parts[2]
42
41
 
43
- unless tool_name && handler_definition
44
- raise ArgumentError, "Invalid TOOL route format: #{definition}"
45
- end
42
+ raise ArgumentError, "Invalid TOOL route format: #{definition}" unless tool_name && handler_definition
46
43
 
47
44
  # Clean up tool name - remove leading slash if present
48
45
  tool_name = tool_name.sub(%r{^/}, '')
@@ -70,9 +67,7 @@ class Otto
70
67
  # First part is the handler class.method
71
68
  parts[1..-1]&.each do |part|
72
69
  key, value = part.split('=', 2)
73
- if key && value
74
- options[key.to_sym] = value
75
- end
70
+ options[key.to_sym] = value if key && value
76
71
  end
77
72
 
78
73
  options
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/schema_validation.rb
4
+
1
5
  require 'json'
2
6
 
3
7
  begin
@@ -10,6 +14,7 @@ class Otto
10
14
  module MCP
11
15
  class ValidationError < StandardError; end
12
16
 
17
+ # JSON Schema validator for MCP protocol requests
13
18
  class Validator
14
19
  def initialize
15
20
  @schemas = {}
@@ -48,7 +53,7 @@ class Otto
48
53
 
49
54
  def mcp_request_schema
50
55
  @schemas[:mcp_request] ||= JSONSchemer.schema({
51
- type: 'object',
56
+ type: 'object',
52
57
  required: %w[jsonrpc method id],
53
58
  properties: {
54
59
  jsonrpc: { const: '2.0' },
@@ -57,12 +62,13 @@ class Otto
57
62
  params: { type: 'object' },
58
63
  },
59
64
  additionalProperties: false,
60
- },
61
- )
65
+ })
62
66
  end
63
67
  end
64
68
 
65
- class ValidationMiddleware
69
+ # Middleware for validating MCP protocol requests using JSON schema
70
+ # Validates JSON-RPC 2.0 structure and tool argument schemas
71
+ class SchemaValidationMiddleware
66
72
  def initialize(app, _security_config = nil)
67
73
  @app = app
68
74
  @validator = Validator.new
@@ -82,10 +88,10 @@ class Otto
82
88
 
83
89
  # Reset body for downstream middleware
84
90
  request.body.rewind if request.body.respond_to?(:rewind)
85
- rescue JSON::ParserError => ex
86
- return validation_error_response(nil, "Invalid JSON: #{ex.message}")
87
- rescue ValidationError => ex
88
- return validation_error_response(data&.dig('id'), ex.message)
91
+ rescue JSON::ParserError => e
92
+ return validation_error_response(nil, "Invalid JSON: #{e.message}")
93
+ rescue ValidationError => e
94
+ return validation_error_response(data&.dig('id'), e.message)
89
95
  end
90
96
  end
91
97
 
@@ -102,15 +108,14 @@ class Otto
102
108
 
103
109
  def validation_error_response(id, message)
104
110
  body = JSON.generate({
105
- jsonrpc: '2.0',
111
+ jsonrpc: '2.0',
106
112
  id: id,
107
113
  error: {
108
114
  code: -32_600,
109
115
  message: 'Invalid Request',
110
116
  data: message,
111
117
  },
112
- },
113
- )
118
+ })
114
119
 
115
120
  [400, { 'content-type' => 'application/json' }, [body]]
116
121
  end