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 +4 -4
- data/.solargraph.yml +13 -0
- data/CHANGELOG.md +10 -1
- data/README.md +114 -15
- data/lib/model_context_protocol/server/configuration.rb +38 -0
- data/lib/model_context_protocol/server/prompt.rb +67 -0
- data/lib/model_context_protocol/server/registry.rb +102 -0
- data/lib/model_context_protocol/server/resource.rb +54 -0
- data/lib/model_context_protocol/server/router.rb +14 -109
- data/lib/model_context_protocol/server/stdio_transport.rb +65 -0
- data/lib/model_context_protocol/server/tool.rb +82 -0
- data/lib/model_context_protocol/server.rb +86 -55
- data/lib/model_context_protocol/version.rb +1 -1
- metadata +24 -8
- data/lib/model_context_protocol/server/router/base_map.rb +0 -22
- data/lib/model_context_protocol/server/router/prompts_map.rb +0 -17
- data/lib/model_context_protocol/server/router/protocol_map.rb +0 -0
- data/lib/model_context_protocol/server/router/resources_map.rb +0 -17
- data/lib/model_context_protocol/server/router/tools_map.rb +0 -17
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0127f55c18cd5b2886b93b9f939b2b1358b14d2700e1f9194b26e5f43935101f
|
4
|
+
data.tar.gz: c0ce5f9bc0891e5b20fb3b30acee6c6dee0ebc2d412fac31078839fcf94f0010
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f17f3371c5c07b264b2a495fbc8b3e47fc8d18993cc3bd64f9fe91f4475720dde9ec536f266af1f22e7c7eb30ffd0bc4e5680f0a53f8a39c08d2eff034206e9f
|
7
|
+
data.tar.gz: 564433c1386b063560c17502b7346ae7db2cc523c47ab4ecd62fb986c51fba178432331a4ceed699ec9469c8d63e719acddce669c03d68969b8fc7086a85e640
|
data/.solargraph.yml
ADDED
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.
|
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
|
-
|
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 = "
|
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.
|
24
|
-
prompts do
|
25
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
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
|
-
|
6
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
10
|
+
def map(method, &handler)
|
11
|
+
@handlers[method] = handler
|
12
|
+
end
|
107
13
|
|
108
|
-
|
109
|
-
|
110
|
-
|
14
|
+
def route(message)
|
15
|
+
method = message["method"]
|
16
|
+
handler = @handlers[method]
|
17
|
+
raise MethodNotFoundError, "Method not found: #{method}" unless handler
|
111
18
|
|
112
|
-
|
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 "
|
1
|
+
require "logger"
|
2
2
|
|
3
3
|
module ModelContextProtocol
|
4
4
|
class Server
|
5
|
-
|
6
|
-
|
5
|
+
# Raised when invalid parameters are provided.
|
6
|
+
class ParameterValidationError < StandardError; end
|
7
7
|
|
8
|
-
|
9
|
-
enable_log || false
|
10
|
-
end
|
8
|
+
attr_reader :configuration, :router
|
11
9
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
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
|
-
|
21
|
-
true
|
22
|
-
end
|
23
|
+
private
|
23
24
|
|
24
|
-
|
25
|
-
|
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
|
-
|
29
|
-
|
38
|
+
PingResponse = Data.define do
|
39
|
+
def serialized
|
40
|
+
{}
|
30
41
|
end
|
31
42
|
end
|
32
43
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
44
|
-
|
56
|
+
router.map("ping") do
|
57
|
+
PingResponse[]
|
58
|
+
end
|
45
59
|
|
46
|
-
|
60
|
+
router.map("resources/list") do
|
61
|
+
configuration.registry.resources_data
|
62
|
+
end
|
47
63
|
|
48
|
-
|
49
|
-
|
50
|
-
|
64
|
+
router.map("resources/read") do |message|
|
65
|
+
configuration.registry.find_resource(message["params"]["uri"]).call
|
66
|
+
end
|
51
67
|
|
52
|
-
|
53
|
-
|
68
|
+
router.map("prompts/list") do
|
69
|
+
configuration.registry.prompts_data
|
70
|
+
end
|
54
71
|
|
55
|
-
|
56
|
-
|
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
|
-
|
76
|
+
router.map("tools/list") do
|
77
|
+
configuration.registry.tools_data
|
78
|
+
end
|
64
79
|
|
65
|
-
|
66
|
-
|
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
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
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
|
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.
|
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-
|
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/
|
32
|
-
- lib/model_context_protocol/server/
|
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
|
File without changes
|
@@ -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
|