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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +43 -4
- data/.rubocop.yml +1 -1
- data/Gemfile +12 -3
- data/Gemfile.lock +51 -8
- data/bin/rspec +16 -0
- data/examples/mcp_demo/app.rb +56 -0
- data/examples/mcp_demo/config.ru +68 -0
- data/examples/mcp_demo/routes +9 -0
- data/lib/concurrent_cache_store.rb +68 -0
- data/lib/otto/helpers/validation.rb +83 -0
- data/lib/otto/mcp/auth/token.rb +76 -0
- data/lib/otto/mcp/protocol.rb +167 -0
- data/lib/otto/mcp/rate_limiting.rb +150 -0
- data/lib/otto/mcp/registry.rb +95 -0
- data/lib/otto/mcp/route_parser.rb +82 -0
- data/lib/otto/mcp/server.rb +196 -0
- data/lib/otto/mcp/validation.rb +119 -0
- data/lib/otto/route_definition.rb +15 -15
- data/lib/otto/route_handlers.rb +126 -97
- data/lib/otto/security/config.rb +3 -1
- data/lib/otto/security/rate_limiting.rb +111 -0
- data/lib/otto/security/validator.rb +35 -74
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +127 -1
- data/otto.gemspec +11 -6
- metadata +67 -19
@@ -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
|