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.
@@ -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