mcp_on_ruby 0.3.0 → 2.0.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -26
  3. data/LICENSE.txt +2 -2
  4. data/README.md +308 -462
  5. data/config/routes.rb +5 -0
  6. data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
  7. data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
  8. data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
  9. data/lib/mcp_on_ruby/call_context.rb +29 -0
  10. data/lib/mcp_on_ruby/configuration.rb +78 -0
  11. data/lib/mcp_on_ruby/controller.rb +60 -0
  12. data/lib/mcp_on_ruby/declaration.rb +24 -0
  13. data/lib/mcp_on_ruby/dispatcher.rb +97 -0
  14. data/lib/mcp_on_ruby/doctor.rb +67 -0
  15. data/lib/mcp_on_ruby/endpoint.rb +43 -0
  16. data/lib/mcp_on_ruby/engine.rb +29 -0
  17. data/lib/mcp_on_ruby/registry.rb +146 -0
  18. data/lib/mcp_on_ruby/result.rb +83 -0
  19. data/lib/mcp_on_ruby/rspec.rb +8 -0
  20. data/lib/mcp_on_ruby/schema.rb +149 -0
  21. data/lib/mcp_on_ruby/server_factory.rb +66 -0
  22. data/lib/mcp_on_ruby/stdio.rb +42 -0
  23. data/lib/mcp_on_ruby/test_helper.rb +86 -0
  24. data/lib/mcp_on_ruby/tool_spec.rb +87 -0
  25. data/lib/mcp_on_ruby/tool_table.rb +31 -0
  26. data/lib/mcp_on_ruby/version.rb +5 -0
  27. data/lib/mcp_on_ruby/warden_hook.rb +21 -0
  28. data/lib/mcp_on_ruby.rb +64 -0
  29. data/lib/tasks/mcp_on_ruby.rake +20 -0
  30. metadata +63 -245
  31. data/CODE_OF_CONDUCT.md +0 -84
  32. data/CONTRIBUTING.md +0 -92
  33. data/lib/ruby_mcp/client.rb +0 -43
  34. data/lib/ruby_mcp/configuration.rb +0 -90
  35. data/lib/ruby_mcp/errors.rb +0 -17
  36. data/lib/ruby_mcp/models/context.rb +0 -52
  37. data/lib/ruby_mcp/models/engine.rb +0 -31
  38. data/lib/ruby_mcp/models/message.rb +0 -60
  39. data/lib/ruby_mcp/providers/anthropic.rb +0 -269
  40. data/lib/ruby_mcp/providers/base.rb +0 -57
  41. data/lib/ruby_mcp/providers/openai.rb +0 -265
  42. data/lib/ruby_mcp/schemas.rb +0 -56
  43. data/lib/ruby_mcp/server/app.rb +0 -84
  44. data/lib/ruby_mcp/server/base_controller.rb +0 -49
  45. data/lib/ruby_mcp/server/content_controller.rb +0 -68
  46. data/lib/ruby_mcp/server/contexts_controller.rb +0 -67
  47. data/lib/ruby_mcp/server/controller.rb +0 -29
  48. data/lib/ruby_mcp/server/engines_controller.rb +0 -34
  49. data/lib/ruby_mcp/server/generate_controller.rb +0 -140
  50. data/lib/ruby_mcp/server/messages_controller.rb +0 -30
  51. data/lib/ruby_mcp/server/router.rb +0 -84
  52. data/lib/ruby_mcp/storage/active_record.rb +0 -414
  53. data/lib/ruby_mcp/storage/base.rb +0 -43
  54. data/lib/ruby_mcp/storage/error.rb +0 -8
  55. data/lib/ruby_mcp/storage/memory.rb +0 -69
  56. data/lib/ruby_mcp/storage/redis.rb +0 -197
  57. data/lib/ruby_mcp/storage_factory.rb +0 -43
  58. data/lib/ruby_mcp/validator.rb +0 -45
  59. data/lib/ruby_mcp/version.rb +0 -6
  60. data/lib/ruby_mcp.rb +0 -71
@@ -1,43 +0,0 @@
1
- # lib/ruby_mcp/client.rb
2
- # frozen_string_literal: true
3
-
4
- module RubyMCP
5
- class Client
6
- attr_reader :storage
7
-
8
- def initialize(storage)
9
- @storage = storage
10
- end
11
-
12
- def create_context(messages = [], metadata = {})
13
- context = RubyMCP::Models::Context.new(messages: messages, metadata: metadata)
14
- storage.create_context(context)
15
- end
16
-
17
- def list_contexts(limit: 50, offset: 0)
18
- storage.list_contexts(limit: limit, offset: offset)
19
- end
20
-
21
- def get_context(context_id)
22
- storage.get_context(context_id)
23
- end
24
-
25
- def delete_context(context_id)
26
- storage.delete_context(context_id)
27
- end
28
-
29
- def add_message(context_id, role, content, metadata: {})
30
- message = RubyMCP::Models::Message.new(role: role, content: content, metadata: metadata)
31
- storage.add_message(context_id, message)
32
- end
33
-
34
- def add_content(context_id, content_data, content_id = nil)
35
- content_id ||= "cnt_#{SecureRandom.hex(10)}"
36
- storage.add_content(context_id, content_id, content_data)
37
- end
38
-
39
- def get_content(context_id, content_id)
40
- storage.get_content(context_id, content_id)
41
- end
42
- end
43
- end
@@ -1,90 +0,0 @@
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
- :redis, :active_record
8
-
9
- def initialize
10
- @providers = {}
11
- @storage = :memory
12
- @server_port = 3000
13
- @server_host = '0.0.0.0'
14
- @auth_required = false
15
- @jwt_secret = nil
16
- @token_expiry = 3600 # 1 hour
17
- @max_contexts = 1000
18
- @redis = {} # Default empty Redis config
19
- @active_record = {} # Default empty ActiveRecord config
20
- end
21
-
22
- def storage_config
23
- case @storage
24
- when :redis
25
- {
26
- type: :redis,
27
- connection: redis_connection_config,
28
- namespace: @redis[:namespace] || 'ruby_mcp',
29
- ttl: @redis[:ttl] || 86_400
30
- }
31
- when :active_record
32
- {
33
- type: :active_record,
34
- connection: @active_record[:connection],
35
- table_prefix: @active_record[:table_prefix] || 'mcp_'
36
- }
37
- else
38
- { type: @storage }
39
- end
40
- end
41
-
42
- def storage_instance
43
- @storage_instance ||= case @storage
44
- when :memory
45
- RubyMCP::Storage::Memory.new
46
- when :redis
47
- begin
48
- require 'redis'
49
- require_relative 'storage/redis'
50
- RubyMCP::Storage::Redis.new(storage_config)
51
- rescue LoadError
52
- raise RubyMCP::Errors::ConfigurationError,
53
- "Redis storage requires the redis gem. Add it to your Gemfile with:
54
- gem 'redis', '~> 5.0'"
55
- end
56
- when :active_record
57
- begin
58
- require 'active_record'
59
- require_relative 'storage/active_record'
60
- RubyMCP::Storage::ActiveRecord.new(storage_config)
61
- rescue LoadError
62
- raise RubyMCP::Errors::ConfigurationError,
63
- "ActiveRecord storage requires the activerecord gem. Add it to your Gemfile with:
64
- gem 'activerecord', '~> 6.0'"
65
- end
66
- else
67
- unless @storage.is_a?(RubyMCP::Storage::Base)
68
- raise RubyMCP::Errors::ConfigurationError, "Unknown storage type: #{@storage}"
69
- end
70
-
71
- @storage # Allow custom storage instance
72
- end
73
- end
74
-
75
- private
76
-
77
- def redis_connection_config
78
- if @redis[:url]
79
- { url: @redis[:url] }
80
- else
81
- {
82
- host: @redis[:host] || 'localhost',
83
- port: @redis[:port] || 6379,
84
- db: @redis[:db] || 0,
85
- password: @redis[:password]
86
- }.compact
87
- end
88
- end
89
- end
90
- end
@@ -1,17 +0,0 @@
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
@@ -1,52 +0,0 @@
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
- content_map: @content_map,
41
- created_at: @created_at.iso8601,
42
- updated_at: @updated_at.iso8601,
43
- metadata: @metadata
44
- }
45
- end
46
-
47
- def estimated_token_count
48
- @messages.sum(&:estimated_token_count)
49
- end
50
- end
51
- end
52
- end
@@ -1,31 +0,0 @@
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
@@ -1,60 +0,0 @@
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
@@ -1,269 +0,0 @@
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
@@ -1,57 +0,0 @@
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