model-context-protocol-rb 0.2.0 → 0.3.1

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,67 @@
1
+ module ModelContextProtocol
2
+ class Server::Resource
3
+ attr_reader :mime_type, :uri
4
+
5
+ def initialize
6
+ @mime_type = self.class.mime_type
7
+ @uri = self.class.uri
8
+ end
9
+
10
+ def call
11
+ raise NotImplementedError, "Subclasses must implement the call method"
12
+ end
13
+
14
+ TextResponse = Data.define(:resource, :text) do
15
+ def serialized
16
+ {contents: [{mimeType: resource.mime_type, text:, uri: resource.uri}]}
17
+ end
18
+ end
19
+ private_constant :TextResponse
20
+
21
+ BinaryResponse = Data.define(:blob, :resource) do
22
+ def serialized
23
+ {contents: [{blob:, mimeType: resource.mime_type, uri: resource.uri}]}
24
+ end
25
+ end
26
+ private_constant :BinaryResponse
27
+
28
+ private def respond_with(type, **options)
29
+ case [type, options]
30
+ in [:text, {text:}]
31
+ TextResponse[resource: self, text:]
32
+ in [:binary, {blob:}]
33
+ BinaryResponse[blob:, resource: self]
34
+ else
35
+ raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{type}, #{options}"
36
+ end
37
+ end
38
+
39
+ class << self
40
+ attr_reader :name, :description, :mime_type, :uri
41
+
42
+ def with_metadata(&block)
43
+ metadata = instance_eval(&block)
44
+
45
+ @name = metadata[:name]
46
+ @description = metadata[:description]
47
+ @mime_type = metadata[:mime_type]
48
+ @uri = metadata[:uri]
49
+ end
50
+
51
+ def inherited(subclass)
52
+ subclass.instance_variable_set(:@name, @name)
53
+ subclass.instance_variable_set(:@description, @description)
54
+ subclass.instance_variable_set(:@mime_type, @mime_type)
55
+ subclass.instance_variable_set(:@uri, @uri)
56
+ end
57
+
58
+ def call
59
+ new.call
60
+ end
61
+
62
+ def metadata
63
+ {name: @name, description: @description, mime_type: @mime_type, uri: @uri}
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,117 +1,36 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ModelContextProtocol
4
- class Server
5
- class Router
6
- attr_accessor :server
7
-
8
- def self.new(&block)
9
- router = allocate
10
- router.send(:initialize)
11
- router.instance_eval(&block) if block
12
- router
13
- end
14
-
15
- def initialize
16
- @routes = {}
17
- register_protocol_routes
18
- end
19
-
20
- def prompts(&block)
21
- PromptsMap.new(@routes).instance_eval(&block)
22
- end
23
-
24
- def resources(&block)
25
- ResourcesMap.new(@routes).instance_eval(&block)
26
- end
27
-
28
- def tools(&block)
29
- ToolsMap.new(@routes).instance_eval(&block)
30
- end
31
-
32
- def route(message)
33
- handler_config = @routes[message["method"]]
34
- return nil unless handler_config
35
-
36
- handler_config[:handler].call(message)
37
- end
38
-
39
- private
40
-
41
- def server_info
42
- if server&.configuration
43
- {
44
- name: server.configuration.name,
45
- version: server.configuration.version
46
- }
47
- else
48
- {name: "mcp-server", version: "1.0.0"}
49
- end
50
- end
51
-
52
- def register_protocol_routes
53
- register("initialize") do |_message|
54
- {
55
- protocolVersion: ModelContextProtocol::Server::PROTOCOL_VERSION,
56
- capabilities: build_capabilities,
57
- serverInfo: server_info
58
- }
59
- end
2
+ class Server::Router
3
+ # Raised when an invalid method is provided.
4
+ class MethodNotFoundError < StandardError; end
60
5
 
61
- register("notifications/initialized") do |_message|
62
- nil # No-op notification handler
63
- end
64
-
65
- register("ping") do |_message|
66
- {} # Simple pong response
67
- end
68
- end
69
-
70
- def register(method, handler = nil, **options, &block)
71
- @routes[method] = {
72
- handler: block || handler,
73
- options: options
74
- }
75
- end
76
-
77
- def build_capabilities
78
- {
79
- prompts: has_prompt_routes? ? {broadcast_changes: prompt_broadcasts_changes?} : nil,
80
- resources: has_resource_routes? ? {
81
- broadcast_changes: resource_broadcasts_changes?,
82
- subscribe: resource_allows_subscriptions?
83
- } : nil,
84
- tools: has_tool_routes? ? {broadcast_changes: tool_broadcasts_changes?} : nil
85
- }.compact
86
- end
87
-
88
- def has_prompt_routes?
89
- @routes.key?("prompts/list") || @routes.key?("prompts/get")
90
- end
91
-
92
- def prompt_broadcasts_changes?
93
- @routes.dig("prompts/list", :options, :broadcast_changes)
94
- end
6
+ def initialize(configuration: nil)
7
+ @handlers = {}
8
+ @configuration = configuration
9
+ end
95
10
 
96
- def has_resource_routes?
97
- @routes.key?("resources/list") || @routes.key?("resources/read")
98
- end
11
+ def map(method, &handler)
12
+ @handlers[method] = handler
13
+ end
99
14
 
100
- def resource_broadcasts_changes?
101
- @routes.dig("resources/list", :options, :broadcast_changes)
102
- end
15
+ def route(message)
16
+ method = message["method"]
17
+ handler = @handlers[method]
18
+ raise MethodNotFoundError, "Method not found: #{method}" unless handler
103
19
 
104
- def resource_allows_subscriptions?
105
- @routes.dig("resources/read", :options, :allow_subscriptions)
20
+ with_environment(@configuration&.environment_variables) do
21
+ handler.call(message)
106
22
  end
23
+ end
107
24
 
108
- def has_tool_routes?
109
- @routes.key?("tools/list") || @routes.key?("tools/call")
110
- end
25
+ private
111
26
 
112
- def tool_broadcasts_changes?
113
- @routes.dig("tools/list", :options, :broadcast_changes)
114
- end
27
+ def with_environment(vars)
28
+ original = ENV.to_h
29
+ vars&.each { |key, value| ENV[key] = value }
30
+ yield
31
+ ensure
32
+ ENV.clear
33
+ original.each { |key, value| ENV[key] = value }
115
34
  end
116
35
  end
117
36
  end
@@ -0,0 +1,65 @@
1
+ module ModelContextProtocol
2
+ class Server::StdioTransport
3
+ Response = Data.define(:id, :result) do
4
+ def serialized
5
+ {jsonrpc: "2.0", id:, result:}
6
+ end
7
+ end
8
+
9
+ ErrorResponse = Data.define(:id, :error) do
10
+ def serialized
11
+ {jsonrpc: "2.0", id:, error:}
12
+ end
13
+ end
14
+
15
+ attr_reader :logger, :router
16
+
17
+ def initialize(logger:, router:)
18
+ @logger = logger
19
+ @router = router
20
+ end
21
+
22
+ def begin
23
+ loop do
24
+ line = $stdin.gets
25
+ break unless line
26
+
27
+ begin
28
+ message = JSON.parse(line.chomp)
29
+ next if message["method"].start_with?("notifications")
30
+
31
+ result = router.route(message)
32
+ send_message(Response[id: message["id"], result: result.serialized])
33
+ rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
34
+ log("Validation error: #{validation_error.message}")
35
+ send_message(
36
+ ErrorResponse[id: message["id"], error: {code: -32602, message: validation_error.message}]
37
+ )
38
+ rescue JSON::ParserError => parser_error
39
+ log("Parser error: #{parser_error.message}")
40
+ send_message(
41
+ ErrorResponse[id: "", error: {code: -32700, message: parser_error.message}]
42
+ )
43
+ rescue => error
44
+ log("Internal error: #{error.message}")
45
+ log(error.backtrace)
46
+ send_message(
47
+ ErrorResponse[id: message["id"], error: {code: -32603, message: error.message}]
48
+ )
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def log(output, level = :error)
56
+ logger.send(level.to_sym, output)
57
+ end
58
+
59
+ def send_message(message)
60
+ message_json = JSON.generate(message.serialized)
61
+ $stdout.puts(message_json)
62
+ $stdout.flush
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,107 @@
1
+ require "json-schema"
2
+
3
+ module ModelContextProtocol
4
+ class Server::Tool
5
+ attr_reader :params
6
+
7
+ def initialize(params)
8
+ validate!(params)
9
+ @params = params
10
+ end
11
+
12
+ def call
13
+ raise NotImplementedError, "Subclasses must implement the call method"
14
+ end
15
+
16
+ TextResponse = Data.define(:text) do
17
+ def serialized
18
+ {content: [{type: "text", text:}], isError: false}
19
+ end
20
+ end
21
+ private_constant :TextResponse
22
+
23
+ ImageResponse = Data.define(:data, :mime_type) do
24
+ def initialize(data:, mime_type: "image/png")
25
+ super
26
+ end
27
+
28
+ def serialized
29
+ {content: [{type: "image", data:, mimeType: mime_type}], isError: false}
30
+ end
31
+ end
32
+ private_constant :ImageResponse
33
+
34
+ ResourceResponse = Data.define(:uri, :text, :mime_type) do
35
+ def initialize(uri:, text:, mime_type: "text/plain")
36
+ super
37
+ end
38
+
39
+ def serialized
40
+ {content: [{type: "resource", resource: {uri:, mimeType: mime_type, text:}}], isError: false}
41
+ end
42
+ end
43
+ private_constant :ResourceResponse
44
+
45
+ ToolErrorResponse = Data.define(:text) do
46
+ def serialized
47
+ {content: [{type: "text", text:}], isError: true}
48
+ end
49
+ end
50
+ private_constant :ToolErrorResponse
51
+
52
+ private def respond_with(type, **options)
53
+ case [type, options]
54
+ in [:text, {text:}]
55
+ TextResponse[text:]
56
+ in [:image, {data:, mime_type:}]
57
+ ImageResponse[data:, mime_type:]
58
+ in [:image, {data:}]
59
+ ImageResponse[data:]
60
+ in [:resource, {mime_type:, text:, uri:}]
61
+ ResourceResponse[mime_type:, text:, uri:]
62
+ in [:resource, {text:, uri:}]
63
+ ResourceResponse[text:, uri:]
64
+ in [:error, {text:}]
65
+ ToolErrorResponse[text:]
66
+ else
67
+ raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{type}, #{options}"
68
+ end
69
+ end
70
+
71
+ private def validate!(params)
72
+ JSON::Validator.validate!(self.class.input_schema, params)
73
+ end
74
+
75
+ class << self
76
+ attr_reader :name, :description, :input_schema
77
+
78
+ def with_metadata(&block)
79
+ metadata = instance_eval(&block)
80
+
81
+ @name = metadata[:name]
82
+ @description = metadata[:description]
83
+ @input_schema = metadata[:inputSchema]
84
+ end
85
+
86
+ def inherited(subclass)
87
+ subclass.instance_variable_set(:@name, @name)
88
+ subclass.instance_variable_set(:@description, @description)
89
+ subclass.instance_variable_set(:@input_schema, @input_schema)
90
+ end
91
+
92
+ def call(params)
93
+ new(params).call
94
+ rescue JSON::Schema::ValidationError => validation_error
95
+ raise ModelContextProtocol::Server::ParameterValidationError, validation_error.message
96
+ rescue ModelContextProtocol::Server::ResponseArgumentsError => response_arguments_error
97
+ raise response_arguments_error
98
+ rescue => error
99
+ ToolErrorResponse[text: error.message]
100
+ end
101
+
102
+ def metadata
103
+ {name: @name, description: @description, inputSchema: @input_schema}
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,81 +1,115 @@
1
- require "json"
1
+ require "logger"
2
2
 
3
3
  module ModelContextProtocol
4
4
  class Server
5
- class Configuration
6
- attr_accessor :enable_log, :name, :router, :version
5
+ # Raised when invalid response arguments are provided.
6
+ class ResponseArgumentsError < StandardError; end
7
7
 
8
- def logging_enabled?
9
- enable_log || false
10
- end
8
+ # Raised when invalid parameters are provided.
9
+ class ParameterValidationError < StandardError; end
11
10
 
12
- def validate!
13
- raise InvalidServerNameError unless valid_name?
14
- raise InvalidRouterError unless valid_router?
15
- raise InvalidServerVersionError unless valid_version?
16
- end
11
+ attr_reader :configuration, :router
17
12
 
18
- private
13
+ def initialize
14
+ @configuration = Configuration.new
15
+ yield(@configuration) if block_given?
16
+ @router = Router.new(configuration:)
17
+ map_handlers
18
+ end
19
19
 
20
- def valid_name?
21
- true
22
- end
20
+ def start
21
+ configuration.validate!
22
+ logdev = configuration.logging_enabled? ? $stderr : File::NULL
23
+ StdioTransport.new(logger: Logger.new(logdev), router:).begin
24
+ end
23
25
 
24
- def valid_router?
25
- true
26
- end
26
+ private
27
27
 
28
- def valid_version?
29
- true
28
+ PROTOCOL_VERSION = "2024-11-05".freeze
29
+ private_constant :PROTOCOL_VERSION
30
+
31
+ InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info) do
32
+ def serialized
33
+ {
34
+ protocolVersion: protocol_version,
35
+ capabilities: capabilities,
36
+ serverInfo: server_info
37
+ }
30
38
  end
31
39
  end
32
40
 
33
- PROTOCOL_VERSION = "2024-11-05".freeze
34
-
35
- attr_reader :configuration
36
-
37
- def initialize
38
- @configuration = Configuration.new
39
- yield(@configuration) if block_given?
40
- @configuration.router.server = self if @configuration.router
41
+ PingResponse = Data.define do
42
+ def serialized
43
+ {}
44
+ end
41
45
  end
42
46
 
43
- def start
44
- log("Server starting")
47
+ def map_handlers
48
+ router.map("initialize") do |_message|
49
+ InitializeResponse[
50
+ protocol_version: PROTOCOL_VERSION,
51
+ capabilities: build_capabilities,
52
+ server_info: {
53
+ name: configuration.name,
54
+ version: configuration.version
55
+ }
56
+ ]
57
+ end
45
58
 
46
- configuration.validate!
59
+ router.map("ping") do
60
+ PingResponse[]
61
+ end
47
62
 
48
- loop do
49
- line = $stdin.gets
50
- break unless line
63
+ router.map("resources/list") do
64
+ configuration.registry.resources_data
65
+ end
51
66
 
52
- message = JSON.parse(line.chomp)
53
- log("Received message: #{message.inspect}")
67
+ router.map("resources/read") do |message|
68
+ configuration.registry.find_resource(message["params"]["uri"]).call
69
+ end
54
70
 
55
- response = configuration.router.route(message)
56
- send_response(message["id"], response) if response
71
+ router.map("prompts/list") do
72
+ configuration.registry.prompts_data
57
73
  end
58
- rescue => e
59
- log("Error: #{e.message}")
60
- log(e.backtrace)
61
- end
62
74
 
63
- private
75
+ router.map("prompts/get") do |message|
76
+ configuration.registry.find_prompt(message["params"]["name"]).call(message["params"]["arguments"])
77
+ end
64
78
 
65
- def log(output)
66
- warn(output) if configuration.logging_enabled?
67
- end
79
+ router.map("tools/list") do
80
+ configuration.registry.tools_data
81
+ end
68
82
 
69
- def send_response(id, result)
70
- return unless result
83
+ router.map("tools/call") do |message|
84
+ configuration.registry.find_tool(message["params"]["name"]).call(message["params"]["arguments"])
85
+ end
86
+ end
71
87
 
72
- response = {
73
- jsonrpc: "2.0",
74
- id: id,
75
- result: result
76
- }
77
- $stdout.puts(JSON.generate(response))
78
- $stdout.flush
88
+ def build_capabilities
89
+ {}.tap do |capabilities|
90
+ capabilities[:logging] = {} if configuration.logging_enabled?
91
+
92
+ registry = configuration.registry
93
+
94
+ if registry.prompts_options.any? && !registry.instance_variable_get(:@prompts).empty?
95
+ capabilities[:prompts] = {
96
+ listChanged: registry.prompts_options[:list_changed]
97
+ }.compact
98
+ end
99
+
100
+ if registry.resources_options.any? && !registry.instance_variable_get(:@resources).empty?
101
+ capabilities[:resources] = {
102
+ subscribe: registry.resources_options[:subscribe],
103
+ listChanged: registry.resources_options[:list_changed]
104
+ }.compact
105
+ end
106
+
107
+ if registry.tools_options.any? && !registry.instance_variable_get(:@tools).empty?
108
+ capabilities[:tools] = {
109
+ listChanged: registry.tools_options[:list_changed]
110
+ }.compact
111
+ end
112
+ end
79
113
  end
80
114
  end
81
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModelContextProtocol
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/tasks/mcp.rake ADDED
@@ -0,0 +1,62 @@
1
+ require "fileutils"
2
+
3
+ namespace :mcp do
4
+ desc "Generate the development server executable with the correct Ruby path"
5
+ task :generate_executable do
6
+ destination_path = "bin/dev"
7
+ template_path = File.expand_path("templates/dev.erb", __dir__)
8
+
9
+ # Create directory if it doesn't exist
10
+ FileUtils.mkdir_p(File.dirname(destination_path))
11
+
12
+ # Get the Ruby path
13
+ ruby_path = detect_ruby_path
14
+
15
+ # Read and process the template
16
+ template = File.read(template_path)
17
+ content = template.gsub("<%= @ruby_path %>", ruby_path)
18
+
19
+ # Write the executable
20
+ File.write(destination_path, content)
21
+
22
+ # Set permissions
23
+ FileUtils.chmod(0o755, destination_path)
24
+
25
+ # Show success message
26
+ puts "\nCreated executable at: #{File.expand_path(destination_path)}"
27
+ puts "Using Ruby path: #{ruby_path}"
28
+ end
29
+
30
+ def detect_ruby_path
31
+ # Get Ruby version from project config
32
+ ruby_version = get_project_ruby_version
33
+
34
+ if ruby_version && ruby_version.strip != ""
35
+ # Find the absolute path to the Ruby executable via ASDF
36
+ asdf_ruby_path = `asdf where ruby #{ruby_version}`.strip
37
+
38
+ if asdf_ruby_path && !asdf_ruby_path.empty? && File.directory?(asdf_ruby_path)
39
+ return File.join(asdf_ruby_path, "bin", "ruby")
40
+ end
41
+ end
42
+
43
+ # Fallback to current Ruby
44
+ `which ruby`.strip
45
+ end
46
+
47
+ def get_project_ruby_version
48
+ # Try ASDF first
49
+ if File.exist?(".tool-versions")
50
+ content = File.read(".tool-versions")
51
+ ruby_line = content.lines.find { |line| line.start_with?("ruby ") }
52
+ return ruby_line.split[1].strip if ruby_line
53
+ end
54
+
55
+ # Try .ruby-version file
56
+ if File.exist?(".ruby-version")
57
+ return File.read(".ruby-version").strip
58
+ end
59
+
60
+ nil
61
+ end
62
+ end
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env <%= @ruby_path %>
2
+
3
+ require "bundler/setup"
4
+ require_relative "../lib/model_context_protocol"
5
+
6
+ Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
7
+
8
+ server = ModelContextProtocol::Server.new do |config|
9
+ config.name = "MCP Development Server"
10
+ config.version = "1.0.0"
11
+ config.enable_log = true
12
+ config.registry = ModelContextProtocol::Server::Registry.new do
13
+ prompts list_changed: true do
14
+ register TestPrompt
15
+ end
16
+
17
+ resources list_changed: true, subscribe: true do
18
+ register TestResource
19
+ register TestBinaryResource
20
+ end
21
+
22
+ tools list_changed: true do
23
+ register TestToolWithTextResponse
24
+ register TestToolWithImageResponse
25
+ register TestToolWithImageResponseDefaultMimeType
26
+ register TestToolWithResourceResponse
27
+ register TestToolWithResourceResponseDefaultMimeType
28
+ register TestToolWithToolErrorResponse
29
+ end
30
+ end
31
+ end
32
+
33
+ server.start