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 +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +159 -0
- data/Rakefile +10 -0
- data/examples/filesystem_demo.rb +191 -0
- data/lib/encom/client.rb +308 -0
- data/lib/encom/error_codes.rb +17 -0
- data/lib/encom/server/tool.rb +150 -0
- data/lib/encom/server.rb +294 -0
- data/lib/encom/server_transport/base.rb +42 -0
- data/lib/encom/server_transport/stdio.rb +73 -0
- data/lib/encom/transport/stdio.rb +236 -0
- data/lib/encom/version.rb +5 -0
- data/lib/encom.rb +8 -0
- data/sig/encom.rbs +4 -0
- metadata +62 -0
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
data/.standard.yml
ADDED
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,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__
|
data/lib/encom/client.rb
ADDED
|
@@ -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
|