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.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Encom
4
+ # Defines standard JSON-RPC error codes and MCP-specific error codes
5
+ module ErrorCodes
6
+ # Standard JSON-RPC error codes
7
+ PARSE_ERROR = -32_700
8
+ INVALID_REQUEST = -32_600
9
+ METHOD_NOT_FOUND = -32_601
10
+ INVALID_PARAMS = -32_602
11
+ INTERNAL_ERROR = -32_603
12
+
13
+ # MCP specific error codes
14
+ TOOL_EXECUTION_ERROR = -32_000
15
+ PROTOCOL_ERROR = -32_001
16
+ end
17
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Encom
4
+ class Server
5
+ class Tool
6
+ attr_reader :name, :description, :schema
7
+
8
+ # Initialize a new tool
9
+ #
10
+ # @param name [Symbol, String] The name of the tool
11
+ # @param description [String] A description of what the tool does
12
+ # @param schema [Hash] A hash describing the input schema for the tool
13
+ # @param proc [Proc] A proc that implements the tool's functionality
14
+ def initialize(name:, description:, schema:, proc:)
15
+ @name = name
16
+ @description = description
17
+ @schema = schema
18
+ @proc = proc
19
+ end
20
+
21
+ def definition
22
+ {
23
+ name: @name.to_s,
24
+ description: @description,
25
+ inputSchema: schema_definition
26
+ }.compact
27
+ end
28
+
29
+ def call(arguments)
30
+ result = nil
31
+
32
+ begin
33
+ if @proc.parameters.first && @proc.parameters.first[0] == :keyreq
34
+ # If the proc expects keyword arguments, pass the arguments hash
35
+ result = @proc.call(**arguments)
36
+ else
37
+ # Otherwise, pass arguments as an array
38
+ result = @proc.call(*arguments)
39
+ end
40
+
41
+ # Ensure the result is in the standard format
42
+ standardize_tool_response(result)
43
+ rescue StandardError => e
44
+ # Return error in MCP-compliant format as per docs
45
+ {
46
+ isError: true,
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text: "Error: #{e.message}"
51
+ }
52
+ ]
53
+ }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Standardize tool response to ensure it conforms to the MCP format
60
+ #
61
+ # @param result [Hash, String, Array, Object] The raw result from the tool proc
62
+ # @return [Hash] A standardized response hash with content array
63
+ def standardize_tool_response(result)
64
+ # If the result is already in the expected format (has content array), return it
65
+ return result if result.is_a?(Hash) && result[:content].is_a?(Array)
66
+
67
+ # If it's a hash with isError, leave it as is
68
+ return result if result.is_a?(Hash) && result[:isError]
69
+
70
+ # If it's a string, convert to text content
71
+ if result.is_a?(String)
72
+ return {
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: result
77
+ }
78
+ ]
79
+ }
80
+ end
81
+
82
+ # If it's an array, assume it's already a content array
83
+ if result.is_a?(Array)
84
+ return {
85
+ content: result
86
+ }
87
+ end
88
+
89
+ # For any other type, convert to string and wrap as text
90
+ {
91
+ content: [
92
+ {
93
+ type: 'text',
94
+ text: result.to_s
95
+ }
96
+ ]
97
+ }
98
+ end
99
+
100
+ def schema_definition
101
+ properties = {}
102
+ required = []
103
+
104
+ @schema.each do |key, value|
105
+ properties[key] = if value.is_a?(Hash)
106
+ {
107
+ type: ruby_type_to_json_type(value[:type]),
108
+ description: value[:description]
109
+ }.compact
110
+ else
111
+ {
112
+ type: ruby_type_to_json_type(value)
113
+ }.compact
114
+ end
115
+ end
116
+
117
+ @proc.parameters.each do |param_type, param_name|
118
+ required << param_name.to_s if param_type == :keyreq
119
+ end
120
+
121
+ {
122
+ type: 'object',
123
+ properties: properties,
124
+ required: required
125
+ }
126
+ end
127
+
128
+ def ruby_type_to_json_type(type)
129
+ return nil unless type
130
+
131
+ case type.to_s
132
+ when 'Integer'
133
+ 'number'
134
+ when 'String'
135
+ 'string'
136
+ when 'TrueClass', 'FalseClass', 'Boolean'
137
+ 'boolean'
138
+ when 'Float'
139
+ 'number'
140
+ when 'Array'
141
+ 'array'
142
+ when 'Hash'
143
+ 'object'
144
+ else
145
+ type.to_s.downcase
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,294 @@
1
+ require 'encom/server/tool'
2
+ require 'json'
3
+
4
+ module Encom
5
+ class Server
6
+ LATEST_PROTOCOL_VERSION = '2024-11-05'
7
+ SUPPORTED_PROTOCOL_VERSIONS = [
8
+ LATEST_PROTOCOL_VERSION
9
+ # Add more supported versions as they're developed
10
+ ].freeze
11
+
12
+ class << self
13
+ def name(server_name = nil)
14
+ @server_name ||= server_name
15
+ end
16
+
17
+ def version(version = nil)
18
+ @version ||= version
19
+ end
20
+
21
+ def tool(tool_name, description, schema, proc)
22
+ @tools ||= []
23
+ @tools << Tool.new(name: tool_name, description:, schema:, proc:)
24
+ end
25
+
26
+ attr_reader :tools
27
+ end
28
+
29
+ attr_reader :transport, :capabilities
30
+
31
+ def initialize(options = {})
32
+ @message_id = 0
33
+ @capabilities = options[:capabilities] || {
34
+ roots: {
35
+ listChanged: true
36
+ },
37
+ sampling: {},
38
+ tools: {}
39
+ }
40
+
41
+ # Validate protocol version immediately
42
+ protocol_version = options[:protocol_version] || LATEST_PROTOCOL_VERSION
43
+ unless SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
44
+ raise ArgumentError, "Unsupported protocol version: #{protocol_version}. Supported versions: #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}"
45
+ end
46
+
47
+ @protocol_version = protocol_version
48
+ @transport = nil
49
+ end
50
+
51
+ def name
52
+ self.class.name
53
+ end
54
+
55
+ def version
56
+ self.class.version
57
+ end
58
+
59
+ def tools
60
+ self.class.tools
61
+ end
62
+
63
+ def call_tool(name, arguments)
64
+ tool = tools.find { |t| t.name.to_s == name.to_s || t.name == name.to_sym }
65
+ raise "Unknown tool: #{name}" unless tool
66
+ tool.call(arguments)
67
+ end
68
+
69
+ # Run the server with the specified transport
70
+ def run(transport_class, transport_options = {})
71
+ @transport = transport_class.new(self, transport_options)
72
+ @transport.start
73
+ rescue StandardError => e
74
+ $stderr.puts "Error running server: #{e.message}"
75
+ $stderr.puts e.backtrace.join("\n") if transport_options[:debug]
76
+ raise
77
+ end
78
+
79
+ # Process incoming JSON-RPC message
80
+ def process_message(message)
81
+ return unless message.is_a?(Hash)
82
+
83
+ if @transport && @transport.respond_to?(:debug)
84
+ @transport.debug "Processing message: #{message.inspect}"
85
+ end
86
+
87
+ # Check for jsonrpc version
88
+ unless message[:jsonrpc] == '2.0'
89
+ if message[:id]
90
+ respond_error(message[:id], Encom::ErrorCodes::INVALID_REQUEST, 'Invalid JSON-RPC request')
91
+ end
92
+ return
93
+ end
94
+
95
+ # Process request by method
96
+ case message[:method]
97
+ when 'initialize'
98
+ handle_initialize(message)
99
+ when 'initialized'
100
+ handle_initialized(message)
101
+ when 'resources/list'
102
+ handle_resources_list(message)
103
+ when 'roots/list'
104
+ handle_roots_list(message)
105
+ when 'sampling/prepare'
106
+ handle_sampling_prepare(message)
107
+ when 'sampling/sample'
108
+ handle_sampling_sample(message)
109
+ when 'tools/list'
110
+ handle_tools_list(message)
111
+ when 'tools/call'
112
+ handle_tools_call(message)
113
+ when 'shutdown'
114
+ handle_shutdown(message)
115
+ else
116
+ if message[:id]
117
+ if @transport && @transport.respond_to?(:debug)
118
+ @transport.debug "Unknown method: #{message[:method]}"
119
+ end
120
+ respond_error(message[:id], Encom::ErrorCodes::METHOD_NOT_FOUND, "Method not found: #{message[:method]}")
121
+ end
122
+ end
123
+ rescue StandardError => e
124
+ if @transport && @transport.respond_to?(:debug)
125
+ @transport.debug "Error processing message: #{e.message}\n#{e.backtrace.join("\n")}"
126
+ end
127
+
128
+ if message && message[:id]
129
+ respond_error(message[:id], Encom::ErrorCodes::INTERNAL_ERROR, "Internal error: #{e.message}")
130
+ end
131
+ end
132
+
133
+ # Generate and send a JSON-RPC response
134
+ def respond(id, result)
135
+ return unless @transport
136
+
137
+ response = {
138
+ jsonrpc: "2.0",
139
+ id: id,
140
+ result: result
141
+ }
142
+
143
+ @transport.send_message(response)
144
+ end
145
+
146
+ # Generate and send a JSON-RPC error response
147
+ def respond_error(id, code, message, data = nil)
148
+ return unless @transport
149
+
150
+ response = {
151
+ jsonrpc: "2.0",
152
+ id: id,
153
+ error: {
154
+ code: code,
155
+ message: message
156
+ }
157
+ }
158
+ response[:error][:data] = data if data
159
+
160
+ @transport.send_message(response)
161
+ end
162
+
163
+ # Handle initialize request
164
+ def handle_initialize(message)
165
+ client_protocol_version = message[:params][:protocolVersion]
166
+ client_capabilities = message[:params][:capabilities]
167
+ client_info = message[:params][:clientInfo]
168
+
169
+ server_info = {
170
+ name: name,
171
+ version: version
172
+ }
173
+
174
+ # Debug log the received protocol version
175
+ if @transport && @transport.respond_to?(:debug)
176
+ @transport.debug "Received initialize request with protocol version: #{client_protocol_version}"
177
+ @transport.debug "Supported protocol versions: #{SUPPORTED_PROTOCOL_VERSIONS.inspect}"
178
+ end
179
+
180
+ # Check if the requested protocol version is supported
181
+ if SUPPORTED_PROTOCOL_VERSIONS.include?(client_protocol_version)
182
+ # Use the requested version if supported
183
+ protocol_version = client_protocol_version
184
+
185
+ if @transport && @transport.respond_to?(:debug)
186
+ @transport.debug "Protocol version #{protocol_version} is supported, sending success response"
187
+ end
188
+
189
+ # Send initialize response
190
+ respond(message[:id], {
191
+ protocolVersion: protocol_version,
192
+ capabilities: @capabilities,
193
+ serverInfo: server_info
194
+ })
195
+ else
196
+ # Return an error for unsupported protocol versions
197
+ if @transport && @transport.respond_to?(:debug)
198
+ @transport.debug "Protocol version error: Client requested unsupported version #{client_protocol_version}"
199
+ end
200
+
201
+ respond_error(
202
+ message[:id],
203
+ Encom::ErrorCodes::PROTOCOL_ERROR,
204
+ "Unsupported protocol version: #{client_protocol_version}",
205
+ { supportedVersions: SUPPORTED_PROTOCOL_VERSIONS }
206
+ )
207
+ end
208
+ end
209
+
210
+ # Handle initialized notification
211
+ def handle_initialized(message)
212
+ # No response needed for notifications
213
+ end
214
+
215
+ # Handle resources/list request
216
+ def handle_resources_list(message)
217
+ # Default implementation returns an empty list
218
+ respond(message[:id], {
219
+ resources: []
220
+ })
221
+ end
222
+
223
+ # Handle roots/list request
224
+ def handle_roots_list(message)
225
+ # Default implementation returns an empty list
226
+ respond(message[:id], {
227
+ roots: []
228
+ })
229
+ end
230
+
231
+ # Handle sampling/prepare request
232
+ def handle_sampling_prepare(message)
233
+ # Default implementation returns a simple response
234
+ respond(message[:id], {
235
+ prepared: false,
236
+ samplingId: nil
237
+ })
238
+ end
239
+
240
+ # Handle sampling/sample request
241
+ def handle_sampling_sample(message)
242
+ # Default implementation returns a simple response
243
+ respond(message[:id], {
244
+ completion: "Sampling not implemented",
245
+ completionId: nil
246
+ })
247
+ end
248
+
249
+ # Handle tools/list request
250
+ def handle_tools_list(message)
251
+ tool_definitions = tools ? tools.map(&:definition) : []
252
+
253
+ respond(message[:id], {
254
+ tools: tool_definitions
255
+ })
256
+ end
257
+
258
+ # Handle tools/call request
259
+ def handle_tools_call(message)
260
+ tool_name = message[:params][:name]
261
+ arguments = message[:params][:arguments] || {}
262
+
263
+ begin
264
+ result = call_tool(tool_name, arguments)
265
+ respond(message[:id], result)
266
+ rescue StandardError => e
267
+ respond_error(message[:id], Encom::ErrorCodes::TOOL_EXECUTION_ERROR, "Tool execution error: #{e.message}")
268
+ end
269
+ end
270
+
271
+ # Handle JSON-RPC shutdown request
272
+ def handle_shutdown(message)
273
+ if message[:id]
274
+ # If it's a request with ID, respond with a success result
275
+ respond(message[:id], {
276
+ shutdown: true
277
+ })
278
+ end
279
+ # Initiate clean shutdown
280
+ shutdown
281
+ end
282
+
283
+ # Shutdown the server and clean up
284
+ def shutdown
285
+ return if @shutting_down
286
+ @shutting_down = true
287
+
288
+ if @transport
289
+ @transport.stop
290
+ @transport = nil
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Encom
4
+ module ServerTransport
5
+ class Base
6
+ attr_reader :server
7
+
8
+ def initialize(server, options = {})
9
+ @server = server
10
+ @options = options
11
+ @debug = options[:debug] || false
12
+ end
13
+
14
+ # Start the transport - must be implemented by subclasses
15
+ def start
16
+ raise NotImplementedError, 'Subclasses must implement #start'
17
+ end
18
+
19
+ # Stop the transport - must be implemented by subclasses
20
+ def stop
21
+ raise NotImplementedError, 'Subclasses must implement #stop'
22
+ end
23
+
24
+ # Send a message through the transport - must be implemented by subclasses
25
+ def send_message(message)
26
+ raise NotImplementedError, 'Subclasses must implement #send_message'
27
+ end
28
+
29
+ # Process an incoming message - default implementation delegates to server
30
+ def process_message(message)
31
+ @server.process_message(message)
32
+ end
33
+
34
+ # Log debug information if debug is enabled
35
+ def debug(message)
36
+ return unless @debug
37
+
38
+ warn "[Encom::ServerTransport] #{message}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'encom/server_transport/base'
5
+
6
+ module Encom
7
+ module ServerTransport
8
+ class Stdio < Base
9
+ def start
10
+ debug 'Starting StdIO transport server'
11
+ debug 'Listening on stdin, writing to stdout...'
12
+
13
+ # Enable line buffering for stdout
14
+ $stdout.sync = true
15
+
16
+ # Set up signal handlers for graceful shutdown
17
+ setup_signal_handlers
18
+
19
+ @running = true
20
+
21
+ # Process messages until stdin is closed or shutdown is requested
22
+ while @running && (line = $stdin.gets)
23
+ begin
24
+ message = JSON.parse(line, symbolize_names: true)
25
+ debug "Received: #{message.inspect}"
26
+ process_message(message)
27
+ rescue JSON::ParserError => e
28
+ debug "Error parsing message: #{e.message}"
29
+ next
30
+ rescue StandardError => e
31
+ debug "Error processing message: #{e.message}"
32
+ next
33
+ end
34
+ end
35
+
36
+ debug 'StdIO transport server stopped'
37
+ end
38
+
39
+ def stop
40
+ debug 'Stopping StdIO transport server'
41
+ @running = false
42
+ # No specific cleanup needed for stdio beyond setting running to false
43
+ end
44
+
45
+ def send_message(message)
46
+ json = JSON.generate(message)
47
+ debug "Sending: #{message.inspect}"
48
+ puts json # Write to stdout
49
+ $stdout.flush
50
+ true
51
+ end
52
+
53
+ def debug(message)
54
+ return unless @debug
55
+
56
+ warn "[Encom::ServerTransport::Stdio] #{message}"
57
+ end
58
+
59
+ private
60
+
61
+ def setup_signal_handlers
62
+ # Handle INT (CTRL+C) and TERM signals for graceful shutdown
63
+ trap('INT') { handle_signal('INT') }
64
+ trap('TERM') { handle_signal('TERM') }
65
+ end
66
+
67
+ def handle_signal(signal)
68
+ debug "Received #{signal} signal, shutting down..."
69
+ stop
70
+ end
71
+ end
72
+ end
73
+ end