mcp_on_ruby 0.1.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 +7 -0
- data/CHANGELOG.md +13 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +92 -0
- data/LICENSE.txt +21 -0
- data/README.md +491 -0
- data/lib/ruby_mcp/configuration.rb +39 -0
- data/lib/ruby_mcp/errors.rb +17 -0
- data/lib/ruby_mcp/models/context.rb +51 -0
- data/lib/ruby_mcp/models/engine.rb +31 -0
- data/lib/ruby_mcp/models/message.rb +60 -0
- data/lib/ruby_mcp/providers/anthropic.rb +269 -0
- data/lib/ruby_mcp/providers/base.rb +57 -0
- data/lib/ruby_mcp/providers/openai.rb +265 -0
- data/lib/ruby_mcp/schemas.rb +56 -0
- data/lib/ruby_mcp/server/app.rb +84 -0
- data/lib/ruby_mcp/server/base_controller.rb +49 -0
- data/lib/ruby_mcp/server/content_controller.rb +68 -0
- data/lib/ruby_mcp/server/contexts_controller.rb +63 -0
- data/lib/ruby_mcp/server/controller.rb +29 -0
- data/lib/ruby_mcp/server/engines_controller.rb +34 -0
- data/lib/ruby_mcp/server/generate_controller.rb +140 -0
- data/lib/ruby_mcp/server/messages_controller.rb +30 -0
- data/lib/ruby_mcp/server/router.rb +84 -0
- data/lib/ruby_mcp/storage/base.rb +43 -0
- data/lib/ruby_mcp/storage/memory.rb +69 -0
- data/lib/ruby_mcp/validator.rb +45 -0
- data/lib/ruby_mcp/version.rb +6 -0
- data/lib/ruby_mcp.rb +52 -0
- metadata +274 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :providers, :storage, :server_port, :server_host,
|
6
|
+
:auth_required, :jwt_secret, :token_expiry, :max_contexts
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@providers = {}
|
10
|
+
@storage = :memory
|
11
|
+
@server_port = 3000
|
12
|
+
@server_host = '0.0.0.0'
|
13
|
+
@auth_required = false
|
14
|
+
@jwt_secret = nil
|
15
|
+
@token_expiry = 3600 # 1 hour
|
16
|
+
@max_contexts = 1000
|
17
|
+
end
|
18
|
+
|
19
|
+
def storage_instance
|
20
|
+
@storage_instance ||= case @storage
|
21
|
+
when :memory
|
22
|
+
RubyMCP::Storage::Memory.new
|
23
|
+
when :redis
|
24
|
+
# Future implementation
|
25
|
+
raise RubyMCP::Errors::ConfigurationError, 'Redis storage not yet implemented'
|
26
|
+
when :active_record
|
27
|
+
# Future implementation
|
28
|
+
raise RubyMCP::Errors::ConfigurationError, 'ActiveRecord storage not yet implemented'
|
29
|
+
else
|
30
|
+
unless @storage.is_a?(RubyMCP::Storage::Base)
|
31
|
+
raise RubyMCP::Errors::ConfigurationError, "Unknown storage type: #{@storage}"
|
32
|
+
end
|
33
|
+
|
34
|
+
@storage # Allow custom storage instance
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Errors
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
class ConfigurationError < Error; end
|
8
|
+
class AuthenticationError < Error; end
|
9
|
+
class ValidationError < Error; end
|
10
|
+
class ProviderError < Error; end
|
11
|
+
class ContextError < Error; end
|
12
|
+
class EngineError < Error; end
|
13
|
+
class MessageError < Error; end
|
14
|
+
class ContentError < Error; end
|
15
|
+
class ServerError < Error; end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module RubyMCP
|
6
|
+
module Models
|
7
|
+
class Context
|
8
|
+
attr_reader :id, :messages, :content_map, :created_at, :updated_at
|
9
|
+
attr_accessor :metadata
|
10
|
+
|
11
|
+
def initialize(id: nil, messages: [], metadata: {})
|
12
|
+
@id = id || "ctx_#{SecureRandom.hex(10)}"
|
13
|
+
@messages = messages || []
|
14
|
+
@content_map = {}
|
15
|
+
@metadata = metadata || {}
|
16
|
+
@created_at = Time.now.utc
|
17
|
+
@updated_at = @created_at
|
18
|
+
end
|
19
|
+
|
20
|
+
def add_message(message)
|
21
|
+
@messages << message
|
22
|
+
@updated_at = Time.now.utc
|
23
|
+
message
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_content(content_id, content_data)
|
27
|
+
@content_map[content_id] = content_data
|
28
|
+
@updated_at = Time.now.utc
|
29
|
+
content_id
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_content(content_id)
|
33
|
+
@content_map[content_id]
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_h
|
37
|
+
{
|
38
|
+
id: @id,
|
39
|
+
messages: @messages.map(&:to_h),
|
40
|
+
created_at: @created_at.iso8601,
|
41
|
+
updated_at: @updated_at.iso8601,
|
42
|
+
metadata: @metadata
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def estimated_token_count
|
47
|
+
@messages.sum(&:estimated_token_count)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Models
|
5
|
+
class Engine
|
6
|
+
attr_reader :id, :provider, :model, :capabilities, :config
|
7
|
+
|
8
|
+
def initialize(id:, provider:, model:, capabilities: [], config: {})
|
9
|
+
@id = id
|
10
|
+
@provider = provider
|
11
|
+
@model = model
|
12
|
+
@capabilities = capabilities
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_h
|
17
|
+
{
|
18
|
+
id: @id,
|
19
|
+
provider: @provider,
|
20
|
+
model: @model,
|
21
|
+
capabilities: @capabilities,
|
22
|
+
config: @config
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def supports?(capability)
|
27
|
+
@capabilities.include?(capability.to_s)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module RubyMCP
|
6
|
+
module Models
|
7
|
+
class Message
|
8
|
+
VALID_ROLES = %w[user assistant system tool].freeze
|
9
|
+
|
10
|
+
attr_reader :id, :role, :content, :created_at
|
11
|
+
attr_accessor :metadata
|
12
|
+
|
13
|
+
def initialize(role:, content:, id: nil, metadata: {})
|
14
|
+
@id = id || "msg_#{SecureRandom.hex(10)}"
|
15
|
+
|
16
|
+
unless VALID_ROLES.include?(role)
|
17
|
+
raise RubyMCP::Errors::ValidationError, "Invalid role: #{role}. Must be one of: #{VALID_ROLES.join(', ')}"
|
18
|
+
end
|
19
|
+
|
20
|
+
@role = role
|
21
|
+
@content = content
|
22
|
+
@created_at = Time.now.utc
|
23
|
+
@metadata = metadata || {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_h
|
27
|
+
{
|
28
|
+
id: @id,
|
29
|
+
role: @role,
|
30
|
+
content: @content,
|
31
|
+
created_at: @created_at.iso8601,
|
32
|
+
metadata: @metadata
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def content_type
|
37
|
+
return 'array' if @content.is_a?(Array)
|
38
|
+
|
39
|
+
'text'
|
40
|
+
end
|
41
|
+
|
42
|
+
def estimated_token_count
|
43
|
+
# Very basic estimation, would need to be improved with a real tokenizer
|
44
|
+
if @content.is_a?(String)
|
45
|
+
@content.split(/\s+/).size
|
46
|
+
elsif @content.is_a?(Array)
|
47
|
+
@content.sum do |part|
|
48
|
+
if part.is_a?(String) || part[:text]
|
49
|
+
(part.is_a?(String) ? part : part[:text]).split(/\s+/).size
|
50
|
+
else
|
51
|
+
10 # Arbitrary count for non-text content
|
52
|
+
end
|
53
|
+
end
|
54
|
+
else
|
55
|
+
10 # Default for unknown content format
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,269 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Providers
|
5
|
+
class Anthropic < Base
|
6
|
+
MODELS = [
|
7
|
+
{
|
8
|
+
id: 'claude-3-opus-20240229',
|
9
|
+
capabilities: %w[text-generation streaming tool-calls]
|
10
|
+
},
|
11
|
+
{
|
12
|
+
id: 'claude-3-sonnet-20240229',
|
13
|
+
capabilities: %w[text-generation streaming tool-calls]
|
14
|
+
},
|
15
|
+
{
|
16
|
+
id: 'claude-3-haiku-20240307',
|
17
|
+
capabilities: %w[text-generation streaming tool-calls]
|
18
|
+
},
|
19
|
+
{
|
20
|
+
id: 'claude-2.1',
|
21
|
+
capabilities: %w[text-generation streaming]
|
22
|
+
},
|
23
|
+
{
|
24
|
+
id: 'claude-2.0',
|
25
|
+
capabilities: %w[text-generation streaming]
|
26
|
+
},
|
27
|
+
{
|
28
|
+
id: 'claude-instant-1.2',
|
29
|
+
capabilities: %w[text-generation streaming]
|
30
|
+
}
|
31
|
+
].freeze
|
32
|
+
|
33
|
+
def list_engines
|
34
|
+
# Anthropic doesn't have an endpoint to list models, so we use a static list
|
35
|
+
MODELS.map do |model_info|
|
36
|
+
RubyMCP::Models::Engine.new(
|
37
|
+
id: "anthropic/#{model_info[:id]}",
|
38
|
+
provider: 'anthropic',
|
39
|
+
model: model_info[:id],
|
40
|
+
capabilities: model_info[:capabilities]
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def generate(context, options = {})
|
46
|
+
messages = format_messages(context)
|
47
|
+
|
48
|
+
payload = {
|
49
|
+
model: options[:model],
|
50
|
+
messages: messages,
|
51
|
+
max_tokens: options[:max_tokens] || 4096,
|
52
|
+
temperature: options[:temperature],
|
53
|
+
top_p: options[:top_p],
|
54
|
+
stop_sequences: options[:stop]
|
55
|
+
}.compact
|
56
|
+
|
57
|
+
if options[:tools]
|
58
|
+
payload[:tools] = options[:tools]
|
59
|
+
payload[:tool_choice] = options[:tool_choice] || 'auto'
|
60
|
+
end
|
61
|
+
|
62
|
+
headers = {
|
63
|
+
'Anthropic-Version' => '2023-06-01',
|
64
|
+
'Content-Type' => 'application/json'
|
65
|
+
}
|
66
|
+
|
67
|
+
response = create_client.post('messages') do |req|
|
68
|
+
req.headers.merge!(headers)
|
69
|
+
req.body = payload.to_json
|
70
|
+
end
|
71
|
+
|
72
|
+
unless response.success?
|
73
|
+
raise RubyMCP::Errors::ProviderError,
|
74
|
+
"Anthropic generation failed: #{response.body['error']&.dig('message') || response.status}"
|
75
|
+
end
|
76
|
+
|
77
|
+
content = response.body['content']&.first&.dig('text')
|
78
|
+
tool_calls = nil
|
79
|
+
|
80
|
+
# Handle tool calls
|
81
|
+
if response.body['tool_calls']
|
82
|
+
tool_calls = response.body['tool_calls'].map do |tc|
|
83
|
+
{
|
84
|
+
id: tc['id'],
|
85
|
+
type: 'function',
|
86
|
+
function: {
|
87
|
+
name: tc['name'],
|
88
|
+
arguments: tc['input']
|
89
|
+
}
|
90
|
+
}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
result = {
|
95
|
+
provider: 'anthropic',
|
96
|
+
model: options[:model],
|
97
|
+
created_at: Time.now.utc.iso8601
|
98
|
+
}
|
99
|
+
|
100
|
+
if tool_calls
|
101
|
+
result[:tool_calls] = tool_calls
|
102
|
+
else
|
103
|
+
result[:content] = content
|
104
|
+
end
|
105
|
+
|
106
|
+
result
|
107
|
+
end
|
108
|
+
|
109
|
+
def generate_stream(context, options = {})
|
110
|
+
messages = format_messages(context)
|
111
|
+
|
112
|
+
payload = {
|
113
|
+
model: options[:model],
|
114
|
+
messages: messages,
|
115
|
+
max_tokens: options[:max_tokens] || 4096,
|
116
|
+
temperature: options[:temperature],
|
117
|
+
top_p: options[:top_p],
|
118
|
+
stop_sequences: options[:stop],
|
119
|
+
stream: true
|
120
|
+
}.compact
|
121
|
+
|
122
|
+
if options[:tools]
|
123
|
+
payload[:tools] = options[:tools]
|
124
|
+
payload[:tool_choice] = options[:tool_choice] || 'auto'
|
125
|
+
end
|
126
|
+
|
127
|
+
headers = {
|
128
|
+
'Anthropic-Version' => '2023-06-01',
|
129
|
+
'Content-Type' => 'application/json'
|
130
|
+
}
|
131
|
+
|
132
|
+
conn = create_client
|
133
|
+
|
134
|
+
# Update the client to handle streaming
|
135
|
+
conn.options.timeout = 120 # Longer timeout for streaming
|
136
|
+
|
137
|
+
generation_id = SecureRandom.uuid
|
138
|
+
content_buffer = ''
|
139
|
+
current_tool_calls = []
|
140
|
+
|
141
|
+
# Initial event
|
142
|
+
yield({
|
143
|
+
id: generation_id,
|
144
|
+
event: 'generation.start',
|
145
|
+
created_at: Time.now.utc.iso8601
|
146
|
+
})
|
147
|
+
|
148
|
+
begin
|
149
|
+
conn.post('messages') do |req|
|
150
|
+
req.headers.merge!(headers)
|
151
|
+
req.body = payload.to_json
|
152
|
+
req.options.on_data = proc do |chunk, _size, _total|
|
153
|
+
next if chunk.strip.empty?
|
154
|
+
|
155
|
+
# Process each SSE event
|
156
|
+
chunk.split('data: ').each do |data|
|
157
|
+
next if data.strip.empty?
|
158
|
+
|
159
|
+
begin
|
160
|
+
json = JSON.parse(data.strip)
|
161
|
+
|
162
|
+
case json['type']
|
163
|
+
when 'content_block_delta'
|
164
|
+
delta = json['delta']['text']
|
165
|
+
content_buffer += delta
|
166
|
+
|
167
|
+
# Send content update
|
168
|
+
yield({
|
169
|
+
id: generation_id,
|
170
|
+
event: 'generation.content',
|
171
|
+
created_at: Time.now.utc.iso8601,
|
172
|
+
content: delta
|
173
|
+
})
|
174
|
+
when 'tool_call'
|
175
|
+
tool_call = {
|
176
|
+
'id' => json['id'],
|
177
|
+
'type' => 'function',
|
178
|
+
'function' => {
|
179
|
+
'name' => json['name'],
|
180
|
+
'arguments' => json['input']
|
181
|
+
}
|
182
|
+
}
|
183
|
+
|
184
|
+
current_tool_calls << tool_call
|
185
|
+
|
186
|
+
# Send tool call update
|
187
|
+
yield({
|
188
|
+
id: generation_id,
|
189
|
+
event: 'generation.tool_call',
|
190
|
+
created_at: Time.now.utc.iso8601,
|
191
|
+
tool_calls: current_tool_calls
|
192
|
+
})
|
193
|
+
when 'message_stop'
|
194
|
+
# Handled by the final event after the streaming is done
|
195
|
+
end
|
196
|
+
rescue JSON::ParserError => e
|
197
|
+
# Skip invalid JSON
|
198
|
+
RubyMCP.logger.warn "Invalid JSON in Anthropic stream: #{e.message}"
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
rescue Faraday::Error => e
|
204
|
+
raise RubyMCP::Errors::ProviderError, "Anthropic streaming failed: #{e.message}"
|
205
|
+
end
|
206
|
+
|
207
|
+
# Final event
|
208
|
+
if current_tool_calls.any?
|
209
|
+
# Final tool calls event
|
210
|
+
yield({
|
211
|
+
id: generation_id,
|
212
|
+
event: 'generation.complete',
|
213
|
+
created_at: Time.now.utc.iso8601,
|
214
|
+
tool_calls: current_tool_calls
|
215
|
+
})
|
216
|
+
else
|
217
|
+
# Final content event
|
218
|
+
yield({
|
219
|
+
id: generation_id,
|
220
|
+
event: 'generation.complete',
|
221
|
+
created_at: Time.now.utc.iso8601,
|
222
|
+
content: content_buffer
|
223
|
+
})
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def abort_generation(_generation_id)
|
228
|
+
# Anthropic doesn't support aborting generations yet
|
229
|
+
raise RubyMCP::Errors::ProviderError, "Anthropic doesn't support aborting generations"
|
230
|
+
end
|
231
|
+
|
232
|
+
protected
|
233
|
+
|
234
|
+
def default_api_base
|
235
|
+
'https://api.anthropic.com/v1'
|
236
|
+
end
|
237
|
+
|
238
|
+
private
|
239
|
+
|
240
|
+
def format_messages(context)
|
241
|
+
context.messages.map do |msg|
|
242
|
+
# Convert to Anthropic's message format
|
243
|
+
if msg.content_type == 'array'
|
244
|
+
# Handle structured content
|
245
|
+
content_parts = []
|
246
|
+
|
247
|
+
msg.content.each do |part|
|
248
|
+
if part.is_a?(String)
|
249
|
+
content_parts << { 'type' => 'text', 'text' => part }
|
250
|
+
elsif part.is_a?(Hash)
|
251
|
+
if part[:type] == 'text'
|
252
|
+
content_parts << { 'type' => 'text', 'text' => part[:text] }
|
253
|
+
elsif part[:type] == 'content_pointer'
|
254
|
+
# We don't have file IDs for Anthropic here
|
255
|
+
content_parts << { 'type' => 'text', 'text' => "[Content reference: #{part[:content_id]}]" }
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
{ 'role' => msg.role, 'content' => content_parts }
|
261
|
+
else
|
262
|
+
# Simple text content
|
263
|
+
{ 'role' => msg.role, 'content' => msg.content }
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Providers
|
5
|
+
class Base
|
6
|
+
attr_reader :config
|
7
|
+
|
8
|
+
def initialize(config = {})
|
9
|
+
@config = config
|
10
|
+
end
|
11
|
+
|
12
|
+
def list_engines
|
13
|
+
raise NotImplementedError, "#{self.class.name} must implement #list_engines"
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate(context, options = {})
|
17
|
+
raise NotImplementedError, "#{self.class.name} must implement #generate"
|
18
|
+
end
|
19
|
+
|
20
|
+
def generate_stream(context, options = {}, &block)
|
21
|
+
raise NotImplementedError, "#{self.class.name} must implement #generate_stream"
|
22
|
+
end
|
23
|
+
|
24
|
+
def abort_generation(generation_id)
|
25
|
+
raise NotImplementedError, "#{self.class.name} must implement #abort_generation"
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
|
30
|
+
def api_key
|
31
|
+
@config[:api_key] || ENV["#{provider_name.upcase}_API_KEY"]
|
32
|
+
end
|
33
|
+
|
34
|
+
def api_base
|
35
|
+
@config[:api_base] || default_api_base
|
36
|
+
end
|
37
|
+
|
38
|
+
def provider_name
|
39
|
+
self.class.name.split('::').last.downcase
|
40
|
+
end
|
41
|
+
|
42
|
+
def default_api_base
|
43
|
+
raise NotImplementedError, "#{self.class.name} must implement #default_api_base"
|
44
|
+
end
|
45
|
+
|
46
|
+
def create_client
|
47
|
+
Faraday.new(url: api_base) do |conn|
|
48
|
+
conn.request :json
|
49
|
+
conn.response :json
|
50
|
+
conn.adapter :net_http
|
51
|
+
conn.headers['Authorization'] = "Bearer #{api_key}"
|
52
|
+
conn.headers['Content-Type'] = 'application/json'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|