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,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Server
|
5
|
+
class GenerateController < BaseController
|
6
|
+
def create
|
7
|
+
context_id = params[:context_id]
|
8
|
+
engine_id = params[:engine_id]
|
9
|
+
|
10
|
+
begin
|
11
|
+
# Get the context
|
12
|
+
context = storage.get_context(context_id)
|
13
|
+
|
14
|
+
# Find the provider
|
15
|
+
provider_name, model = parse_engine_id(engine_id)
|
16
|
+
provider = get_provider(provider_name)
|
17
|
+
|
18
|
+
# Generation options
|
19
|
+
options = {
|
20
|
+
model: model,
|
21
|
+
max_tokens: params[:max_tokens],
|
22
|
+
temperature: params[:temperature],
|
23
|
+
top_p: params[:top_p],
|
24
|
+
frequency_penalty: params[:frequency_penalty],
|
25
|
+
presence_penalty: params[:presence_penalty],
|
26
|
+
stop: params[:stop]
|
27
|
+
}.compact
|
28
|
+
|
29
|
+
# Generate the response
|
30
|
+
response = provider.generate(context, options)
|
31
|
+
|
32
|
+
# Add the assistant message to the context if requested
|
33
|
+
if params[:update_context] != false
|
34
|
+
message = RubyMCP::Models::Message.new(
|
35
|
+
role: 'assistant',
|
36
|
+
content: response[:content],
|
37
|
+
metadata: response[:metadata]
|
38
|
+
)
|
39
|
+
storage.add_message(context_id, message)
|
40
|
+
end
|
41
|
+
|
42
|
+
ok(response)
|
43
|
+
rescue RubyMCP::Errors::ContextError => e
|
44
|
+
not_found(e.message)
|
45
|
+
rescue RubyMCP::Errors::ProviderError, RubyMCP::Errors::EngineError => e
|
46
|
+
bad_request(e.message)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def stream
|
51
|
+
context_id = params[:context_id]
|
52
|
+
engine_id = params[:engine_id]
|
53
|
+
|
54
|
+
# Check that we have a streaming-compatible request
|
55
|
+
return bad_request('Streaming requires HTTP/1.1 or higher') unless request.env['HTTP_VERSION'] == 'HTTP/1.1'
|
56
|
+
|
57
|
+
begin
|
58
|
+
# Get the context
|
59
|
+
context = storage.get_context(context_id)
|
60
|
+
|
61
|
+
# Find the provider
|
62
|
+
provider_name, model = parse_engine_id(engine_id)
|
63
|
+
provider = get_provider(provider_name)
|
64
|
+
|
65
|
+
# Generation options
|
66
|
+
options = {
|
67
|
+
model: model,
|
68
|
+
max_tokens: params[:max_tokens],
|
69
|
+
temperature: params[:temperature],
|
70
|
+
top_p: params[:top_p],
|
71
|
+
frequency_penalty: params[:frequency_penalty],
|
72
|
+
presence_penalty: params[:presence_penalty],
|
73
|
+
stop: params[:stop]
|
74
|
+
}.compact
|
75
|
+
|
76
|
+
# Prepare streaming response
|
77
|
+
headers = {
|
78
|
+
'Content-Type' => 'text/event-stream',
|
79
|
+
'Cache-Control' => 'no-cache',
|
80
|
+
'Connection' => 'keep-alive'
|
81
|
+
}
|
82
|
+
|
83
|
+
# Start streaming
|
84
|
+
chunked_body = Enumerator.new do |yielder|
|
85
|
+
complete_message = ''
|
86
|
+
|
87
|
+
# Stream the response
|
88
|
+
provider.generate_stream(context, options) do |chunk|
|
89
|
+
data = chunk.to_json
|
90
|
+
yielder << "data: #{data}\n\n"
|
91
|
+
|
92
|
+
# Accumulate content for final message
|
93
|
+
complete_message += chunk[:content] if chunk[:content]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Add the complete message to context if requested
|
97
|
+
if params[:update_context] != false && !complete_message.empty?
|
98
|
+
message = RubyMCP::Models::Message.new(
|
99
|
+
role: 'assistant',
|
100
|
+
content: complete_message
|
101
|
+
)
|
102
|
+
storage.add_message(context_id, message)
|
103
|
+
end
|
104
|
+
|
105
|
+
# End the stream
|
106
|
+
yielder << "data: [DONE]\n\n"
|
107
|
+
end
|
108
|
+
|
109
|
+
[200, headers, chunked_body]
|
110
|
+
rescue RubyMCP::Errors::ContextError => e
|
111
|
+
not_found(e.message)
|
112
|
+
rescue RubyMCP::Errors::ProviderError, RubyMCP::Errors::EngineError => e
|
113
|
+
bad_request(e.message)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def parse_engine_id(engine_id)
|
120
|
+
parts = engine_id.to_s.split('/', 2)
|
121
|
+
raise RubyMCP::Errors::ValidationError, 'Invalid engine_id format' unless parts.length == 2
|
122
|
+
|
123
|
+
parts
|
124
|
+
end
|
125
|
+
|
126
|
+
def get_provider(provider_name)
|
127
|
+
provider_config = RubyMCP.configuration.providers[provider_name.to_sym]
|
128
|
+
raise RubyMCP::Errors::ProviderError, "Provider not configured: #{provider_name}" unless provider_config
|
129
|
+
|
130
|
+
class_name = provider_name.to_s.capitalize
|
131
|
+
unless RubyMCP::Providers.const_defined?(class_name)
|
132
|
+
raise RubyMCP::Errors::ProviderError, "Provider not found: #{provider_name}"
|
133
|
+
end
|
134
|
+
|
135
|
+
provider_class = RubyMCP::Providers.const_get(class_name)
|
136
|
+
provider_class.new(provider_config)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Server
|
5
|
+
class MessagesController < BaseController
|
6
|
+
def create
|
7
|
+
context_id = params[:context_id]
|
8
|
+
|
9
|
+
begin
|
10
|
+
# Create the message
|
11
|
+
message = RubyMCP::Models::Message.new(
|
12
|
+
role: params[:role],
|
13
|
+
content: params[:content],
|
14
|
+
id: params[:id],
|
15
|
+
metadata: params[:metadata]
|
16
|
+
)
|
17
|
+
|
18
|
+
# Add to the context
|
19
|
+
storage.add_message(context_id, message)
|
20
|
+
|
21
|
+
created(message.to_h)
|
22
|
+
rescue RubyMCP::Errors::ContextError => e
|
23
|
+
not_found(e.message)
|
24
|
+
rescue RubyMCP::Errors::ValidationError => e
|
25
|
+
bad_request(e.message)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Server
|
5
|
+
class Router
|
6
|
+
Route = Struct.new(:http_method, :path, :controller, :action)
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@routes = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def add(method, path, controller, action)
|
13
|
+
@routes << Route.new(method, path, controller, action)
|
14
|
+
end
|
15
|
+
|
16
|
+
def route(request)
|
17
|
+
route = find_route(request.request_method, request.path)
|
18
|
+
return nil unless route
|
19
|
+
|
20
|
+
params = extract_params(route.path, request.path)
|
21
|
+
|
22
|
+
# Add body params for non-GET requests
|
23
|
+
if request.post? || request.put?
|
24
|
+
begin
|
25
|
+
body_params = JSON.parse(request.body.read, symbolize_names: true)
|
26
|
+
params.merge!(body_params)
|
27
|
+
rescue JSON::ParserError
|
28
|
+
# Handle empty or invalid JSON
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Add query params
|
33
|
+
params.merge!(extract_query_params(request))
|
34
|
+
|
35
|
+
# Initialize the controller and call the action
|
36
|
+
controller = route.controller.new(request, params)
|
37
|
+
controller.send(route.action)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def find_route(method, path)
|
43
|
+
@routes.find do |route|
|
44
|
+
route.http_method == method && path_matches?(route.path, path)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def path_matches?(route_path, request_path)
|
49
|
+
route_parts = route_path.split('/')
|
50
|
+
request_parts = request_path.split('/')
|
51
|
+
|
52
|
+
return false if route_parts.length != request_parts.length
|
53
|
+
|
54
|
+
route_parts.zip(request_parts).all? do |route_part, request_part|
|
55
|
+
route_part.start_with?(':') || route_part == request_part
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def extract_params(route_path, request_path)
|
60
|
+
params = {}
|
61
|
+
|
62
|
+
route_parts = route_path.split('/')
|
63
|
+
request_parts = request_path.split('/')
|
64
|
+
|
65
|
+
route_parts.zip(request_parts).each do |route_part, request_part|
|
66
|
+
if route_part.start_with?(':')
|
67
|
+
param_name = route_part[1..].to_sym
|
68
|
+
params[param_name] = request_part
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
params
|
73
|
+
end
|
74
|
+
|
75
|
+
def extract_query_params(request)
|
76
|
+
params = {}
|
77
|
+
request.params.each do |key, value|
|
78
|
+
params[key.to_sym] = value
|
79
|
+
end
|
80
|
+
params
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
module Storage
|
5
|
+
class Base
|
6
|
+
def initialize(options = {})
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_context(context)
|
11
|
+
raise NotImplementedError, "#{self.class.name} must implement #create_context"
|
12
|
+
end
|
13
|
+
|
14
|
+
def get_context(context_id)
|
15
|
+
raise NotImplementedError, "#{self.class.name} must implement #get_context"
|
16
|
+
end
|
17
|
+
|
18
|
+
def update_context(context)
|
19
|
+
raise NotImplementedError, "#{self.class.name} must implement #update_context"
|
20
|
+
end
|
21
|
+
|
22
|
+
def delete_context(context_id)
|
23
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete_context"
|
24
|
+
end
|
25
|
+
|
26
|
+
def list_contexts(limit: 100, offset: 0)
|
27
|
+
raise NotImplementedError, "#{self.class.name} must implement #list_contexts"
|
28
|
+
end
|
29
|
+
|
30
|
+
def add_message(context_id, message)
|
31
|
+
raise NotImplementedError, "#{self.class.name} must implement #add_message"
|
32
|
+
end
|
33
|
+
|
34
|
+
def add_content(context_id, content_id, content_data)
|
35
|
+
raise NotImplementedError, "#{self.class.name} must implement #add_content"
|
36
|
+
end
|
37
|
+
|
38
|
+
def get_content(context_id, content_id)
|
39
|
+
raise NotImplementedError, "#{self.class.name} must implement #get_content"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RubyMCP
|
6
|
+
module Storage
|
7
|
+
class Memory < Base
|
8
|
+
def initialize(options = {})
|
9
|
+
super
|
10
|
+
@contexts = {}
|
11
|
+
@contents = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def create_context(context)
|
15
|
+
@contexts[context.id] = context
|
16
|
+
context
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_context(context_id)
|
20
|
+
context = @contexts[context_id]
|
21
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless context
|
22
|
+
|
23
|
+
context
|
24
|
+
end
|
25
|
+
|
26
|
+
def update_context(context)
|
27
|
+
@contexts[context.id] = context
|
28
|
+
context
|
29
|
+
end
|
30
|
+
|
31
|
+
def delete_context(context_id)
|
32
|
+
context = get_context(context_id)
|
33
|
+
@contexts.delete(context_id)
|
34
|
+
@contents.delete(context_id)
|
35
|
+
context
|
36
|
+
end
|
37
|
+
|
38
|
+
def list_contexts(limit: 100, offset: 0)
|
39
|
+
@contexts.values
|
40
|
+
.sort_by(&:updated_at)
|
41
|
+
.reverse
|
42
|
+
.slice(offset, limit) || []
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_message(context_id, message)
|
46
|
+
context = get_context(context_id)
|
47
|
+
context.add_message(message)
|
48
|
+
update_context(context)
|
49
|
+
message
|
50
|
+
end
|
51
|
+
|
52
|
+
def add_content(context_id, content_id, content_data)
|
53
|
+
get_context(context_id)
|
54
|
+
@contents[context_id] ||= {}
|
55
|
+
@contents[context_id][content_id] = content_data
|
56
|
+
content_id
|
57
|
+
end
|
58
|
+
|
59
|
+
def get_content(context_id, content_id)
|
60
|
+
get_context(context_id)
|
61
|
+
contents = @contents[context_id] || {}
|
62
|
+
content = contents[content_id]
|
63
|
+
raise RubyMCP::Errors::ContentError, "Content not found: #{content_id}" unless content
|
64
|
+
|
65
|
+
content
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'schemas'
|
4
|
+
|
5
|
+
module RubyMCP
|
6
|
+
class Validator
|
7
|
+
def self.validate_context(params)
|
8
|
+
validate(params, Schemas::ContextSchema)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.validate_message(params)
|
12
|
+
validate(params, Schemas::MessageSchema)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.validate_generate(params)
|
16
|
+
validate(params, Schemas::GenerateSchema)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.validate_content(params)
|
20
|
+
validate(params, Schemas::ContentSchema)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.validate(params, schema)
|
24
|
+
result = schema.call(params)
|
25
|
+
|
26
|
+
if result.success?
|
27
|
+
true
|
28
|
+
else
|
29
|
+
# This converts nested error hashes to strings properly
|
30
|
+
error_messages = format_errors(result.errors.to_h)
|
31
|
+
raise RubyMCP::Errors::ValidationError, "Validation failed: #{error_messages}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.format_errors(errors, prefix = '')
|
36
|
+
errors.map do |key, value|
|
37
|
+
if value.is_a?(Hash)
|
38
|
+
format_errors(value, "#{prefix}#{key}.")
|
39
|
+
else
|
40
|
+
"#{prefix}#{key}: #{Array(value).join(', ')}"
|
41
|
+
end
|
42
|
+
end.join('; ')
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/ruby_mcp.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'jwt'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
require 'concurrent'
|
8
|
+
require 'logger'
|
9
|
+
require 'dry-schema'
|
10
|
+
|
11
|
+
require_relative 'ruby_mcp/version'
|
12
|
+
require_relative 'ruby_mcp/errors'
|
13
|
+
require_relative 'ruby_mcp/configuration'
|
14
|
+
|
15
|
+
require_relative 'ruby_mcp/models/context'
|
16
|
+
require_relative 'ruby_mcp/models/message'
|
17
|
+
require_relative 'ruby_mcp/models/engine'
|
18
|
+
require_relative 'ruby_mcp/providers/base'
|
19
|
+
require_relative 'ruby_mcp/storage/base'
|
20
|
+
require_relative 'ruby_mcp/storage/memory'
|
21
|
+
require_relative 'ruby_mcp/server/router'
|
22
|
+
require_relative 'ruby_mcp/server/base_controller'
|
23
|
+
require_relative 'ruby_mcp/server/app'
|
24
|
+
require_relative 'ruby_mcp/server/controller'
|
25
|
+
require_relative 'ruby_mcp/server/engines_controller'
|
26
|
+
require_relative 'ruby_mcp/server/contexts_controller'
|
27
|
+
require_relative 'ruby_mcp/server/messages_controller'
|
28
|
+
require_relative 'ruby_mcp/server/content_controller'
|
29
|
+
require_relative 'ruby_mcp/server/generate_controller'
|
30
|
+
require_relative 'ruby_mcp/providers/openai'
|
31
|
+
require_relative 'ruby_mcp/providers/anthropic'
|
32
|
+
|
33
|
+
require_relative 'ruby_mcp/schemas'
|
34
|
+
require_relative 'ruby_mcp/validator'
|
35
|
+
|
36
|
+
module RubyMCP
|
37
|
+
class << self
|
38
|
+
attr_accessor :configuration
|
39
|
+
attr_writer :logger
|
40
|
+
|
41
|
+
def configure
|
42
|
+
self.configuration ||= Configuration.new
|
43
|
+
yield(configuration) if block_given?
|
44
|
+
end
|
45
|
+
|
46
|
+
def logger
|
47
|
+
@logger ||= Logger.new($stdout).tap do |log|
|
48
|
+
log.progname = name
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|