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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +44 -5
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +28 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +9 -9
- data/lib/otto/route_definition.rb +30 -33
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +29 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -376
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +15 -11
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +16 -0
- data/lib/otto/security/validator.rb +8 -292
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- 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
|