actionmcp 0.102.0 → 0.103.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.
@@ -1,264 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # MCP client using Server-Sent Events (SSE) transport
6
- class StreamableClient < Base
7
- # Define a custom error class for connection issues
8
- class ConnectionError < StandardError; end
9
-
10
- SSE_TIMEOUT = 10
11
- ENDPOINT_TIMEOUT = 5 # Seconds
12
-
13
- attr_reader :base_url, :sse_path, :post_url, :session
14
-
15
- def initialize(url, connect: true, logger: ActionMCP.logger, **_options)
16
- gem "faraday", ">= 2.0"
17
- require "faraday"
18
- require "uri"
19
- super(logger: logger)
20
- @type = :sse
21
- setup_connection(url)
22
- @buffer = +""
23
- @stop_requested = false
24
- @endpoint_received = false
25
- @endpoint_mutex = Mutex.new
26
- @endpoint_condition = ConditionVariable.new
27
- @connection_mutex = Mutex.new
28
- @connection_condition = ConditionVariable.new
29
-
30
- self.connect if connect
31
- end
32
-
33
- protected
34
-
35
- def start_transport
36
- log_debug("Connecting to #{@base_url}#{@sse_path}...")
37
- @stop_requested = false
38
-
39
- # Reset connection state before starting
40
- @connection_mutex.synchronize do
41
- @connected = false
42
- @connection_error = nil
43
- end
44
-
45
- # Start connection thread
46
- @sse_thread = Thread.new { listen_sse }
47
-
48
- # Wait for endpoint
49
- wait_for_endpoint
50
- end
51
-
52
- def stop_transport
53
- log_debug("Stopping SSE connection...")
54
- @stop_requested = true
55
- cleanup_sse_thread
56
- end
57
-
58
- def send_message(json_rpc)
59
- response = @conn.post(post_url,
60
- json_rpc,
61
- { "Content-Type" => "application/json" })
62
- response.success?
63
- end
64
-
65
- def ready?
66
- endpoint_ready?
67
- end
68
-
69
- private
70
-
71
- def setup_connection(url)
72
- uri = URI.parse(url)
73
- @base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
74
- @sse_path = uri.path
75
-
76
- @conn = Faraday.new(url: @base_url) do |f|
77
- f.headers["User-Agent"] = user_agent
78
- f.options.timeout = nil # No read timeout
79
- f.options.open_timeout = SSE_TIMEOUT # Connection timeout
80
-
81
- # Use Net::HTTP adapter explicitly as it works well with streaming
82
- f.adapter :net_http do |http|
83
- http.read_timeout = nil # No read timeout at adapter level too
84
- http.open_timeout = SSE_TIMEOUT # Connection timeout
85
- end
86
- end
87
-
88
- @post_url = nil
89
- end
90
-
91
- def wait_for_endpoint
92
- success = false
93
- error = nil
94
-
95
- @endpoint_mutex.synchronize do
96
- unless @endpoint_received
97
- # Wait with timeout for endpoint
98
- timeout = @endpoint_condition.wait(@endpoint_mutex, ENDPOINT_TIMEOUT)
99
-
100
- # Handle timeout
101
- unless timeout || @endpoint_received
102
- error = "Timeout waiting for MCP endpoint (#{ENDPOINT_TIMEOUT} seconds)"
103
- end
104
- end
105
-
106
- success = @endpoint_received
107
- end
108
-
109
- if error
110
- log_error(error)
111
- raise ConnectionError, error
112
- end
113
-
114
- # If we have the endpoint, consider the connection successful
115
- if success
116
- @connection_mutex.synchronize do
117
- @connected = true
118
- @connection_condition.broadcast
119
- end
120
- end
121
-
122
- success
123
- end
124
-
125
- def endpoint_ready?
126
- @endpoint_mutex.synchronize { @endpoint_received }
127
- end
128
-
129
- def listen_sse
130
- log_debug("Starting SSE listener...")
131
-
132
- begin
133
- @conn.get(@sse_path) do |req|
134
- req.headers["Accept"] = "text/event-stream"
135
- req.headers["Cache-Control"] = "no-cache"
136
-
137
- req.options.on_data = proc do |chunk, bytes|
138
- handle_sse_data(chunk, bytes)
139
- end
140
- end
141
-
142
- # This should never be reached during normal operation
143
- # as the SSE connection stays open
144
- rescue Faraday::ConnectionFailed => e
145
- handle_connection_error(format_connection_error(e))
146
- end
147
- end
148
-
149
- def format_connection_error(error)
150
- if error.message.include?("Connection refused")
151
- "Connection refused - server at #{@base_url} is not running or not accepting connections"
152
- else
153
- "Connection failed: #{error.message}"
154
- end
155
- end
156
-
157
- def connection_error=(message)
158
- @connection_mutex.synchronize do
159
- @connection_error = message
160
- @connection_condition.broadcast
161
- end
162
- end
163
-
164
- def handle_connection_error(message)
165
- log_error("SSE connection failed: #{message}")
166
- self.connection_error = message
167
- @connected = false
168
- @error_callback&.call(StandardError.new(message))
169
- end
170
-
171
- def handle_sse_data(chunk, _overall_bytes)
172
- process_chunk(chunk)
173
- throw :halt if @stop_requested
174
- end
175
-
176
- def process_chunk(chunk)
177
- @buffer << chunk
178
- # If the buffer does not contain a newline but appears to be a complete JSON object,
179
- # flush it as a complete event.
180
- if @buffer.strip.match?(/^\{.*\}$/)
181
- (@current_event ||= []) << @buffer.strip
182
- @buffer = +""
183
- return handle_complete_event
184
- end
185
- process_buffer while @buffer.include?("\n")
186
- end
187
-
188
- def process_buffer
189
- line, _sep, rest = @buffer.partition("\n")
190
- @buffer = rest
191
-
192
- if line.strip.empty?
193
- handle_complete_event
194
- else
195
- (@current_event ||= []) << line.strip
196
- end
197
- end
198
-
199
- def handle_complete_event
200
- return unless @current_event
201
-
202
- handle_event(@current_event)
203
- @current_event = nil
204
- end
205
-
206
- def handle_event(lines)
207
- event_data = parse_event(lines)
208
- process_event(event_data)
209
- end
210
-
211
- def parse_event(lines)
212
- event_data = { type: "message", data: +"" }
213
- has_data_prefix = false
214
-
215
- lines.each do |line|
216
- if line.start_with?("event:")
217
- event_data[:type] = line.split(":", 2)[1].strip
218
- elsif line.start_with?("data:")
219
- has_data_prefix = true
220
- event_data[:data] << line.split(":", 2)[1].strip
221
- end
222
- end
223
-
224
- # If no "data:" prefix was found, treat the entire event as data
225
- event_data[:data] = lines.join("\n") unless has_data_prefix
226
- event_data
227
- end
228
-
229
- def process_event(event_data)
230
- case event_data[:type]
231
- when "endpoint" then set_post_endpoint(event_data[:data])
232
- when "message" then handle_raw_message(event_data[:data])
233
- else log_error("Unknown event type: #{event_data[:type]}")
234
- end
235
- end
236
-
237
- def set_post_endpoint(endpoint_path)
238
- @post_url = build_post_url(endpoint_path)
239
- log_debug("Received POST endpoint: #{post_url}")
240
-
241
- # Signal that we have received the endpoint
242
- @endpoint_mutex.synchronize do
243
- @endpoint_received = true
244
- @endpoint_condition.broadcast
245
- end
246
-
247
- # Now that we have the endpoint, send initial capabilities
248
- send_initial_capabilities
249
- end
250
-
251
- def build_post_url(endpoint_path)
252
- URI.join(@base_url, endpoint_path).to_s
253
- rescue StandardError
254
- "#{@base_url}#{endpoint_path}"
255
- end
256
-
257
- def cleanup_sse_thread
258
- return unless @sse_thread
259
-
260
- @sse_thread.join(SSE_TIMEOUT) || @sse_thread.kill
261
- end
262
- end
263
- end
264
- end
@@ -1,306 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "transport"
4
- require_relative "session_store"
5
-
6
- module ActionMCP
7
- module Client
8
- # StreamableHTTP transport implementation following MCP specification
9
- class StreamableHttpTransport < TransportBase
10
- class ConnectionError < StandardError; end
11
- class AuthenticationError < StandardError; end
12
-
13
- SSE_TIMEOUT = 10
14
- ENDPOINT_TIMEOUT = 5
15
-
16
- attr_reader :session_id, :last_event_id, :protocol_version
17
-
18
- def initialize(url, session_store:, session_id: nil, protocol_version: nil, **options)
19
- super(url, session_store: session_store, **options)
20
- @session_id = session_id
21
- @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
22
- @negotiated_protocol_version = nil
23
- @last_event_id = nil
24
- @buffer = +""
25
- @current_event = nil
26
- @reconnect_attempts = 0
27
- @max_reconnect_attempts = options[:max_reconnect_attempts] || 3
28
- @reconnect_delay = options[:reconnect_delay] || 1.0
29
-
30
- setup_http_client
31
- end
32
-
33
- def connect
34
- log_debug("Connecting via StreamableHTTP to #{@url}")
35
-
36
- # Load session if session_id provided
37
- load_session_state if @session_id
38
-
39
- # Start SSE stream if server supports it
40
- start_sse_stream
41
-
42
- # Set ready first, then connected (so transport is ready when on_connect fires)
43
- set_ready(true)
44
- set_connected(true)
45
- log_debug("StreamableHTTP connection established")
46
- true
47
- rescue StandardError => e
48
- handle_error(e)
49
- false
50
- end
51
-
52
- def disconnect
53
- return true unless connected?
54
-
55
- log_debug("Disconnecting StreamableHTTP")
56
- stop_sse_stream
57
- save_session_state if @session_id
58
- set_connected(false)
59
- set_ready(false)
60
- true
61
- rescue StandardError => e
62
- handle_error(e)
63
- false
64
- end
65
-
66
- def send_message(message)
67
- raise ConnectionError, "Transport not ready" unless ready?
68
-
69
- headers = build_post_headers
70
- json_data = message.is_a?(String) ? message : message.to_json
71
-
72
- log_debug("Sending message via POST")
73
- response = @http_client.post(@url, json_data, headers)
74
-
75
- handle_post_response(response, message)
76
- true
77
- rescue StandardError => e
78
- handle_error(e)
79
- false
80
- end
81
-
82
- private
83
-
84
- def setup_http_client
85
- require "faraday"
86
- @http_client = Faraday.new do |f|
87
- f.headers["User-Agent"] = user_agent
88
- f.options.timeout = nil # No read timeout for SSE
89
- f.options.open_timeout = SSE_TIMEOUT
90
- f.adapter :net_http
91
- end
92
- end
93
-
94
- def build_get_headers
95
- headers = {
96
- "Accept" => "text/event-stream",
97
- "Cache-Control" => "no-cache"
98
- }
99
- headers["mcp-session-id"] = @session_id if @session_id
100
- headers["Last-Event-ID"] = @last_event_id if @last_event_id
101
-
102
- # Add MCP-Protocol-Version header for GET requests when we have a negotiated version
103
- headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
104
-
105
- log_debug("Final GET headers: #{headers}")
106
- headers
107
- end
108
-
109
- def build_post_headers
110
- headers = {
111
- "Content-Type" => "application/json",
112
- "Accept" => "application/json, text/event-stream"
113
- }
114
- headers["mcp-session-id"] = @session_id if @session_id
115
-
116
- # Add MCP-Protocol-Version header as per 2025-06-18 spec
117
- # Only include when we have a negotiated version from previous handshake
118
- headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
119
-
120
- log_debug("Final POST headers: #{headers}")
121
- headers
122
- end
123
-
124
- def start_sse_stream
125
- log_debug("Starting SSE stream")
126
- @sse_thread = Thread.new { run_sse_stream }
127
- end
128
-
129
- def stop_sse_stream
130
- return unless @sse_thread
131
-
132
- log_debug("Stopping SSE stream")
133
- @stop_requested = true
134
- @sse_thread.kill if @sse_thread.alive?
135
- @sse_thread = nil
136
- @stop_requested = false
137
- end
138
-
139
- def run_sse_stream
140
- headers = build_get_headers
141
-
142
- @http_client.get(@url, nil, headers) do |req|
143
- req.options.on_data = proc do |chunk, _bytes|
144
- break if @stop_requested
145
-
146
- process_sse_chunk(chunk)
147
- end
148
- end
149
- rescue StandardError => e
150
- handle_sse_error(e)
151
- end
152
-
153
- def process_sse_chunk(chunk)
154
- @buffer << chunk
155
- process_complete_events while @buffer.include?("\n\n")
156
- end
157
-
158
- def process_complete_events
159
- event_data, _separator, rest = @buffer.partition("\n\n")
160
- @buffer = rest
161
-
162
- return if event_data.strip.empty?
163
-
164
- parse_sse_event(event_data)
165
- end
166
-
167
- def parse_sse_event(event_data)
168
- lines = event_data.split("\n")
169
- event_id = nil
170
- data_lines = []
171
-
172
- lines.each do |line|
173
- if line.start_with?("id:")
174
- event_id = line[3..].strip
175
- elsif line.start_with?("data:")
176
- data_lines << line[5..].strip
177
- end
178
- end
179
-
180
- return if data_lines.empty?
181
-
182
- @last_event_id = event_id if event_id
183
-
184
- begin
185
- message_data = data_lines.join("\n")
186
- message = MultiJson.load(message_data)
187
- handle_message(message)
188
- rescue MultiJson::ParseError => e
189
- log_error("Failed to parse SSE message: #{e}")
190
- end
191
- end
192
-
193
- def handle_post_response(response, _original_message)
194
- # Extract session ID from response headers
195
- @session_id = response.headers["mcp-session-id"] if response.headers["mcp-session-id"]
196
-
197
- case response.status
198
- when 200
199
- handle_success_response(response)
200
- when 202
201
- # Accepted - message received, no immediate response
202
- log_debug("Message accepted (202)")
203
- when 401
204
- raise AuthenticationError, "Authentication required"
205
- when 405
206
- # Method not allowed - server doesn't support this operation
207
- log_debug("Server returned 405 - operation not supported")
208
- else
209
- handle_error_response(response)
210
- end
211
- end
212
-
213
- def handle_success_response(response)
214
- content_type = response.headers["content-type"]
215
-
216
- if content_type&.include?("application/json")
217
- # Direct JSON response
218
- handle_json_response(response)
219
- elsif content_type&.include?("text/event-stream")
220
- # SSE response stream
221
- handle_sse_response_stream(response)
222
- end
223
- end
224
-
225
- def handle_json_response(response)
226
- message = MultiJson.load(response.body)
227
-
228
- # Check if this is an initialize response to capture negotiated protocol version
229
- if message.is_a?(Hash) && message["result"] && message["result"]["protocolVersion"]
230
- @negotiated_protocol_version = message["result"]["protocolVersion"]
231
- log_debug("Negotiated protocol version: #{@negotiated_protocol_version}")
232
- end
233
-
234
- handle_message(message)
235
- rescue MultiJson::ParseError => e
236
- log_error("Failed to parse JSON response: #{e}")
237
- end
238
-
239
- def handle_sse_response_stream(response)
240
- # Handle SSE stream from POST response
241
- response.body.each_line do |line|
242
- process_sse_chunk(line)
243
- end
244
- end
245
-
246
- def handle_error_response(response)
247
- error_msg = +"HTTP #{response.status}: #{response.reason_phrase}"
248
- error_msg << " - #{response.body}" if response.body && !response.body.empty?
249
- raise ConnectionError, error_msg
250
- end
251
-
252
- def handle_sse_error(error)
253
- log_error("SSE stream error: #{error.message}")
254
-
255
- if should_reconnect?
256
- schedule_reconnect
257
- else
258
- handle_error(error)
259
- end
260
- end
261
-
262
- def should_reconnect?
263
- connected? && @reconnect_attempts < @max_reconnect_attempts
264
- end
265
-
266
- def schedule_reconnect
267
- @reconnect_attempts += 1
268
- delay = @reconnect_delay * @reconnect_attempts
269
-
270
- log_debug("Scheduling SSE reconnect in #{delay}s (attempt #{@reconnect_attempts})")
271
-
272
- Thread.new do
273
- sleep(delay)
274
- start_sse_stream unless @stop_requested
275
- end
276
- end
277
-
278
- def load_session_state
279
- session_data = @session_store.load_session(@session_id)
280
- return unless session_data
281
-
282
- @last_event_id = session_data[:last_event_id]
283
- log_debug("Loaded session state: last_event_id=#{@last_event_id}")
284
- end
285
-
286
- def save_session_state
287
- return unless @session_id
288
-
289
- session_data = {
290
- id: @session_id,
291
- last_event_id: @last_event_id,
292
- session_data: {},
293
- protocol_version: @protocol_version
294
- }
295
-
296
- @session_store.save_session(@session_id, session_data)
297
- log_debug("Saved session state")
298
- end
299
-
300
-
301
- def user_agent
302
- "ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
303
- end
304
- end
305
- end
306
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Test session store that tracks all operations for assertions
6
- class TestSessionStore < VolatileSessionStore
7
- attr_reader :operations, :saved_sessions, :loaded_sessions,
8
- :deleted_sessions, :updated_sessions
9
-
10
- def initialize
11
- super
12
- @operations = Concurrent::Array.new
13
- @saved_sessions = Concurrent::Array.new
14
- @loaded_sessions = Concurrent::Array.new
15
- @deleted_sessions = Concurrent::Array.new
16
- @updated_sessions = Concurrent::Array.new
17
- end
18
-
19
- def load_session(session_id)
20
- session = super
21
- @operations << { type: :load, session_id: session_id, found: !session.nil? }
22
- @loaded_sessions << session_id if session
23
- session
24
- end
25
-
26
- def save_session(session_id, session_data)
27
- super
28
- @operations << { type: :save, session_id: session_id, data: session_data }
29
- @saved_sessions << session_id
30
- end
31
-
32
- def delete_session(session_id)
33
- result = super
34
- @operations << { type: :delete, session_id: session_id }
35
- @deleted_sessions << session_id
36
- result
37
- end
38
-
39
- def update_session(session_id, attributes)
40
- result = super
41
- @operations << { type: :update, session_id: session_id, attributes: attributes }
42
- @updated_sessions << session_id if result
43
- result
44
- end
45
-
46
- # Test helper methods
47
- def session_saved?(session_id)
48
- @saved_sessions.include?(session_id)
49
- end
50
-
51
- def session_loaded?(session_id)
52
- @loaded_sessions.include?(session_id)
53
- end
54
-
55
- def session_deleted?(session_id)
56
- @deleted_sessions.include?(session_id)
57
- end
58
-
59
- def session_updated?(session_id)
60
- @updated_sessions.include?(session_id)
61
- end
62
-
63
- def operation_count(type = nil)
64
- if type
65
- @operations.count { |op| op[:type] == type }
66
- else
67
- @operations.size
68
- end
69
- end
70
-
71
- def last_saved_data(session_id)
72
- @operations.reverse.find { |op| op[:type] == :save && op[:session_id] == session_id }&.dig(:data)
73
- end
74
-
75
- def reset_tracking!
76
- @operations.clear
77
- @saved_sessions.clear
78
- @loaded_sessions.clear
79
- @deleted_sessions.clear
80
- @updated_sessions.clear
81
- end
82
- end
83
- end
84
- end