model-context-protocol-rb 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fffc016471f9e02baa6f9288860d65c7e9f4c9b573589d4abb31d824318b5ea1
4
- data.tar.gz: 59b220de0291942879c34d7ca8cc47173b10d692a13e271c1458a59a7f2ff9fb
3
+ metadata.gz: 0127f55c18cd5b2886b93b9f939b2b1358b14d2700e1f9194b26e5f43935101f
4
+ data.tar.gz: c0ce5f9bc0891e5b20fb3b30acee6c6dee0ebc2d412fac31078839fcf94f0010
5
5
  SHA512:
6
- metadata.gz: abda740fa404a916e3b30aa3b8e8c06e1731bb4e810b44b3d60f91742f92298fa66d2b4165f102679a49ee2246da6223ece27ad68302e96040148c2a83d00b3d
7
- data.tar.gz: bc71626a2bec6b61a4e8e3de7a6ef8a44fb64a9e8d56f163734ff8a7e4ef42d05f552b0918f16e798ed15749b145fa901cbc4747a798ca9a274ef941b8f08e44
6
+ metadata.gz: f17f3371c5c07b264b2a495fbc8b3e47fc8d18993cc3bd64f9fe91f4475720dde9ec536f266af1f22e7c7eb30ffd0bc4e5680f0a53f8a39c08d2eff034206e9f
7
+ data.tar.gz: 564433c1386b063560c17502b7346ae7db2cc523c47ab4ecd62fb986c51fba178432331a4ceed699ec9469c8d63e719acddce669c03d68969b8fc7086a85e640
data/.solargraph.yml ADDED
@@ -0,0 +1,13 @@
1
+ include:
2
+ - "**/*.rb"
3
+ exclude:
4
+ - spec/**/*
5
+ - ".bundle/**"
6
+ require: []
7
+ domains: []
8
+ require_paths: []
9
+ plugins:
10
+ - solargraph-standardrb
11
+ reporters:
12
+ - standardrb
13
+ max_files: 5000
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2025-03-11
4
+
5
+ - (Breaking) Replaced router initialization in favor of registry initialization during server configuration. The server now relies on the registry for auto-discovery of prompts, resources, and tools; this requires the use of SDK-provided builders to facilitate.
6
+ - (Breaking) Implemented the use of `Data` objects across the implementation. As a result, responses from custom handlers must now respond with an object that responds to `serialized`.
7
+ - Refactored the implementation to maintain separation of concerns and improve testability/maintainability.
8
+ - Improved test coverage.
9
+ - Improved development tooling.
10
+
3
11
  ## [0.2.0] - 2025-01-13
4
12
 
5
13
  - Added a basic, synchronous server implementation that routes requests to custom handlers.
@@ -8,6 +16,7 @@
8
16
 
9
17
  - Initial release
10
18
 
11
- [Unreleased]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.2.0...HEAD
19
+ [Unreleased]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.0...HEAD
20
+ [0.3.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.2.0...v0.3.0
12
21
  [0.2.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.1.0...v0.2.0
13
22
  [0.1.0]: https://github.com/dickdavis/model-context-protocol-rb/releases/tag/v0.1.0
data/README.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  An implementation of the [Model Context Protocol (MCP)](https://spec.modelcontextprotocol.io/specification/2024-11-05/) in Ruby.
4
4
 
5
+ This SDK is experimental and subject to change. The initial focus is to implement MCP server support with the goal of providing a stable API by version `0.4`. MCP client support will follow.
6
+
7
+ You are welcome to contribute.
8
+
9
+ TODO's:
10
+
11
+ * [Completion](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/completion/)
12
+ * [Logging](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/logging/)
13
+ * [Pagination](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/)
14
+ * [Prompt list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#list-changed-notification)
15
+ * [Resource list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification)
16
+ * [Resource subscriptions](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions)
17
+ * [Resource templates](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates)
18
+ * [Tool list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification)
19
+
5
20
  ## Usage
6
21
 
7
22
  Include `model-context-protocol-rb` in your project.
@@ -10,30 +25,26 @@ Include `model-context-protocol-rb` in your project.
10
25
  require 'model-context-protocol-rb'
11
26
  ```
12
27
 
13
- # Building an MCP Server
28
+ ### Building an MCP Server
14
29
 
15
30
  Build a simple MCP server by routing methods to your custom handlers. Then, configure and run the server.
16
31
 
17
32
  ```ruby
18
33
  server = ModelContextProtocol::Server.new do |config|
19
- config.name = "My MCP Server"
20
- config.router = router
34
+ config.name = "MCP Development Server"
21
35
  config.version = "1.0.0"
22
36
  config.enable_log = true
23
- config.router = ModelContextProtocol::Router.new do
24
- prompts do
25
- list Prompt::List, broadcast_changes: true
26
- get Prompt::Get
37
+ config.registry = ModelContextProtocol::Server::Registry.new do
38
+ prompts list_changed: true do
39
+ register TestPrompt
27
40
  end
28
41
 
29
- resources do
30
- list Resource::List, broadcast_changes: true
31
- read Resource::Read, allow_subscriptions: true
42
+ resources list_changed: true, subscribe: true do
43
+ register TestResource
32
44
  end
33
45
 
34
- tools do
35
- list Tool::List, broadcast_changes: true
36
- call Tool::Call
46
+ tools list_changed: true do
47
+ register TestTool
37
48
  end
38
49
  end
39
50
  end
@@ -41,9 +52,97 @@ end
41
52
  server.start
42
53
  ```
43
54
 
44
- Messages from the MCP client will be routed to the appropriate custom handler. Your customer handler must respond to `call`; the router will pass the message to the handler as an argument.
55
+ Messages from the MCP client will be routed to the appropriate custom handler. This SDK provides several classes that should be used to build your handlers.
56
+
57
+ #### ModelContextProtocol::Server::Prompt
58
+
59
+ The `Prompt` class is used to define a prompt that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/) in the `with_metadata` block, and then implement the call method to build your prompt. The `call` method should return a `Response` data object.
60
+
61
+ ```ruby
62
+ class TestPrompt < ModelContextProtocol::Server::Prompt
63
+ with_metadata do
64
+ {
65
+ name: "Test Prompt",
66
+ description: "A test prompt",
67
+ arguments: [
68
+ {
69
+ name: "message",
70
+ description: "The thing to do",
71
+ required: true
72
+ },
73
+ {
74
+ name: "other",
75
+ description: "Another thing to do",
76
+ required: false
77
+ }
78
+ ]
79
+ }
80
+ end
81
+
82
+ def call
83
+ messages = [
84
+ {
85
+ role: "user",
86
+ content: {
87
+ type: "text",
88
+ text: "Do this: #{params["message"]}"
89
+ }
90
+ }
91
+ ]
92
+
93
+ Response[messages:, prompt: self]
94
+ end
95
+ end
96
+ ```
97
+
98
+ #### ModelContextProtocol::Server::Resource
99
+
100
+ The `Resource` class is used to define a resource that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/) in the `with_metadata` block, and then implement the 'call' method to build your prompt. The `call` method should return a `TextResponse` or a `BinaryResponse` data object.
101
+
102
+ ```ruby
103
+ class TestResource < ModelContextProtocol::Server::Resource
104
+ with_metadata do
105
+ {
106
+ name: "Test Resource",
107
+ description: "A test resource",
108
+ mime_type: "text/plain",
109
+ uri: "resource://test-resource"
110
+ }
111
+ end
112
+
113
+ def call
114
+ TextResponse[resource: self, text: "Here's the data"]
115
+ end
116
+ end
117
+ ```
118
+
119
+ #### ModelContextProtocol::Server::Tool
45
120
 
46
- Your handler should return a valid JSONRPC 2.0 response.
121
+ The `Tool` class is used to define a tool that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/) in the `with_metadata` block, and then implement the `call` method to build your prompt. The `call` method should return a `TextResponse`, `ImageResponse`, `ResourceResponse`, or `ToolErrorResponse` data object.
122
+
123
+ ```ruby
124
+ class TestTool < ModelContextProtocol::Server::Tool
125
+ with_metadata do
126
+ {
127
+ name: "test-tool",
128
+ description: "A test tool",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ message: {
133
+ type: "string"
134
+ }
135
+ },
136
+ required: ["message"]
137
+ }
138
+ }
139
+ end
140
+
141
+ def call
142
+ TextResponse[text: "You said: #{params["message"]}"]
143
+ end
144
+ end
145
+ ```
47
146
 
48
147
  ## Installation
49
148
 
@@ -0,0 +1,38 @@
1
+ module ModelContextProtocol
2
+ class Server::Configuration
3
+ # Raised when configured with invalid name.
4
+ class InvalidServerNameError < StandardError; end
5
+
6
+ # Raised when configured with invalid version.
7
+ class InvalidServerVersionError < StandardError; end
8
+
9
+ # Raised when configured with invalid registry.
10
+ class InvalidRegistryError < StandardError; end
11
+
12
+ attr_accessor :enable_log, :name, :registry, :version
13
+
14
+ def logging_enabled?
15
+ enable_log || false
16
+ end
17
+
18
+ def validate!
19
+ raise InvalidServerNameError unless valid_name?
20
+ raise InvalidRegistryError unless valid_registry?
21
+ raise InvalidServerVersionError unless valid_version?
22
+ end
23
+
24
+ private
25
+
26
+ def valid_name?
27
+ name&.is_a?(String)
28
+ end
29
+
30
+ def valid_registry?
31
+ registry&.is_a?(ModelContextProtocol::Server::Registry)
32
+ end
33
+
34
+ def valid_version?
35
+ version&.is_a?(String)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,67 @@
1
+ module ModelContextProtocol
2
+ class Server::Prompt
3
+ attr_reader :params, :description
4
+
5
+ Response = Data.define(:messages, :prompt) do
6
+ def serialized
7
+ {description: prompt.description, messages:}
8
+ end
9
+ end
10
+
11
+ def initialize(params)
12
+ validate!(params)
13
+ @description = self.class.description
14
+ @params = params
15
+ end
16
+
17
+ def call
18
+ raise NotImplementedError, "Subclasses must implement the call method"
19
+ end
20
+
21
+ private def validate!(params = {})
22
+ arguments = self.class.arguments || []
23
+ required_args = arguments.select { |arg| arg[:required] }.map { |arg| arg[:name] }
24
+ valid_arg_names = arguments.map { |arg| arg[:name] }
25
+
26
+ missing_args = required_args - params.keys
27
+ unless missing_args.empty?
28
+ missing_args_list = missing_args.join(", ")
29
+ raise ArgumentError, "Missing required arguments: #{missing_args_list}"
30
+ end
31
+
32
+ extra_args = params.keys - valid_arg_names
33
+ unless extra_args.empty?
34
+ extra_args_list = extra_args.join(", ")
35
+ raise ArgumentError, "Unexpected arguments: #{extra_args_list}"
36
+ end
37
+ end
38
+
39
+ class << self
40
+ attr_reader :name, :description, :arguments
41
+
42
+ def with_metadata(&block)
43
+ metadata = instance_eval(&block)
44
+
45
+ @name = metadata[:name]
46
+ @description = metadata[:description]
47
+ @arguments = metadata[:arguments]
48
+ end
49
+
50
+ def inherited(subclass)
51
+ subclass.instance_variable_set(:@name, @name)
52
+ subclass.instance_variable_set(:@description, @description)
53
+ subclass.instance_variable_set(:@arguments, @arguments)
54
+ end
55
+
56
+ def call(params)
57
+ new(params).call
58
+ rescue ArgumentError => error
59
+ raise ModelContextProtocol::Server::ParameterValidationError, error.message
60
+ end
61
+
62
+ def metadata
63
+ {name: @name, description: @description, arguments: @arguments}
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,102 @@
1
+ module ModelContextProtocol
2
+ class Server::Registry
3
+ attr_reader :prompts_options, :resources_options, :tools_options
4
+
5
+ def self.new(&block)
6
+ registry = allocate
7
+ registry.send(:initialize)
8
+ registry.instance_eval(&block) if block
9
+ registry
10
+ end
11
+
12
+ def initialize
13
+ @prompts = []
14
+ @resources = []
15
+ @tools = []
16
+ @prompts_options = {}
17
+ @resources_options = {}
18
+ @tools_options = {}
19
+ end
20
+
21
+ def prompts(options = {}, &block)
22
+ @prompts_options = options
23
+ instance_eval(&block) if block
24
+ end
25
+
26
+ def resources(options = {}, &block)
27
+ @resources_options = options
28
+ instance_eval(&block) if block
29
+ end
30
+
31
+ def tools(options = {}, &block)
32
+ @tools_options = options
33
+ instance_eval(&block) if block
34
+ end
35
+
36
+ def register(klass)
37
+ metadata = klass.metadata
38
+ entry = {klass: klass}.merge(metadata)
39
+
40
+ case klass.ancestors
41
+ when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Prompt) }
42
+ @prompts << entry
43
+ when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Resource) }
44
+ @resources << entry
45
+ when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Tool) }
46
+ @tools << entry
47
+ else
48
+ raise ArgumentError, "Unknown class type: #{klass}"
49
+ end
50
+ end
51
+
52
+ def find_prompt(name)
53
+ find_by_name(@prompts, name)
54
+ end
55
+
56
+ def find_resource(uri)
57
+ entry = @resources.find { |r| r[:uri] == uri }
58
+ entry ? entry[:klass] : nil
59
+ end
60
+
61
+ def find_tool(name)
62
+ find_by_name(@tools, name)
63
+ end
64
+
65
+ def prompts_data
66
+ PromptsData[prompts: @prompts.map { |entry| entry.except(:klass) }]
67
+ end
68
+
69
+ def resources_data
70
+ ResourcesData[resources: @resources.map { |entry| entry.except(:klass) }]
71
+ end
72
+
73
+ def tools_data
74
+ ToolsData[tools: @tools.map { |entry| entry.except(:klass) }]
75
+ end
76
+
77
+ private
78
+
79
+ PromptsData = Data.define(:prompts) do
80
+ def serialized
81
+ {prompts:}
82
+ end
83
+ end
84
+
85
+ ResourcesData = Data.define(:resources) do
86
+ def serialized
87
+ {resources:}
88
+ end
89
+ end
90
+
91
+ ToolsData = Data.define(:tools) do
92
+ def serialized
93
+ {tools:}
94
+ end
95
+ end
96
+
97
+ def find_by_name(collection, name)
98
+ entry = collection.find { |item| item[:name] == name }
99
+ entry ? entry[:klass] : nil
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,54 @@
1
+ module ModelContextProtocol
2
+ class Server::Resource
3
+ attr_reader :mime_type, :uri
4
+
5
+ TextResponse = Data.define(:resource, :text) do
6
+ def serialized
7
+ {contents: [{mimeType: resource.mime_type, text:, uri: resource.uri}]}
8
+ end
9
+ end
10
+
11
+ BinaryResponse = Data.define(:blob, :resource) do
12
+ def serialized
13
+ {contents: [{blob:, mimeType: resource.mime_type, uri: resource.uri}]}
14
+ end
15
+ end
16
+
17
+ def initialize
18
+ @mime_type = self.class.mime_type
19
+ @uri = self.class.uri
20
+ end
21
+
22
+ def call
23
+ raise NotImplementedError, "Subclasses must implement the call method"
24
+ end
25
+
26
+ class << self
27
+ attr_reader :name, :description, :mime_type, :uri
28
+
29
+ def with_metadata(&block)
30
+ metadata = instance_eval(&block)
31
+
32
+ @name = metadata[:name]
33
+ @description = metadata[:description]
34
+ @mime_type = metadata[:mime_type]
35
+ @uri = metadata[:uri]
36
+ end
37
+
38
+ def inherited(subclass)
39
+ subclass.instance_variable_set(:@name, @name)
40
+ subclass.instance_variable_set(:@description, @description)
41
+ subclass.instance_variable_set(:@mime_type, @mime_type)
42
+ subclass.instance_variable_set(:@uri, @uri)
43
+ end
44
+
45
+ def call
46
+ new.call
47
+ end
48
+
49
+ def metadata
50
+ {name: @name, description: @description, mime_type: @mime_type, uri: @uri}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,117 +1,22 @@
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
2
+ class Server::Router
3
+ # Raised when an invalid method is provided.
4
+ class MethodNotFoundError < StandardError; end
51
5
 
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
60
-
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
95
-
96
- def has_resource_routes?
97
- @routes.key?("resources/list") || @routes.key?("resources/read")
98
- end
99
-
100
- def resource_broadcasts_changes?
101
- @routes.dig("resources/list", :options, :broadcast_changes)
102
- end
6
+ def initialize
7
+ @handlers = {}
8
+ end
103
9
 
104
- def resource_allows_subscriptions?
105
- @routes.dig("resources/read", :options, :allow_subscriptions)
106
- end
10
+ def map(method, &handler)
11
+ @handlers[method] = handler
12
+ end
107
13
 
108
- def has_tool_routes?
109
- @routes.key?("tools/list") || @routes.key?("tools/call")
110
- end
14
+ def route(message)
15
+ method = message["method"]
16
+ handler = @handlers[method]
17
+ raise MethodNotFoundError, "Method not found: #{method}" unless handler
111
18
 
112
- def tool_broadcasts_changes?
113
- @routes.dig("tools/list", :options, :broadcast_changes)
114
- end
19
+ handler.call(message)
115
20
  end
116
21
  end
117
22
  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,82 @@
1
+ require "json-schema"
2
+
3
+ module ModelContextProtocol
4
+ class Server::Tool
5
+ attr_reader :params
6
+
7
+ TextResponse = Data.define(:text) do
8
+ def serialized
9
+ {content: [{type: "text", text:}], isError: false}
10
+ end
11
+ end
12
+
13
+ ImageResponse = Data.define(:data, :mime_type) do
14
+ def initialize(data:, mime_type: "image/png")
15
+ super
16
+ end
17
+
18
+ def serialized
19
+ {content: [{type: "image", data:, mimeType: mime_type}], isError: false}
20
+ end
21
+ end
22
+
23
+ ResourceResponse = Data.define(:uri, :text, :mime_type) do
24
+ def initialize(uri:, text:, mime_type: "text/plain")
25
+ super
26
+ end
27
+
28
+ def serialized
29
+ {content: [{type: "resource", resource: {uri:, mimeType: mime_type, text:}}], isError: false}
30
+ end
31
+ end
32
+
33
+ ToolErrorResponse = Data.define(:text) do
34
+ def serialized
35
+ {content: [{type: "text", text:}], isError: true}
36
+ end
37
+ end
38
+
39
+ def initialize(params)
40
+ validate!(params)
41
+ @params = params
42
+ end
43
+
44
+ def call
45
+ raise NotImplementedError, "Subclasses must implement the call method"
46
+ end
47
+
48
+ private def validate!(params)
49
+ JSON::Validator.validate!(self.class.input_schema, params)
50
+ end
51
+
52
+ class << self
53
+ attr_reader :name, :description, :input_schema
54
+
55
+ def with_metadata(&block)
56
+ metadata = instance_eval(&block)
57
+
58
+ @name = metadata[:name]
59
+ @description = metadata[:description]
60
+ @input_schema = metadata[:inputSchema]
61
+ end
62
+
63
+ def inherited(subclass)
64
+ subclass.instance_variable_set(:@name, @name)
65
+ subclass.instance_variable_set(:@description, @description)
66
+ subclass.instance_variable_set(:@input_schema, @input_schema)
67
+ end
68
+
69
+ def call(params)
70
+ new(params).call
71
+ rescue JSON::Schema::ValidationError => error
72
+ raise ModelContextProtocol::Server::ParameterValidationError, error.message
73
+ rescue => error
74
+ ToolErrorResponse[text: error.message]
75
+ end
76
+
77
+ def metadata
78
+ {name: @name, description: @description, inputSchema: @input_schema}
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,81 +1,112 @@
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 parameters are provided.
6
+ class ParameterValidationError < StandardError; end
7
7
 
8
- def logging_enabled?
9
- enable_log || false
10
- end
8
+ attr_reader :configuration, :router
11
9
 
12
- def validate!
13
- raise InvalidServerNameError unless valid_name?
14
- raise InvalidRouterError unless valid_router?
15
- raise InvalidServerVersionError unless valid_version?
16
- end
10
+ def initialize
11
+ @configuration = Configuration.new
12
+ yield(@configuration) if block_given?
13
+ @router = Router.new
14
+ map_handlers
15
+ end
17
16
 
18
- private
17
+ def start
18
+ configuration.validate!
19
+ logdev = configuration.logging_enabled? ? $stderr : File::NULL
20
+ StdioTransport.new(logger: Logger.new(logdev), router:).begin
21
+ end
19
22
 
20
- def valid_name?
21
- true
22
- end
23
+ private
23
24
 
24
- def valid_router?
25
- true
25
+ PROTOCOL_VERSION = "2024-11-05".freeze
26
+ private_constant :PROTOCOL_VERSION
27
+
28
+ InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info) do
29
+ def serialized
30
+ {
31
+ protocolVersion: protocol_version,
32
+ capabilities: capabilities,
33
+ serverInfo: server_info
34
+ }
26
35
  end
36
+ end
27
37
 
28
- def valid_version?
29
- true
38
+ PingResponse = Data.define do
39
+ def serialized
40
+ {}
30
41
  end
31
42
  end
32
43
 
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
- end
44
+ def map_handlers
45
+ router.map("initialize") do |_message|
46
+ InitializeResponse[
47
+ protocol_version: PROTOCOL_VERSION,
48
+ capabilities: build_capabilities,
49
+ server_info: {
50
+ name: configuration.name,
51
+ version: configuration.version
52
+ }
53
+ ]
54
+ end
42
55
 
43
- def start
44
- log("Server starting")
56
+ router.map("ping") do
57
+ PingResponse[]
58
+ end
45
59
 
46
- configuration.validate!
60
+ router.map("resources/list") do
61
+ configuration.registry.resources_data
62
+ end
47
63
 
48
- loop do
49
- line = $stdin.gets
50
- break unless line
64
+ router.map("resources/read") do |message|
65
+ configuration.registry.find_resource(message["params"]["uri"]).call
66
+ end
51
67
 
52
- message = JSON.parse(line.chomp)
53
- log("Received message: #{message.inspect}")
68
+ router.map("prompts/list") do
69
+ configuration.registry.prompts_data
70
+ end
54
71
 
55
- response = configuration.router.route(message)
56
- send_response(message["id"], response) if response
72
+ router.map("prompts/get") do |message|
73
+ configuration.registry.find_prompt(message["params"]["name"]).call(message["params"]["arguments"])
57
74
  end
58
- rescue => e
59
- log("Error: #{e.message}")
60
- log(e.backtrace)
61
- end
62
75
 
63
- private
76
+ router.map("tools/list") do
77
+ configuration.registry.tools_data
78
+ end
64
79
 
65
- def log(output)
66
- warn(output) if configuration.logging_enabled?
80
+ router.map("tools/call") do |message|
81
+ configuration.registry.find_tool(message["params"]["name"]).call(message["params"]["arguments"])
82
+ end
67
83
  end
68
84
 
69
- def send_response(id, result)
70
- return unless result
71
-
72
- response = {
73
- jsonrpc: "2.0",
74
- id: id,
75
- result: result
76
- }
77
- $stdout.puts(JSON.generate(response))
78
- $stdout.flush
85
+ def build_capabilities
86
+ {}.tap do |capabilities|
87
+ capabilities[:logging] = {} if configuration.logging_enabled?
88
+
89
+ registry = configuration.registry
90
+
91
+ if registry.prompts_options.any? && !registry.instance_variable_get(:@prompts).empty?
92
+ capabilities[:prompts] = {
93
+ listChanged: registry.prompts_options[:list_changed]
94
+ }.compact
95
+ end
96
+
97
+ if registry.resources_options.any? && !registry.instance_variable_get(:@resources).empty?
98
+ capabilities[:resources] = {
99
+ subscribe: registry.resources_options[:subscribe],
100
+ listChanged: registry.resources_options[:list_changed]
101
+ }.compact
102
+ end
103
+
104
+ if registry.tools_options.any? && !registry.instance_variable_get(:@tools).empty?
105
+ capabilities[:tools] = {
106
+ listChanged: registry.tools_options[:list_changed]
107
+ }.compact
108
+ end
109
+ end
79
110
  end
80
111
  end
81
112
  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.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model-context-protocol-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dick Davis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-14 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2025-03-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json-schema
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
13
27
  description:
14
28
  email:
15
29
  - dick@hey.com
@@ -19,6 +33,7 @@ extra_rdoc_files: []
19
33
  files:
20
34
  - ".reek.yml"
21
35
  - ".rspec"
36
+ - ".solargraph.yml"
22
37
  - ".standard.yml"
23
38
  - ".tool-versions"
24
39
  - CHANGELOG.md
@@ -27,12 +42,13 @@ files:
27
42
  - Rakefile
28
43
  - lib/model_context_protocol.rb
29
44
  - lib/model_context_protocol/server.rb
45
+ - lib/model_context_protocol/server/configuration.rb
46
+ - lib/model_context_protocol/server/prompt.rb
47
+ - lib/model_context_protocol/server/registry.rb
48
+ - lib/model_context_protocol/server/resource.rb
30
49
  - lib/model_context_protocol/server/router.rb
31
- - lib/model_context_protocol/server/router/base_map.rb
32
- - lib/model_context_protocol/server/router/prompts_map.rb
33
- - lib/model_context_protocol/server/router/protocol_map.rb
34
- - lib/model_context_protocol/server/router/resources_map.rb
35
- - lib/model_context_protocol/server/router/tools_map.rb
50
+ - lib/model_context_protocol/server/stdio_transport.rb
51
+ - lib/model_context_protocol/server/tool.rb
36
52
  - lib/model_context_protocol/version.rb
37
53
  homepage: https://github.com/dickdavis/model-context-protocol-rb
38
54
  licenses:
@@ -1,22 +0,0 @@
1
- module ModelContextProtocol
2
- class Server
3
- class Router
4
- ##
5
- # Base class for route maps.
6
- class BaseMap
7
- def initialize(routes)
8
- @routes = routes
9
- end
10
-
11
- private
12
-
13
- def register(method, handler, **options)
14
- @routes[method] = {
15
- handler: handler,
16
- options: options
17
- }
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,17 +0,0 @@
1
- module ModelContextProtocol
2
- class Server
3
- class Router
4
- ##
5
- # Maps prompt operations to handlers.
6
- class PromptsMap < BaseMap
7
- def list(handler, broadcast_changes: false)
8
- register("prompts/list", handler, broadcast_changes:)
9
- end
10
-
11
- def get(handler)
12
- register("prompts/get", handler)
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,17 +0,0 @@
1
- module ModelContextProtocol
2
- class Server
3
- class Router
4
- ##
5
- # Maps resource operations to handlers.
6
- class ResourcesMap < BaseMap
7
- def list(handler, broadcast_changes: false)
8
- register("resources/list", handler, broadcast_changes:)
9
- end
10
-
11
- def read(handler, allow_subscriptions: false)
12
- register("resources/read", handler, allow_subscriptions:)
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,17 +0,0 @@
1
- module ModelContextProtocol
2
- class Server
3
- class Router
4
- ##
5
- # Maps tool operations to handlers.
6
- class ToolsMap < BaseMap
7
- def list(handler, broadcast_changes: false)
8
- register("tools/list", handler, broadcast_changes:)
9
- end
10
-
11
- def call(handler)
12
- register("tools/call", handler)
13
- end
14
- end
15
- end
16
- end
17
- end