actionmcp 0.1.2 → 0.2.3

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +133 -30
  3. data/Rakefile +0 -2
  4. data/exe/actionmcp_cli +221 -0
  5. data/lib/action_mcp/capability.rb +52 -0
  6. data/lib/action_mcp/client.rb +249 -0
  7. data/lib/action_mcp/configuration.rb +55 -1
  8. data/lib/action_mcp/content/audio.rb +9 -0
  9. data/lib/action_mcp/content/image.rb +9 -0
  10. data/lib/action_mcp/content/resource.rb +13 -0
  11. data/lib/action_mcp/content/text.rb +8 -1
  12. data/lib/action_mcp/content.rb +13 -3
  13. data/lib/action_mcp/engine.rb +34 -0
  14. data/lib/action_mcp/gem_version.rb +2 -2
  15. data/lib/action_mcp/integer_array.rb +17 -0
  16. data/lib/action_mcp/json_rpc/json_rpc_error.rb +22 -1
  17. data/lib/action_mcp/json_rpc/notification.rb +13 -6
  18. data/lib/action_mcp/json_rpc/request.rb +26 -2
  19. data/lib/action_mcp/json_rpc/response.rb +42 -31
  20. data/lib/action_mcp/json_rpc.rb +1 -7
  21. data/lib/action_mcp/json_rpc_handler.rb +106 -0
  22. data/lib/action_mcp/logging.rb +19 -0
  23. data/lib/action_mcp/prompt.rb +33 -45
  24. data/lib/action_mcp/prompts_registry.rb +32 -1
  25. data/lib/action_mcp/registry_base.rb +72 -40
  26. data/lib/action_mcp/renderable.rb +54 -0
  27. data/lib/action_mcp/resource.rb +5 -3
  28. data/lib/action_mcp/server.rb +10 -0
  29. data/lib/action_mcp/string_array.rb +14 -0
  30. data/lib/action_mcp/tool.rb +112 -102
  31. data/lib/action_mcp/tools_registry.rb +28 -3
  32. data/lib/action_mcp/transport/capabilities.rb +21 -0
  33. data/lib/action_mcp/transport/messaging.rb +20 -0
  34. data/lib/action_mcp/transport/prompts.rb +19 -0
  35. data/lib/action_mcp/transport/sse_client.rb +309 -0
  36. data/lib/action_mcp/transport/stdio_client.rb +117 -0
  37. data/lib/action_mcp/transport/tools.rb +20 -0
  38. data/lib/action_mcp/transport/transport_base.rb +125 -0
  39. data/lib/action_mcp/transport.rb +1 -238
  40. data/lib/action_mcp/transport_handler.rb +54 -0
  41. data/lib/action_mcp/version.rb +4 -5
  42. data/lib/action_mcp.rb +40 -27
  43. data/lib/generators/action_mcp/install/install_generator.rb +2 -0
  44. data/lib/generators/action_mcp/prompt/templates/prompt.rb.erb +3 -1
  45. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +5 -1
  46. data/lib/tasks/action_mcp_tasks.rake +28 -5
  47. metadata +68 -10
  48. data/exe/action_mcp_stdio +0 -0
  49. data/lib/action_mcp/json_rpc/base.rb +0 -12
  50. data/lib/action_mcp/railtie.rb +0 -27
  51. data/lib/action_mcp/resources_bank.rb +0 -96
@@ -1,141 +1,114 @@
1
- # lib/action_mcp/tool.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActionMCP
5
- class Tool
6
- include ActiveModel::Model
7
- include ActiveModel::Attributes
8
-
9
- class_attribute :_tool_name, instance_accessor: false
10
- class_attribute :_description, instance_accessor: false, default: ""
4
+ # Base class for defining tools.
5
+ #
6
+ # Provides a DSL for specifying metadata, properties, and nested collection schemas.
7
+ # Tools are registered automatically in the ToolsRegistry unless marked as abstract.
8
+ class Tool < Capability
9
+ # --------------------------------------------------------------------------
10
+ # Class Attributes for Tool Metadata and Schema
11
+ # --------------------------------------------------------------------------
12
+ # @!attribute _schema_properties
13
+ # @return [Hash] The schema properties of the tool.
14
+ # @!attribute _required_properties
15
+ # @return [Array<String>] The required properties of the tool.
11
16
  class_attribute :_schema_properties, instance_accessor: false, default: {}
12
17
  class_attribute :_required_properties, instance_accessor: false, default: []
13
- class_attribute :abstract_tool, instance_accessor: false, default: false
14
-
15
- # Register each non-abstract subclass in ToolsRegistry
16
- def self.inherited(subclass)
17
- super
18
- return if subclass == Tool
19
-
20
- subclass.abstract_tool = false
21
- return if subclass.name == "ApplicationTool"
22
-
23
- ToolsRegistry.register(subclass.tool_name, subclass)
24
- end
25
18
 
26
- # Mark this tool as abstract so it won’t be available for use.
27
- def self.abstract!
28
- self.abstract_tool = true
29
- ToolsRegistry.unregister(tool_name) if ToolsRegistry.items.key?(tool_name)
30
- end
31
-
32
- def self.abstract?
33
- abstract_tool
34
- end
35
-
36
- # ---------------------------------------------------
37
- # Tool Name & Description
38
- # ---------------------------------------------------
19
+ # --------------------------------------------------------------------------
20
+ # Tool Name and Description DSL
21
+ # --------------------------------------------------------------------------
22
+ # Sets or retrieves the tool's name.
23
+ #
24
+ # @param name [String, nil] Optional. The name to set for the tool.
25
+ # @return [String] The current tool name.
39
26
  def self.tool_name(name = nil)
40
27
  if name
41
- self._tool_name = name
28
+ self._capability_name = name
42
29
  else
43
- _tool_name || default_tool_name
30
+ _capability_name || default_tool_name
44
31
  end
45
32
  end
46
33
 
34
+ # Returns a default tool name based on the class name.
35
+ #
36
+ # @return [String] The default tool name.
47
37
  def self.default_tool_name
48
- name.demodulize.underscore.dasherize.sub(/-tool$/, "")
38
+ name.demodulize.underscore.sub(/_tool$/, "")
49
39
  end
50
40
 
51
- def self.description(text = nil)
52
- if text
53
- self._description = text
54
- else
55
- _description
56
- end
41
+ class << self
42
+ alias default_capability_name default_tool_name
57
43
  end
58
44
 
59
- # ---------------------------------------------------
45
+ # --------------------------------------------------------------------------
60
46
  # Property DSL (Direct Declaration)
61
- # ---------------------------------------------------
47
+ # --------------------------------------------------------------------------
48
+ # Defines a property for the tool.
49
+ #
50
+ # This method builds a JSON Schema definition for the property, registers it
51
+ # in the tool's schema, and creates an ActiveModel attribute for it.
52
+ #
53
+ # @param prop_name [Symbol, String] The property name.
54
+ # @param type [String] The JSON Schema type (default: "string").
55
+ # @param description [String, nil] Optional description for the property.
56
+ # @param required [Boolean] Whether the property is required (default: false).
57
+ # @param default [Object, nil] The default value for the property.
58
+ # @param opts [Hash] Additional options for the JSON Schema.
59
+ # @return [void]
62
60
  def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
63
- # Build JSON Schema definition for the property.
61
+ # Build the JSON Schema definition.
64
62
  prop_definition = { type: type }
65
63
  prop_definition[:description] = description if description && !description.empty?
66
64
  prop_definition.merge!(opts) if opts.any?
67
65
 
68
66
  self._schema_properties = _schema_properties.merge(prop_name.to_s => prop_definition)
69
- self._required_properties = _required_properties.dup
70
- _required_properties << prop_name.to_s if required
71
-
72
- # Map our DSL type to an ActiveModel attribute type.
73
- am_type = case type.to_s
74
- when "number" then :float
75
- when "integer" then :integer
76
- when "array" then :string
77
- else
78
- :string
67
+ self._required_properties = _required_properties.dup.tap do |req|
68
+ req << prop_name.to_s if required
79
69
  end
80
- attribute prop_name, am_type, default: default
70
+
71
+ # Map the JSON Schema type to an ActiveModel attribute type.
72
+ attribute prop_name, map_json_type_to_active_model_type(type), default: default
73
+ validates prop_name, presence: true, if: -> { required }
74
+
75
+ return unless %w[number integer].include?(type)
76
+
77
+ validates prop_name, numericality: true
81
78
  end
82
79
 
83
- # ---------------------------------------------------
80
+ # --------------------------------------------------------------------------
84
81
  # Collection DSL
85
- # ---------------------------------------------------
86
- # Supports two forms:
87
- #
88
- # 1. Without a block:
89
- # collection :args, type: "string", description: "Command arguments"
82
+ # --------------------------------------------------------------------------
83
+ # Defines a collection property for the tool.
90
84
  #
91
- # 2. With a block (defining a nested object):
92
- # collection :files, description: "List of Files" do
93
- # property :file, required: true, description: 'file uri'
94
- # property :checksum, required: true, description: 'checksum to verify'
95
- # end
96
- def self.collection(prop_name, type: nil, description: nil, required: false, default: nil, **_opts, &block)
97
- if block_given?
98
- # Build nested schema for an object.
99
- nested_schema = { type: "object", properties: {}, required: [] }
100
- dsl = CollectionDSL.new(nested_schema)
101
- dsl.instance_eval(&block)
102
- collection_definition = { type: "array", description: description, items: nested_schema }
103
- else
104
- raise ArgumentError, "Type is required for a collection without a block" if type.nil?
85
+ # @param prop_name [Symbol, String] The collection property name.
86
+ # @param type [String] The type for collection items.
87
+ # @param description [String, nil] Optional description for the collection.
88
+ # @param required [Boolean] Whether the collection is required (default: false).
89
+ # @param default [Array, nil] The default value for the collection.
90
+ # @return [void]
91
+ def self.collection(prop_name, type:, description: nil, required: false, default: [])
92
+ raise ArgumentError, "Type is required for a collection" if type.nil?
105
93
 
106
- collection_definition = { type: "array", description: description, items: { type: type } }
107
- end
94
+ collection_definition = { type: "array", description: description, items: { type: type } }
108
95
 
109
96
  self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
110
- self._required_properties = _required_properties.dup
111
- _required_properties << prop_name.to_s if required
112
-
113
- # Register the property as an attribute.
114
- # (Mapping for a collection can be customized; here we use :string to mimic previous behavior.)
115
- attribute prop_name, :string, default: default
116
- end
117
-
118
- # DSL for building a nested schema within a collection block.
119
- class CollectionDSL
120
- attr_reader :schema
121
-
122
- def initialize(schema)
123
- @schema = schema
97
+ self._required_properties = _required_properties.dup.tap do |req|
98
+ req << prop_name.to_s if required
124
99
  end
125
100
 
126
- def property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
127
- prop_definition = { type: type }
128
- prop_definition[:description] = description if description && !description.empty?
129
- prop_definition.merge!(opts) if opts.any?
130
-
131
- @schema[:properties][prop_name.to_s] = prop_definition
132
- @schema[:required] << prop_name.to_s if required
133
- end
101
+ type = map_json_type_to_active_model_type("array_#{type}")
102
+ attribute prop_name, type, default: default
103
+ validates prop_name, presence: true, if: -> { required }
134
104
  end
135
105
 
136
- # ---------------------------------------------------
137
- # Convert Tool Definition to Hash
138
- # ---------------------------------------------------
106
+ # --------------------------------------------------------------------------
107
+ # Tool Definition Serialization
108
+ # --------------------------------------------------------------------------
109
+ # Returns a hash representation of the tool definition including its JSON Schema.
110
+ #
111
+ # @return [Hash] The tool definition.
139
112
  def self.to_h
140
113
  schema = { type: "object", properties: _schema_properties }
141
114
  schema[:required] = _required_properties if _required_properties.any?
@@ -145,5 +118,42 @@ module ActionMCP
145
118
  inputSchema: schema
146
119
  }.compact
147
120
  end
121
+
122
+ # --------------------------------------------------------------------------
123
+ # Instance Methods
124
+ # --------------------------------------------------------------------------
125
+ # Abstract method to perform the tool's action.
126
+ #
127
+ # Subclasses must implement this method.
128
+ #
129
+ # @raise [NotImplementedError] Always raised if not implemented in a subclass.
130
+ # @return [Array<Content>] Array of Content objects is expected as return value
131
+ def call
132
+ raise NotImplementedError, "Subclasses must implement the call method"
133
+ # Default implementation (no-op)
134
+ # In a real subclass, you might do:
135
+ # def call
136
+ # # Perform logic, e.g. analyze code, etc.
137
+ # # Array of Content objects is expected as return value
138
+ # end
139
+ end
140
+
141
+ private
142
+
143
+ # Maps a JSON Schema type to an ActiveModel attribute type.
144
+ #
145
+ # @param type [String] The JSON Schema type.
146
+ # @return [Symbol] The corresponding ActiveModel attribute type.
147
+ def self.map_json_type_to_active_model_type(type)
148
+ case type.to_s
149
+ when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
150
+ when "array_number" then :integer_array
151
+ when "array_integer" then :string_array
152
+ when "array_string" then :string_array
153
+ else :string
154
+ end
155
+ end
156
+
157
+ private_class_method :map_json_type_to_active_model_type
148
158
  end
149
159
  end
@@ -1,12 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # frozen_string_literal: true
4
-
5
3
  module ActionMCP
4
+ # Registry for managing tools.
6
5
  class ToolsRegistry < RegistryBase
7
6
  class << self
7
+ # @!method tools
8
+ # Returns all registered tools.
9
+ # @return [Hash] A hash of registered tools.
8
10
  alias tools items
9
- alias available_tools enabled
11
+
12
+ # Calls a tool with the given name and arguments.
13
+ #
14
+ # @param tool_name [String] The name of the tool to call.
15
+ # @param arguments [Hash] The arguments to pass to the tool.
16
+ # @param _metadata [Hash] Optional metadata.
17
+ # @return [Hash] A hash containing the tool's response.
18
+ def tool_call(tool_name, arguments, _metadata = {})
19
+ tool = find(tool_name)
20
+ tool = tool.new(arguments)
21
+ tool.validate
22
+ if tool.valid?
23
+ { content: [ tool.call ] }
24
+ else
25
+ {
26
+ content: tool.errors.full_messages.map { |msg| Content::Text.new(msg) },
27
+ isError: true
28
+ }
29
+ end
30
+ end
31
+
32
+ def item_klass
33
+ Tool
34
+ end
10
35
  end
11
36
  end
12
37
  end
@@ -0,0 +1,21 @@
1
+ module ActionMCP
2
+ module Transport
3
+ module Capabilities
4
+ def send_capabilities(request_id, params = {})
5
+ @protocol_version = params["protocolVersion"]
6
+ @client_info = params["clientInfo"]
7
+ @client_capabilities = params["capabilities"]
8
+ capabilities = ActionMCP.configuration.capabilities
9
+
10
+ payload = {
11
+ protocolVersion: PROTOCOL_VERSION,
12
+ serverInfo: {
13
+ name: ActionMCP.configuration.name,
14
+ version: ActionMCP.configuration.version
15
+ }
16
+ }.merge(capabilities)
17
+ send_jsonrpc_response(request_id, result: payload)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ module ActionMCP
2
+ module Transport
3
+ module Messaging
4
+ def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
5
+ request = JsonRpc::Request.new(id: id, method: method, params: params)
6
+ write_message(request.to_json)
7
+ end
8
+
9
+ def send_jsonrpc_response(request_id, result: nil, error: nil)
10
+ response = JsonRpc::Response.new(id: request_id, result: result, error: error)
11
+ write_message(response.to_json)
12
+ end
13
+
14
+ def send_jsonrpc_notification(method, params = nil)
15
+ notification = JsonRpc::Notification.new(method: method, params: params)
16
+ write_message(notification.to_json)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ module ActionMCP
2
+ module Transport
3
+ module Prompts
4
+ def send_prompts_list(request_id)
5
+ prompts = format_registry_items(PromptsRegistry.non_abstract)
6
+ send_jsonrpc_response(request_id, result: { prompts: prompts })
7
+ end
8
+
9
+ def send_prompts_get(request_id, prompt_name, params)
10
+ send_jsonrpc_response(request_id, result: PromptsRegistry.prompt_call(prompt_name.to_s, params))
11
+ rescue RegistryBase::NotFound
12
+ send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(
13
+ :method_not_found,
14
+ message: "Prompt not found: #{prompt_name}"
15
+ ).as_json)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,309 @@
1
+ require "faraday"
2
+ require "uri"
3
+
4
+ module ActionMCP
5
+ module Transport
6
+ class SSEClient < TransportBase
7
+ TIMEOUT = 10 # Increased from 1 second
8
+ ENDPOINT_TIMEOUT = 5 # Seconds
9
+
10
+ # Define a custom error class for connection issues
11
+ class ConnectionError < StandardError; end
12
+
13
+ def initialize(url, **options)
14
+ super(**options)
15
+ setup_connection(url)
16
+ @buffer = ""
17
+ @stop_requested = false
18
+ @endpoint_received = false
19
+ @endpoint_mutex = Mutex.new
20
+ @endpoint_condition = ConditionVariable.new
21
+
22
+ # Add connection state management
23
+ @connection_mutex = Mutex.new
24
+ @connection_condition = ConditionVariable.new
25
+ @connection_error = nil
26
+ @connected = false
27
+ end
28
+
29
+ def start(initialize_request_id)
30
+ log_info("Connecting to #{@base_url}#{@sse_path}...")
31
+ @stop_requested = false
32
+ @initialize_request_id = initialize_request_id
33
+
34
+ # Reset connection state before starting
35
+ @connection_mutex.synchronize do
36
+ @connected = false
37
+ @connection_error = nil
38
+ end
39
+
40
+ # Start connection thread
41
+ @sse_thread = Thread.new { listen_sse }
42
+
43
+ # Wait for endpoint instead of connection completion
44
+ wait_for_endpoint
45
+ end
46
+
47
+ def wait_for_endpoint
48
+ success = false
49
+ error = nil
50
+
51
+ @endpoint_mutex.synchronize do
52
+ unless @endpoint_received
53
+ # Wait with timeout for endpoint
54
+ timeout = @endpoint_condition.wait(@endpoint_mutex, ENDPOINT_TIMEOUT)
55
+
56
+ # Handle timeout
57
+ unless timeout || @endpoint_received
58
+ error = "Timeout waiting for MCP endpoint (#{ENDPOINT_TIMEOUT} seconds)"
59
+ end
60
+ end
61
+
62
+ success = @endpoint_received
63
+ end
64
+
65
+ if error
66
+ log_error(error)
67
+ raise ConnectionError.new(error)
68
+ end
69
+
70
+ # If we have the endpoint, consider the connection successful
71
+ if success
72
+ @connection_mutex.synchronize do
73
+ @connected = true
74
+ @connection_condition.broadcast
75
+ end
76
+ end
77
+
78
+ success
79
+ end
80
+
81
+ def send_message(json_rpc)
82
+ # Wait for endpoint if not yet received
83
+ unless endpoint_ready?
84
+ log_info("Waiting for endpoint before sending message...")
85
+ wait_for_endpoint
86
+ end
87
+
88
+ validate_post_endpoint
89
+ log_debug("\e[34m" + "--> #{json_rpc}" + "\e[0m")
90
+ send_http_request(json_rpc)
91
+ end
92
+
93
+ def stop
94
+ log_info("Stopping SSE connection...")
95
+ @stop_requested = true
96
+ cleanup_sse_thread
97
+ end
98
+
99
+ def ready?
100
+ endpoint_ready?
101
+ end
102
+
103
+ private
104
+
105
+ def setup_connection(url)
106
+ uri = URI.parse(url)
107
+ @base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
108
+ @sse_path = uri.path
109
+
110
+ @conn = Faraday.new(url: @base_url) do |f|
111
+ f.headers["User-Agent"] = user_agent
112
+
113
+ f.options.timeout = nil # No read timeout
114
+ f.options.open_timeout = 10 # Connection timeout
115
+
116
+ # Use Net::HTTP adapter explicitly as it works well with streaming
117
+ f.adapter :net_http do |http|
118
+ # Configure the adapter directly
119
+ http.read_timeout = nil # No read timeout at adapter level too
120
+ http.open_timeout = 10 # Connection timeout
121
+ end
122
+
123
+ # Add logger middleware
124
+ # f.response :logger, @logger, headers: true, bodies: true
125
+ end
126
+
127
+ @post_url = nil
128
+ end
129
+
130
+ def endpoint_ready?
131
+ @endpoint_mutex.synchronize { @endpoint_received }
132
+ end
133
+
134
+ private
135
+
136
+ # The listen_sse method should NOT mark connection as successful at the end
137
+ def listen_sse
138
+ log_info("Starting SSE listener...")
139
+
140
+ begin
141
+ @conn.get(@sse_path) do |req|
142
+ req.headers["Accept"] = "text/event-stream"
143
+ req.headers["Cache-Control"] = "no-cache"
144
+
145
+ req.options.on_data = Proc.new do |chunk, bytes|
146
+ handle_sse_data(chunk, bytes)
147
+ end
148
+ end
149
+
150
+ # This should never be reached during normal operation
151
+ # as the SSE connection stays open
152
+ rescue Faraday::ConnectionFailed => e
153
+ handle_connection_error(format_connection_error(e))
154
+ rescue => e
155
+ handle_connection_error("Unexpected error: #{e.message}")
156
+ end
157
+ end
158
+
159
+ def format_connection_error(error)
160
+ if error.message.include?("Connection refused")
161
+ "Connection refused - server at #{@base_url} is not running or not accepting connections"
162
+ else
163
+ "Connection failed: #{error.message}"
164
+ end
165
+ end
166
+
167
+ def handle_connection_error(message)
168
+ log_error("SSE connection failed: #{message}")
169
+
170
+ # Set error and notify waiting threads
171
+ @connection_mutex.synchronize do
172
+ @connection_error = message
173
+ @connection_condition.broadcast
174
+ end
175
+
176
+ @on_error&.call(StandardError.new(message))
177
+ end
178
+
179
+ # Send the initialized notification to the server
180
+ def send_initialized_notification
181
+ notification = JsonRpc::Notification.new(
182
+ method: "notifications/initialized"
183
+ )
184
+
185
+ logger.info("Sent initialized notification to server")
186
+ send_message(notification.to_json)
187
+ end
188
+
189
+ def handle_sse_data(chunk, _overall_bytes)
190
+ process_chunk(chunk)
191
+ throw :halt if @stop_requested
192
+ end
193
+
194
+ def process_chunk(chunk)
195
+ @buffer << chunk
196
+ # If the buffer does not contain a newline but appears to be a complete JSON object,
197
+ # flush it as a complete event.
198
+ if @buffer.strip.start_with?("{") && @buffer.strip.end_with?("}")
199
+ (@current_event ||= []) << @buffer.strip
200
+ @buffer = ""
201
+ return handle_complete_event
202
+ end
203
+ process_buffer while @buffer.include?("\n")
204
+ end
205
+
206
+
207
+ def process_buffer
208
+ line, _sep, rest = @buffer.partition("\n")
209
+ @buffer = rest
210
+
211
+ if line.strip.empty?
212
+ handle_complete_event
213
+ else
214
+ (@current_event ||= []) << line.strip
215
+ end
216
+ end
217
+
218
+ def handle_complete_event
219
+ return unless @current_event
220
+
221
+ handle_event(@current_event)
222
+ @current_event = nil
223
+ end
224
+
225
+ def handle_event(lines)
226
+ event_data = parse_event(lines)
227
+ process_event(event_data)
228
+ end
229
+
230
+ def parse_event(lines)
231
+ event_data = { type: "message", data: "" }
232
+ has_data_prefix = false
233
+
234
+ lines.each do |line|
235
+ if line.start_with?("event:")
236
+ event_data[:type] = line.split(":", 2)[1].strip
237
+ elsif line.start_with?("data:")
238
+ has_data_prefix = true
239
+ event_data[:data] << line.split(":", 2)[1].strip
240
+ end
241
+ end
242
+
243
+ # If no "data:" prefix was found, treat the entire event as data
244
+ unless has_data_prefix
245
+ event_data[:data] = lines.join("\n")
246
+ end
247
+ event_data
248
+ end
249
+
250
+ def process_event(event_data)
251
+ case event_data[:type]
252
+ when "endpoint" then set_post_endpoint(event_data[:data])
253
+ when "message" then handle_raw_message(event_data[:data])
254
+ when "ping" then log_debug("Received ping")
255
+ else log_error("Unknown event type: #{event_data[:type]}")
256
+ end
257
+ end
258
+
259
+ # Modify set_post_endpoint to mark connection as ready
260
+ def set_post_endpoint(endpoint_path)
261
+ @post_url = build_post_url(endpoint_path)
262
+ log_info("Received POST endpoint: #{@post_url}")
263
+
264
+ # Signal that we have received the endpoint
265
+ @endpoint_mutex.synchronize do
266
+ @endpoint_received = true
267
+ @endpoint_condition.broadcast
268
+ end
269
+
270
+ # Now that we have the endpoint, send initial capabilities
271
+ send_initial_capabilities
272
+ end
273
+
274
+ def build_post_url(endpoint_path)
275
+ URI.join(@base_url, endpoint_path).to_s
276
+ rescue StandardError
277
+ "#{@base_url}#{endpoint_path}"
278
+ end
279
+
280
+ def validate_post_endpoint
281
+ raise "MCP endpoint not set (no 'endpoint' event received)" unless @post_url
282
+ end
283
+
284
+ def send_http_request(json_rpc)
285
+ response = @conn.post(@post_url,
286
+ json_rpc,
287
+ { "Content-Type" => "application/json" }
288
+ )
289
+ handle_http_response(response)
290
+ end
291
+
292
+ def handle_http_response(response)
293
+ unless response.success?
294
+ log_error("HTTP POST failed: #{response.status} - #{response.body}")
295
+ end
296
+ end
297
+
298
+ def cleanup_sse_thread
299
+ return unless @sse_thread
300
+
301
+ @sse_thread.join(TIMEOUT) || @sse_thread.kill
302
+ end
303
+
304
+ def user_agent
305
+ "ActionMCP-sse-client"
306
+ end
307
+ end
308
+ end
309
+ end