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.
- checksums.yaml +4 -4
- data/README.md +11 -3
- data/lib/action_mcp/test_helper/session_store_assertions.rb +0 -70
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- metadata +2 -27
- data/lib/action_mcp/client/active_record_session_store.rb +0 -57
- data/lib/action_mcp/client/base.rb +0 -225
- data/lib/action_mcp/client/blueprint.rb +0 -163
- data/lib/action_mcp/client/catalog.rb +0 -164
- data/lib/action_mcp/client/collection.rb +0 -168
- data/lib/action_mcp/client/elicitation.rb +0 -34
- data/lib/action_mcp/client/json_rpc_handler.rb +0 -202
- data/lib/action_mcp/client/logging.rb +0 -19
- data/lib/action_mcp/client/messaging.rb +0 -28
- data/lib/action_mcp/client/prompt_book.rb +0 -117
- data/lib/action_mcp/client/prompts.rb +0 -47
- data/lib/action_mcp/client/request_timeouts.rb +0 -74
- data/lib/action_mcp/client/resources.rb +0 -100
- data/lib/action_mcp/client/roots.rb +0 -13
- data/lib/action_mcp/client/server.rb +0 -60
- data/lib/action_mcp/client/session_store.rb +0 -39
- data/lib/action_mcp/client/session_store_factory.rb +0 -27
- data/lib/action_mcp/client/streamable_client.rb +0 -264
- data/lib/action_mcp/client/streamable_http_transport.rb +0 -306
- data/lib/action_mcp/client/test_session_store.rb +0 -84
- data/lib/action_mcp/client/toolbox.rb +0 -199
- data/lib/action_mcp/client/tools.rb +0 -47
- data/lib/action_mcp/client/transport.rb +0 -137
- data/lib/action_mcp/client/volatile_session_store.rb +0 -38
- data/lib/action_mcp/client.rb +0 -71
|
@@ -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
|