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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/rate_limiting.rb
4
+
5
+ require 'json'
6
+
7
+ require_relative '../security/rate_limiting'
8
+
9
+ begin
10
+ require 'rack/attack'
11
+ rescue LoadError
12
+ # rack-attack is optional - graceful fallback
13
+ end
14
+
15
+ class Otto
16
+ module MCP
17
+ # Rate limiter for MCP protocol endpoints
18
+ class RateLimiter < Otto::Security::RateLimiting
19
+ def self.configure_rack_attack!(config = {})
20
+ return unless defined?(Rack::Attack)
21
+
22
+ # Start with base configuration from general rate limiting
23
+ super
24
+
25
+ # Add MCP-specific rules
26
+ configure_mcp_rules(config)
27
+ configure_mcp_responses
28
+ configure_mcp_logging
29
+ end
30
+
31
+ def self.configure_mcp_rules(config)
32
+ # MCP endpoint requests - 60 per minute by default
33
+ mcp_requests_limit = config[:mcp_requests_per_minute] || 60
34
+
35
+ Rack::Attack.throttle('mcp_requests', limit: mcp_requests_limit, period: 60) do |request|
36
+ endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
37
+ request.ip if request.path.start_with?(endpoint)
38
+ end
39
+
40
+ # Tool calls are more expensive - 20 per minute by default
41
+ tool_calls_limit = config[:tool_calls_per_minute] || 20
42
+
43
+ Rack::Attack.throttle('mcp_tool_calls', limit: tool_calls_limit, period: 60) do |request|
44
+ endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
45
+ if request.path.start_with?(endpoint) && request.post?
46
+ begin
47
+ body = request.body.read
48
+ data = JSON.parse(body)
49
+ request.ip if data['method'] == 'tools/call'
50
+ rescue JSON::ParserError
51
+ nil
52
+ ensure
53
+ request.body.rewind if request.body.respond_to?(:rewind)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def self.configure_mcp_responses
60
+ # Override throttled responder to provide JSON-RPC formatted responses for MCP requests
61
+ Rack::Attack.throttled_responder = lambda do |request|
62
+ match_data = request.env['rack.attack.match_data']
63
+ now = match_data[:epoch_time]
64
+
65
+ headers = {
66
+ 'content-type' => 'application/json',
67
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
68
+ }
69
+
70
+ # Check if this is an MCP request
71
+ endpoint = request.env['otto.mcp_http_endpoint'] || '/_mcp'
72
+ if request.path.start_with?(endpoint)
73
+ # JSON-RPC error response for MCP
74
+ error_response = {
75
+ jsonrpc: '2.0',
76
+ id: nil,
77
+ error: {
78
+ code: -32_000,
79
+ message: 'Rate limit exceeded',
80
+ data: {
81
+ retry_after: headers['retry-after'].to_i,
82
+ limit: match_data[:limit],
83
+ period: match_data[:period],
84
+ },
85
+ },
86
+ }
87
+ [429, headers, [JSON.generate(error_response)]]
88
+ else
89
+ # Use the general rate limiting response for non-MCP requests
90
+ accept_header = request.env['HTTP_ACCEPT'].to_s
91
+ if accept_header.include?('application/json')
92
+ error_response = {
93
+ error: 'Rate limit exceeded',
94
+ message: 'Too many requests',
95
+ retry_after: headers['retry-after'].to_i,
96
+ limit: match_data[:limit],
97
+ period: match_data[:period],
98
+ }
99
+ [429, headers, [JSON.generate(error_response)]]
100
+ else
101
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
102
+ headers['content-type'] = 'text/plain'
103
+ [429, headers, [body]]
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ def self.configure_mcp_logging
110
+ return unless defined?(ActiveSupport::Notifications)
111
+
112
+ ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
113
+ req = payload[:request]
114
+ endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
115
+
116
+ if req.path.start_with?(endpoint)
117
+ Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
118
+ else
119
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Middleware for applying rate limits to MCP protocol endpoints
126
+ class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
127
+ def initialize(app, security_config = nil)
128
+ @app = app
129
+ @security_config = security_config
130
+ @rate_limiter_available = defined?(Rack::Attack)
131
+
132
+ if @rate_limiter_available
133
+ configure_mcp_rate_limiting
134
+ else
135
+ Otto.logger.warn '[MCP] rack-attack not available - rate limiting disabled'
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def configure_mcp_rate_limiting
142
+ # Get base configuration from security config
143
+ base_config = @security_config&.rate_limiting_config || {}
144
+
145
+ # Add MCP-specific defaults
146
+ mcp_config = base_config.merge({
147
+ mcp_requests_per_minute: 60,
148
+ tool_calls_per_minute: 20,
149
+ })
150
+
151
+ RateLimiter.configure_rack_attack!(mcp_config)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/registry.rb
4
+
5
+ class Otto
6
+ module MCP
7
+ # Registry for managing MCP resources and tools
8
+ class Registry
9
+ def initialize
10
+ @resources = {}
11
+ @tools = {}
12
+ end
13
+
14
+ def register_resource(uri, name, description, mime_type, handler)
15
+ @resources[uri] = {
16
+ uri: uri,
17
+ name: name,
18
+ description: description,
19
+ mimeType: mime_type,
20
+ handler: handler,
21
+ }
22
+ end
23
+
24
+ def register_tool(name, description, input_schema, handler)
25
+ @tools[name] = {
26
+ name: name,
27
+ description: description,
28
+ inputSchema: input_schema,
29
+ handler: handler,
30
+ }
31
+ end
32
+
33
+ def list_resources
34
+ @resources.values.map do |resource|
35
+ {
36
+ uri: resource[:uri],
37
+ name: resource[:name],
38
+ description: resource[:description],
39
+ mimeType: resource[:mimeType],
40
+ }
41
+ end
42
+ end
43
+
44
+ def list_tools
45
+ @tools.values.map do |tool|
46
+ {
47
+ name: tool[:name],
48
+ description: tool[:description],
49
+ inputSchema: tool[:inputSchema],
50
+ }
51
+ end
52
+ end
53
+
54
+ def read_resource(uri)
55
+ resource = @resources[uri]
56
+ return nil unless resource
57
+
58
+ begin
59
+ content = resource[:handler].call
60
+ {
61
+ contents: [{
62
+ uri: uri,
63
+ mimeType: resource[:mimeType],
64
+ text: content.to_s,
65
+ }],
66
+ }
67
+ rescue StandardError => e
68
+ Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.message}"
69
+ nil
70
+ end
71
+ end
72
+
73
+ def call_tool(name, arguments, env)
74
+ tool = @tools[name]
75
+ raise "Tool not found: #{name}" unless tool
76
+
77
+ handler = tool[:handler]
78
+ if handler.respond_to?(:call)
79
+ result = handler.call(arguments, env)
80
+ elsif handler.is_a?(String) && handler.include?('.')
81
+ klass_method = handler.split('.')
82
+ klass_name = klass_method[0..-2].join('::')
83
+ method_name = klass_method.last
84
+
85
+ klass = Object.const_get(klass_name)
86
+ result = klass.public_send(method_name, arguments, env)
87
+ else
88
+ raise "Invalid tool handler: #{handler}"
89
+ end
90
+
91
+ {
92
+ content: [{
93
+ type: 'text',
94
+ text: result.to_s,
95
+ }],
96
+ }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/route_parser.rb
4
+
5
+ class Otto
6
+ module MCP
7
+ # Parser for MCP route definitions and resource URIs
8
+ class RouteParser
9
+ def self.parse_mcp_route(_verb, _path, definition)
10
+ # MCP route format: MCP resource_uri HandlerClass.method_name
11
+ # Note: The path parameter is ignored for MCP routes - resource_uri comes from definition
12
+ parts = definition.split(/\s+/, 3)
13
+
14
+ raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}" if parts[0] != 'MCP'
15
+
16
+ resource_uri = parts[1]
17
+ handler_definition = parts[2]
18
+
19
+ raise ArgumentError, "Invalid MCP route format: #{definition}" unless resource_uri && handler_definition
20
+
21
+ # Clean up URI - remove leading slash if present since MCP URIs are relative
22
+ resource_uri = resource_uri.sub(%r{^/}, '')
23
+
24
+ {
25
+ type: :mcp_resource,
26
+ resource_uri: resource_uri,
27
+ handler: handler_definition,
28
+ options: extract_options_from_handler(handler_definition),
29
+ }
30
+ end
31
+
32
+ def self.parse_tool_route(_verb, _path, definition)
33
+ # TOOL route format: TOOL tool_name HandlerClass.method_name
34
+ # Note: The path parameter is ignored for TOOL routes - tool_name comes from definition
35
+ parts = definition.split(/\s+/, 3)
36
+
37
+ raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}" if parts[0] != 'TOOL'
38
+
39
+ tool_name = parts[1]
40
+ handler_definition = parts[2]
41
+
42
+ raise ArgumentError, "Invalid TOOL route format: #{definition}" unless tool_name && handler_definition
43
+
44
+ # Clean up tool name - remove leading slash if present
45
+ tool_name = tool_name.sub(%r{^/}, '')
46
+
47
+ {
48
+ type: :mcp_tool,
49
+ tool_name: tool_name,
50
+ handler: handler_definition,
51
+ options: extract_options_from_handler(handler_definition),
52
+ }
53
+ end
54
+
55
+ def self.is_mcp_route?(definition)
56
+ definition.start_with?('MCP ')
57
+ end
58
+
59
+ def self.is_tool_route?(definition)
60
+ definition.start_with?('TOOL ')
61
+ end
62
+
63
+ def self.extract_options_from_handler(handler_definition)
64
+ parts = handler_definition.split(/\s+/)
65
+ options = {}
66
+
67
+ # First part is the handler class.method
68
+ parts[1..-1]&.each do |part|
69
+ key, value = part.split('=', 2)
70
+ options[key.to_sym] = value if key && value
71
+ end
72
+
73
+ options
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/server.rb
4
+
5
+ require_relative 'protocol'
6
+ require_relative 'registry'
7
+ require_relative 'route_parser'
8
+ require_relative 'auth/token'
9
+ require_relative 'validation'
10
+ require_relative 'rate_limiting'
11
+
12
+ class Otto
13
+ module MCP
14
+ # MCP server implementation providing Model Context Protocol endpoints
15
+ class Server
16
+ attr_reader :protocol, :otto_instance
17
+
18
+ def initialize(otto_instance)
19
+ @otto_instance = otto_instance
20
+ @protocol = Protocol.new(otto_instance)
21
+ @enabled = false
22
+ end
23
+
24
+ def enable!(options = {})
25
+ @enabled = true
26
+ @http_endpoint = options.fetch(:http_endpoint, '/_mcp')
27
+ @auth_tokens = options[:auth_tokens] || []
28
+ @enable_validation = options.fetch(:enable_validation, true)
29
+ @enable_rate_limiting = options.fetch(:enable_rate_limiting, true)
30
+
31
+ # Configure middleware
32
+ configure_middleware(options)
33
+
34
+ # Add MCP endpoint route to Otto
35
+ add_mcp_endpoint_route
36
+
37
+ Otto.logger.info "[MCP] Server enabled with HTTP endpoint: #{@http_endpoint}" if Otto.debug
38
+ end
39
+
40
+ def enabled?
41
+ @enabled
42
+ end
43
+
44
+ def register_mcp_route(route_info)
45
+ case route_info[:type]
46
+ when :mcp_resource
47
+ register_resource(route_info)
48
+ when :mcp_tool
49
+ register_tool(route_info)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def configure_middleware(_options)
56
+ # Configure middleware in security-optimal order:
57
+ # 1. Rate limiting (reject excessive requests early)
58
+ # 2. Authentication (validate credentials before parsing)
59
+ # 3. Validation (expensive JSON schema validation last)
60
+
61
+ # Configure rate limiting first
62
+ if @enable_rate_limiting
63
+ @otto_instance.use Otto::MCP::RateLimitMiddleware, @otto_instance.security_config
64
+ Otto.logger.debug '[MCP] Rate limiting enabled' if Otto.debug
65
+ end
66
+
67
+ # Configure authentication second
68
+ if @auth_tokens.any?
69
+ @auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
70
+ @otto_instance.security_config.mcp_auth = @auth
71
+ @otto_instance.use Otto::MCP::Auth::TokenMiddleware
72
+ Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
73
+ end
74
+
75
+ # Configure validation last (most expensive)
76
+ return unless @enable_validation
77
+
78
+ @otto_instance.use Otto::MCP::ValidationMiddleware
79
+ Otto.logger.debug '[MCP] Request validation enabled' if Otto.debug
80
+ end
81
+
82
+ def add_mcp_endpoint_route
83
+ InternalHandler.otto_instance = @otto_instance
84
+
85
+ mcp_route = Otto::Route.new('POST', @http_endpoint, 'Otto::MCP::InternalHandler.handle_request')
86
+ mcp_route.otto = @otto_instance
87
+
88
+ @otto_instance.routes[:POST] ||= []
89
+ @otto_instance.routes[:POST] << mcp_route
90
+
91
+ @otto_instance.routes_literal[:POST] ||= {}
92
+ @otto_instance.routes_literal[:POST][@http_endpoint] = mcp_route
93
+
94
+ # Ensure env carries endpoint for middlewares
95
+ @otto_instance.use proc { |app|
96
+ lambda { |env|
97
+ env['otto.mcp_http_endpoint'] = @http_endpoint
98
+ app.call(env)
99
+ }
100
+ }
101
+ end
102
+
103
+ def register_resource(route_info)
104
+ uri = route_info[:resource_uri]
105
+ handler_def = route_info[:handler]
106
+
107
+ # Parse handler definition
108
+ klass_method = handler_def.split(/\s+/).first.split('.')
109
+ klass_name = klass_method[0..-2].join('::')
110
+ method_name = klass_method.last
111
+
112
+ # Create resource handler
113
+ handler = lambda do
114
+ klass = Object.const_get(klass_name)
115
+ method = klass.method(method_name)
116
+ if method.arity != 0
117
+ raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"
118
+ end
119
+
120
+ klass.public_send(method_name)
121
+ rescue StandardError => e
122
+ Otto.logger.error "[MCP] Resource handler error for #{uri}: #{e.message}"
123
+ raise
124
+ end
125
+
126
+ # Register with protocol registry
127
+ @protocol.registry.register_resource(
128
+ uri,
129
+ extract_name_from_uri(uri),
130
+ "Resource: #{uri}",
131
+ 'text/plain',
132
+ handler
133
+ )
134
+
135
+ Otto.logger.debug "[MCP] Registered resource: #{uri} -> #{handler_def}" if Otto.debug
136
+ end
137
+
138
+ def register_tool(route_info)
139
+ name = route_info[:tool_name]
140
+ handler_def = route_info[:handler]
141
+
142
+ # Parse handler definition
143
+ klass_method = handler_def.split(/\s+/).first.split('.')
144
+ klass_name = klass_method[0..-2].join('::')
145
+ method_name = klass_method.last
146
+
147
+ # Create input schema - basic for now
148
+ input_schema = {
149
+ type: 'object',
150
+ properties: {},
151
+ required: [],
152
+ }
153
+
154
+ # Register with protocol registry
155
+ @protocol.registry.register_tool(
156
+ name,
157
+ "Tool: #{name}",
158
+ input_schema,
159
+ "#{klass_name}.#{method_name}"
160
+ )
161
+
162
+ Otto.logger.debug "[MCP] Registered tool: #{name} -> #{handler_def}" if Otto.debug
163
+ end
164
+
165
+ def extract_name_from_uri(uri)
166
+ uri.split('/').last || uri
167
+ end
168
+ end
169
+
170
+ # Internal handler class for MCP protocol endpoints
171
+ class InternalHandler
172
+ @otto_instance = nil
173
+
174
+ class << self
175
+ attr_writer :otto_instance
176
+ end
177
+
178
+ class << self
179
+ attr_reader :otto_instance
180
+ end
181
+
182
+ def self.handle_request(req, res)
183
+ otto_instance = @otto_instance
184
+
185
+ if otto_instance.nil?
186
+ return [500, { 'content-type' => 'application/json' },
187
+ [JSON.generate({ error: 'Otto instance not available' })]]
188
+ end
189
+
190
+ mcp_server = otto_instance.mcp_server
191
+
192
+ unless mcp_server&.enabled?
193
+ return [404, { 'content-type' => 'application/json' },
194
+ [JSON.generate({ error: 'MCP not enabled' })]]
195
+ end
196
+
197
+ status, headers, body = mcp_server.protocol.handle_request(req.env)
198
+
199
+ res.status = status
200
+ headers.each { |k, v| res[k] = v }
201
+ res.body = body
202
+ res.finish
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/mcp/validation.rb
4
+
5
+ require 'json'
6
+
7
+ begin
8
+ require 'json_schemer'
9
+ rescue LoadError
10
+ # json_schemer is optional - graceful fallback
11
+ end
12
+
13
+ class Otto
14
+ module MCP
15
+ class ValidationError < StandardError; end
16
+
17
+ # JSON Schema validator for MCP protocol requests
18
+ class Validator
19
+ def initialize
20
+ @schemas = {}
21
+ @json_schemer_available = defined?(JSONSchemer)
22
+ end
23
+
24
+ def validate_request(data)
25
+ return true unless @json_schemer_available
26
+
27
+ schema = mcp_request_schema
28
+ validation_errors = schema.validate(data).to_a
29
+
30
+ unless validation_errors.empty?
31
+ error_messages = validation_errors.map { |error| error['details'] || error['error'] || error.to_s }.join(', ')
32
+ raise ValidationError, "Invalid MCP request: #{error_messages}"
33
+ end
34
+
35
+ true
36
+ end
37
+
38
+ def validate_tool_arguments(tool_name, arguments, schema)
39
+ return true unless @json_schemer_available && schema
40
+
41
+ schemer = JSONSchemer.schema(schema)
42
+ validation_errors = schemer.validate(arguments).to_a
43
+
44
+ unless validation_errors.empty?
45
+ error_messages = validation_errors.map { |error| error['details'] || error['error'] || error.to_s }.join(', ')
46
+ raise ValidationError, "Invalid arguments for tool #{tool_name}: #{error_messages}"
47
+ end
48
+
49
+ true
50
+ end
51
+
52
+ private
53
+
54
+ def mcp_request_schema
55
+ @schemas[:mcp_request] ||= JSONSchemer.schema({
56
+ type: 'object',
57
+ required: %w[jsonrpc method id],
58
+ properties: {
59
+ jsonrpc: { const: '2.0' },
60
+ method: { type: 'string' },
61
+ id: {},
62
+ params: { type: 'object' },
63
+ },
64
+ additionalProperties: false,
65
+ })
66
+ end
67
+ end
68
+
69
+ # Middleware for validating MCP protocol requests using JSON schema
70
+ class ValidationMiddleware
71
+ def initialize(app, _security_config = nil)
72
+ @app = app
73
+ @validator = Validator.new
74
+ end
75
+
76
+ def call(env)
77
+ # Only validate MCP endpoints
78
+ return @app.call(env) unless mcp_endpoint?(env)
79
+
80
+ request = Rack::Request.new(env)
81
+
82
+ if request.post? && request.content_type&.include?('application/json')
83
+ begin
84
+ body = request.body.read
85
+ data = JSON.parse(body)
86
+ @validator.validate_request(data)
87
+
88
+ # Reset body for downstream middleware
89
+ request.body.rewind if request.body.respond_to?(:rewind)
90
+ rescue JSON::ParserError => e
91
+ return validation_error_response(nil, "Invalid JSON: #{e.message}")
92
+ rescue ValidationError => e
93
+ return validation_error_response(data&.dig('id'), e.message)
94
+ end
95
+ end
96
+
97
+ @app.call(env)
98
+ end
99
+
100
+ private
101
+
102
+ def mcp_endpoint?(env)
103
+ endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
104
+ path = env['PATH_INFO'].to_s
105
+ path.start_with?(endpoint)
106
+ end
107
+
108
+ def validation_error_response(id, message)
109
+ body = JSON.generate({
110
+ jsonrpc: '2.0',
111
+ id: id,
112
+ error: {
113
+ code: -32_600,
114
+ message: 'Invalid Request',
115
+ data: message,
116
+ },
117
+ })
118
+
119
+ [400, { 'content-type' => 'application/json' }, [body]]
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'json'
5
+ require_relative 'redirect'
6
+ require_relative 'view'
7
+ require_relative 'default'
8
+
9
+ class Otto
10
+ module ResponseHandlers
11
+ # Auto-detection handler that chooses appropriate handler based on context
12
+ class AutoHandler < BaseHandler
13
+ def self.handle(result, response, context = {})
14
+ # Auto-detect based on result type and request context
15
+ handler_class = detect_handler_type(result, response, context)
16
+ handler_class.handle(result, response, context)
17
+ end
18
+
19
+ def self.detect_handler_type(result, response, context)
20
+ # Check if response type was already set by the handler
21
+ content_type = response['Content-Type']
22
+
23
+ if content_type&.include?('application/json')
24
+ JSONHandler
25
+ elsif (context[:logic_instance]&.respond_to?(:redirect_path) && context[:logic_instance].redirect_path) ||
26
+ (result.is_a?(String) && result.match?(%r{^/}))
27
+ # Logic instance has redirect path or result is a string path
28
+ RedirectHandler
29
+ elsif result.is_a?(Hash)
30
+ JSONHandler
31
+ elsif context[:logic_instance]&.respond_to?(:view)
32
+ ViewHandler
33
+ else
34
+ DefaultHandler
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end