otto 1.5.0 → 1.6.0

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