encom 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8768eb489945cac6ad76aae9c8f34457db2224cc343868b93518fb7b2ae32ae7
4
+ data.tar.gz: b9621af83733c962fb380915dd3fbd5c5cd452bb53dbffc6a0b2b50143c76119
5
+ SHA512:
6
+ metadata.gz: d0d9977d2fd67dd06414c88a10fc309212cd4df8fd261bb0fbe4d9c4e7bb97cc84e91a9f6605b455c3ccc04f887f59526f79e73fba44d08ec5e535e82085223b
7
+ data.tar.gz: 14ad9642e9978fc251b3d03d215d81ce9aab1f8f4baf24b5ab8a5afbe29b0e795a923a8b8d802c99c3ffee1e7943503facdc905fd1a13cd5fc61783b4e347b4d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Kyle Byrne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # Encom
2
+
3
+ Work in progress Ruby implementation of the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
4
+
5
+ Encom is a Ruby library for implementing both MCP servers and clients. The gem provides a flexible and easy-to-use framework to build applications that can communicate using the [MCP specification](https://spec.modelcontextprotocol.io/specification/2024-11-05/).
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'encom'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ $ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ $ gem install encom
25
+ ```
26
+
27
+ ## Building an MCP Server
28
+
29
+ ### Basic Server Implementation
30
+
31
+ ```ruby
32
+ require 'encom/server'
33
+ require 'encom/server_transport/stdio'
34
+
35
+ class MyServer < Encom::Server
36
+ name "MyMCPServer"
37
+ version "1.0.0"
38
+
39
+ # Define a tool that the server exposes
40
+ tool :hello_world,
41
+ "Says hello to the specified name",
42
+ {
43
+ type: "object",
44
+ properties: {
45
+ name: {
46
+ type: "string",
47
+ description: "The name to greet"
48
+ }
49
+ },
50
+ required: ["name"]
51
+ } do |args|
52
+ { greeting: "Hello, #{args[:name]}!" }
53
+ end
54
+ end
55
+
56
+ # Start the server with a chosen transport mechanism
57
+ server = MyServer.new
58
+ server.run(Encom::ServerTransport::Stdio)
59
+ ```
60
+
61
+ ### Starting Your Server
62
+
63
+ ```ruby
64
+ # Run the server file
65
+ ruby my_server.rb
66
+ ```
67
+
68
+ ## Building an MCP Client
69
+
70
+ ### Basic Client Implementation
71
+
72
+ ```ruby
73
+ require 'encom/client'
74
+ require 'encom/transport/stdio'
75
+
76
+ # Create a client
77
+ client = Encom::Client.new(
78
+ name: 'MyClient',
79
+ version: '1.0.0',
80
+ capabilities: {
81
+ tools: {
82
+ execute: true
83
+ }
84
+ }
85
+ )
86
+
87
+ # Set up error handling
88
+ client.on_error do |error|
89
+ puts "ERROR: #{error.class} - #{error.message}"
90
+ end
91
+
92
+ # Connect to an MCP server
93
+ transport = Encom::Transport::Stdio.new(
94
+ command: 'ruby',
95
+ args: ['path/to/your/server.rb']
96
+ )
97
+
98
+ client.connect(transport)
99
+
100
+ # List available tools
101
+ tools = client.list_tools
102
+
103
+ # Call a tool
104
+ result = client.call_tool(
105
+ name: 'hello_world',
106
+ arguments: { name: 'World' }
107
+ )
108
+
109
+ puts result[:greeting] # Outputs: Hello, World!
110
+
111
+ # Close the connection when done
112
+ client.close
113
+ ```
114
+
115
+ ## Example Usage
116
+
117
+ See the `examples` directory for complete demonstrations:
118
+
119
+ - `filesystem_demo.rb`: Shows how to connect to an MCP filesystem server
120
+
121
+ ## Specification support
122
+
123
+ ### Server
124
+
125
+ - Tools ✅
126
+ - Prompts 🟠
127
+ - Resources 🟠
128
+
129
+ We haven't yet added a DSL for defining prompts or resources but these can be defined as shown in `bin/mock_mcp_server`
130
+
131
+ ### Client
132
+ - Server tool interface ✅
133
+ - Server prompt interface ❌
134
+ - Server resource interface ❌
135
+ - Roots ❌
136
+ - Sampling ❌
137
+
138
+
139
+ ### Available Transports
140
+
141
+ Encom currently supports different transport mechanisms for communication:
142
+
143
+ - **STDIO**: Communication through standard input/output
144
+ - Custom transports can be implemented by extending the base transport classes
145
+
146
+ ## Development
147
+
148
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
149
+
150
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
151
+
152
+
153
+ ## Contributing
154
+
155
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kylebyrne/encom.
156
+
157
+ ## License
158
+
159
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'standard/rake'
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'encom/client'
6
+ require 'encom/transport/stdio'
7
+ require 'fileutils'
8
+ require 'tmpdir'
9
+
10
+ # Create a client with appropriate capabilities
11
+ def create_client
12
+ client = Encom::Client.new(
13
+ name: 'FilesystemDemoClient',
14
+ version: '1.0.0',
15
+ capabilities: {
16
+ tools: {
17
+ execute: true
18
+ }
19
+ }
20
+ )
21
+
22
+ # Set up error handling
23
+ client.on_error do |error|
24
+ puts "ERROR: #{error.class} - #{error.message}"
25
+ end
26
+
27
+ client
28
+ end
29
+
30
+ # Connect to the filesystem server
31
+ def connect_to_server(client)
32
+ # Use npx to run the server without requiring global installation
33
+ transport = Encom::Transport::Stdio.new(
34
+ command: 'npx',
35
+ args: [
36
+ '-y',
37
+ '@modelcontextprotocol/server-filesystem',
38
+ '.' # Allow access to the current directory
39
+ ]
40
+ )
41
+
42
+ puts 'Connecting to filesystem server using npx...'
43
+ client.connect(transport)
44
+
45
+ # Give it a moment to initialize
46
+ sleep(1)
47
+
48
+ if client.initialized
49
+ puts "✓ Connected to #{client.server_info[:name]} #{client.server_info[:version]}"
50
+ puts "✓ Protocol version: #{client.protocol_version}"
51
+ puts "✓ Server capabilities: #{client.server_capabilities.inspect}"
52
+ puts ''
53
+ else
54
+ puts '✗ Failed to connect to server'
55
+ exit(1)
56
+ end
57
+ end
58
+
59
+ # List available tools
60
+ def list_tools(client)
61
+ puts 'Fetching available tools...'
62
+ tools = client.list_tools
63
+
64
+ puts "Available tools (#{tools.size}):"
65
+ tools.each_with_index do |tool, i|
66
+ puts "#{i + 1}. #{tool[:name]} - #{tool[:description]}"
67
+ end
68
+ puts ''
69
+
70
+ tools
71
+ end
72
+
73
+ # Demo the filesystem tools
74
+ def demo_filesystem_tools(client, _tools)
75
+ # Get the list of allowed directories
76
+ puts 'Getting allowed directories...'
77
+ result = client.call_tool(
78
+ name: 'list_allowed_directories',
79
+ arguments: {}
80
+ )
81
+ allowed_dirs = extract_text_content(result)
82
+ puts allowed_dirs
83
+ puts ''
84
+
85
+ # List files in current directory
86
+ puts 'Listing files in current directory...'
87
+ result = client.call_tool(
88
+ name: 'list_directory',
89
+ arguments: {
90
+ path: '.'
91
+ }
92
+ )
93
+ puts extract_text_content(result)
94
+ puts ''
95
+
96
+ # Create a temporary file within the project directory
97
+ temp_file = "examples/mcp_demo_#{Time.now.to_i}.txt"
98
+ puts "Creating a temporary file at #{temp_file}..."
99
+
100
+ content = "This is a test file created by the MCP filesystem demo at #{Time.now}"
101
+ result = client.call_tool(
102
+ name: 'write_file',
103
+ arguments: {
104
+ path: temp_file,
105
+ content: content
106
+ }
107
+ )
108
+ puts extract_text_content(result)
109
+ puts ''
110
+
111
+ # Read the file back
112
+ puts 'Reading back the file content...'
113
+ result = client.call_tool(
114
+ name: 'read_file',
115
+ arguments: {
116
+ path: temp_file
117
+ }
118
+ )
119
+ puts "File content: #{extract_text_content(result)}"
120
+ puts ''
121
+
122
+ # Get file info
123
+ puts 'Getting file information...'
124
+ result = client.call_tool(
125
+ name: 'get_file_info',
126
+ arguments: {
127
+ path: temp_file
128
+ }
129
+ )
130
+ puts extract_text_content(result)
131
+ puts ''
132
+
133
+ # Clean up - Move the file to a .bak extension to demonstrate move_file
134
+ puts 'Demonstrating move_file by renaming the temporary file...'
135
+ bak_file = "#{temp_file}.bak"
136
+ result = client.call_tool(
137
+ name: 'move_file',
138
+ arguments: {
139
+ source: temp_file,
140
+ destination: bak_file
141
+ }
142
+ )
143
+ puts extract_text_content(result)
144
+ puts "File renamed to: #{bak_file}"
145
+ puts ''
146
+
147
+ # Search for our backup file
148
+ puts 'Searching for our backup file...'
149
+ result = client.call_tool(
150
+ name: 'search_files',
151
+ arguments: {
152
+ path: 'examples',
153
+ pattern: 'mcp_demo_'
154
+ }
155
+ )
156
+ puts extract_text_content(result)
157
+ puts ''
158
+
159
+ puts "Note: Remember to manually delete the backup file: #{bak_file}"
160
+ puts ''
161
+ end
162
+
163
+ # Helper to extract text content from a tool response
164
+ def extract_text_content(result)
165
+ if result && result[:content]&.first
166
+ content_item = result[:content].first
167
+ if content_item[:type] == 'text'
168
+ content_item[:text]
169
+ else
170
+ content_item.inspect
171
+ end
172
+ else
173
+ 'No content in response'
174
+ end
175
+ end
176
+
177
+ # Main program
178
+ def main
179
+ client = create_client
180
+ connect_to_server(client)
181
+
182
+ tools = list_tools(client)
183
+ demo_filesystem_tools(client, tools)
184
+
185
+ puts 'Demo completed successfully!'
186
+ ensure
187
+ client&.close
188
+ end
189
+
190
+ # Run the demo
191
+ main if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'encom/error_codes'
5
+ module Encom
6
+ class Client
7
+ LATEST_PROTOCOL_VERSION = '2024-11-05'
8
+ SUPPORTED_PROTOCOL_VERSIONS = [
9
+ LATEST_PROTOCOL_VERSION
10
+ # Add more supported versions as they're developed
11
+ ].freeze
12
+
13
+ class ProtocolVersionError < StandardError; end
14
+ class ConnectionError < StandardError; end
15
+ class ToolError < StandardError; end
16
+ class RequestTimeoutError < StandardError; end
17
+
18
+ attr_reader :name, :version, :responses, :server_info, :server_capabilities,
19
+ :initialized, :protocol_version, :tool_responses
20
+
21
+ def initialize(name:, version:, capabilities:)
22
+ @name = name
23
+ @version = version
24
+ @capabilities = capabilities
25
+ @message_id = 0
26
+ @responses = []
27
+ @tool_responses = {}
28
+ @pending_requests = {}
29
+ @response_mutex = Mutex.new
30
+ @response_condition = ConditionVariable.new
31
+ @initialized = false
32
+ @closing = false
33
+ @error_handlers = []
34
+ @first_error_reported = false # Flag to track if we've already reported an error
35
+ end
36
+
37
+ # Register a callback for error handling
38
+ def on_error(&block)
39
+ @error_handlers << block
40
+ self
41
+ end
42
+
43
+ def connect(transport)
44
+ @transport = transport
45
+
46
+ @transport
47
+ .on_close { close }
48
+ .on_data { |data| handle_response(data) }
49
+ .on_error { |error| handle_transport_error(error) }
50
+ .start
51
+
52
+ # Send initialize request
53
+ request(
54
+ {
55
+ method: 'initialize',
56
+ params: {
57
+ protocolVersion: LATEST_PROTOCOL_VERSION,
58
+ capabilities: @capabilities,
59
+ clientInfo: {
60
+ name: @name,
61
+ version: @version
62
+ }
63
+ }
64
+ }
65
+ )
66
+ rescue JSON::ParserError => e
67
+ # This might be a protocol version error or other startup error
68
+ trigger_error(ConnectionError.new("Error parsing initial response: #{e.message}"))
69
+ end
70
+
71
+ def handle_transport_error(error)
72
+ trigger_error(ConnectionError.new("Transport error: #{error.message}"))
73
+ end
74
+
75
+ def handle_response(data)
76
+ data = data.strip if data.is_a?(String)
77
+ parsed_response = JSON.parse(data, symbolize_names: true)
78
+
79
+ @responses << parsed_response
80
+
81
+ # Check for protocol errors immediately, even without an ID
82
+ if parsed_response[:error] &&
83
+ parsed_response[:error][:code] == Encom::ErrorCodes::PROTOCOL_ERROR &&
84
+ parsed_response[:error][:message].include?('Unsupported protocol version')
85
+ # Only trigger a protocol error if we haven't already closed the connection
86
+ unless @closing
87
+ error = ProtocolVersionError.new(parsed_response[:error][:message])
88
+ close
89
+ trigger_error(error)
90
+ end
91
+ return
92
+ end
93
+
94
+ if parsed_response[:id]
95
+ @response_mutex.synchronize do
96
+ @tool_responses[parsed_response[:id]] = parsed_response
97
+ @response_condition.broadcast # Signal threads waiting for this response
98
+ end
99
+
100
+ if parsed_response[:result]
101
+ handle_initialize_result(parsed_response) if @pending_requests[parsed_response[:id]] == 'initialize'
102
+ elsif parsed_response[:error]
103
+ handle_error(parsed_response)
104
+ end
105
+ end
106
+ rescue JSON::ParserError => e
107
+ error_msg = "Error parsing response: #{e.message}, Raw response: #{data.inspect}"
108
+ trigger_error(ConnectionError.new(error_msg))
109
+ end
110
+
111
+ def handle_initialize_result(response)
112
+ @server_info = response[:result][:serverInfo]
113
+ @server_capabilities = response[:result][:capabilities]
114
+ @protocol_version = response[:result][:protocolVersion]
115
+
116
+ unless SUPPORTED_PROTOCOL_VERSIONS.include?(@protocol_version)
117
+ error_message = "Unsupported protocol version: #{@protocol_version}. " \
118
+ "This client supports: #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}"
119
+ error = ProtocolVersionError.new(error_message)
120
+ close
121
+ trigger_error(error)
122
+ return
123
+ end
124
+
125
+ @initialized = true
126
+
127
+ notification(
128
+ {
129
+ method: 'initialized',
130
+ params: {}
131
+ }
132
+ )
133
+ end
134
+
135
+ def handle_error(response)
136
+ # Don't process errors if we're already closing
137
+ return if @closing
138
+
139
+ # Check if this is an error response to an initialize request
140
+ if @pending_requests[response[:id]] == 'initialize' &&
141
+ response[:error][:code] == Encom::ErrorCodes::PROTOCOL_ERROR
142
+ error = ProtocolVersionError.new("Unsupported protocol version: #{response[:error][:message]}")
143
+ close
144
+ trigger_error(error)
145
+ return
146
+ end
147
+
148
+ error_msg = "Error from server: #{response[:error][:message]} (#{response[:error][:code]})"
149
+ trigger_error(ConnectionError.new(error_msg))
150
+ end
151
+
152
+ def trigger_error(error)
153
+ # Only report the first error
154
+ return if @first_error_reported
155
+
156
+ @first_error_reported = true
157
+
158
+ if @error_handlers.empty?
159
+ # TODO: I'd love to re-raise this to the user but this ends up run
160
+ # in a background thread right now due to how we've implement the stdio transport
161
+ puts "MCP Client Error: #{error.message}"
162
+ else
163
+ @error_handlers.each { |handler| handler.call(error) }
164
+ end
165
+ end
166
+
167
+ def request(request_data)
168
+ @message_id += 1
169
+ id = @message_id
170
+
171
+ @pending_requests[id] = request_data[:method]
172
+
173
+ @transport.send(
174
+ JSON.generate({
175
+ jsonrpc: '2.0',
176
+ id: id
177
+ }.merge(request_data))
178
+ )
179
+
180
+ id
181
+ end
182
+
183
+ def notification(notification_data)
184
+ @transport.send(
185
+ JSON.generate({
186
+ jsonrpc: '2.0'
187
+ }.merge(notification_data))
188
+ )
189
+ end
190
+
191
+ # Wait for a response with a specific ID, with timeout
192
+ #
193
+ # @param id [Integer] The ID of the request to wait for
194
+ # @param timeout [Numeric] The timeout in seconds
195
+ # @return [Hash] The response
196
+ # @raise [RequestTimeoutError] If the timeout is reached
197
+ def wait_for_response(id, timeout = 5)
198
+ deadline = Time.now + timeout
199
+
200
+ @response_mutex.synchronize do
201
+ @response_condition.wait(@response_mutex, 0.1) while !@tool_responses.key?(id) && Time.now < deadline
202
+
203
+ raise RequestTimeoutError, "Timeout waiting for response to request #{id}" unless @tool_responses.key?(id)
204
+
205
+ @tool_responses[id]
206
+ end
207
+ end
208
+
209
+ # List available tools from the server
210
+ #
211
+ # @param params [Hash, nil] Optional parameters for the list_tools request
212
+ # @param timeout [Numeric] The timeout in seconds
213
+ # @return [Array<Hash>] The list of tools
214
+ # @raise [RequestTimeoutError] If the timeout is reached
215
+ # @raise [ConnectionError] If there is an error communicating with the server
216
+ def list_tools(params = nil, timeout = 5)
217
+ request_data = {
218
+ method: 'tools/list'
219
+ }
220
+
221
+ request_data[:params] = params if params
222
+
223
+ id = request(request_data)
224
+
225
+ # Wait for the response
226
+ response = wait_for_response(id, timeout)
227
+
228
+ if response[:error]
229
+ error_msg = "Error from server: #{response[:error][:message]} (#{response[:error][:code]})"
230
+ raise ConnectionError, error_msg
231
+ end
232
+
233
+ # Return the tools array
234
+ response[:result][:tools]
235
+ end
236
+
237
+ # Call a tool on the server
238
+ #
239
+ # @param name [String] The name of the tool to call
240
+ # @param arguments [Hash] The arguments to pass to the tool
241
+ # @param timeout [Numeric] The timeout in seconds
242
+ # @return [Hash] The tool result containing content array
243
+ # @raise [RequestTimeoutError] If the timeout is reached
244
+ # @raise [ToolError] If there is an error with the tool execution
245
+ # @raise [ConnectionError] If there is an error communicating with the server
246
+ def call_tool(name:, arguments:, timeout: 5)
247
+ id = request(
248
+ {
249
+ method: 'tools/call',
250
+ params: {
251
+ name: name,
252
+ arguments: arguments
253
+ }
254
+ }
255
+ )
256
+
257
+ # Wait for the response
258
+ response = wait_for_response(id, timeout)
259
+
260
+ if response[:error]
261
+ error_msg = "Tool error: #{response[:error][:message]} (#{response[:error][:code]})"
262
+ raise ToolError, error_msg
263
+ end
264
+
265
+ # Return the result content
266
+ response[:result]
267
+ end
268
+
269
+ # Get the list of tools from a previous list_tools request
270
+ #
271
+ # @param request_id [Integer] The ID of the list_tools request
272
+ # @return [Array<Hash>, nil] The list of tools or nil if the response isn't available
273
+ def get_tools(request_id)
274
+ response = @tool_responses[request_id]
275
+ return nil unless response && response[:result]
276
+
277
+ response[:result][:tools]
278
+ end
279
+
280
+ # Get the result of a tool call
281
+ #
282
+ # @param request_id [Integer] The ID of the call_tool request
283
+ # @return [Hash, nil] The tool result or nil if the response isn't available
284
+ def get_tool_result(request_id)
285
+ response = @tool_responses[request_id]
286
+ return nil unless response
287
+
288
+ if response[:error]
289
+ error_msg = "Tool error: #{response[:error][:message]} (#{response[:error][:code]})"
290
+ raise ToolError, error_msg
291
+ end
292
+
293
+ response[:result]
294
+ end
295
+
296
+ def close
297
+ return if @closing
298
+
299
+ @closing = true
300
+ puts 'Closing'
301
+
302
+ return unless @transport
303
+
304
+ @transport.close
305
+ @transport = nil
306
+ end
307
+ end
308
+ end