actionmcp 0.50.13 → 0.51.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8cbc049b43ac92baf337a6700c1a31f3dc7a9a5bc6c63a245d845b26f40a6a4
4
- data.tar.gz: e116ae134611daf77d0db8955325c4f3318ab27b7bec45ce77b1f4bdebdbc413
3
+ metadata.gz: 88b1f1dba04f05f96a9cf1c4ab928412400cb0a796d6635277924e57aa91a3c6
4
+ data.tar.gz: 163a43c7d4e3e247ba6f1084250e9452e9f36ee0e2ba2ee4a1d6fe67ca95f147
5
5
  SHA512:
6
- metadata.gz: 0ca8d760b8758d6325da76b10ef9b94e76312538c1a0d4765a793944746f3f4b83bed4a843b9e0de6a5c10a4878e2cece3157386a056750fc9fe6d57543054b5
7
- data.tar.gz: '0692acfc6b64ee7c3bb7047cc863dd2976e7aca7bb6c19e39a146ced044e1136c71364598f02b5cff9a3af803114a647692005150d758957c0db427366bed185'
6
+ metadata.gz: fae52dbb988f73cdcc4fa4678b12d352cdb151f1111c753a1210c37169b0b2d04dc53dbeafc092cd9dea6e6e5e2397672a7a1252982425f726f546427c52c327
7
+ data.tar.gz: 613a305bd349a425d6f7169566b160eeda391870a20b8f997d01b587e558ef50ca005947a5482d24db5c4846a53e136f00301ad9f648f0b0c5d92c7a58b998f6
@@ -171,7 +171,11 @@ module ActionMCP
171
171
  )
172
172
 
173
173
  # Maintain cache limit by removing oldest events if needed
174
- sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all if sse_events.count > max_events
174
+ count = sse_events.count
175
+ excess = count - max_events
176
+ if excess.positive?
177
+ sse_events.order(event_id: :asc).limit(excess).delete_all
178
+ end
175
179
 
176
180
  event
177
181
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "transport"
4
+
3
5
  module ActionMCP
4
6
  module Client
5
7
  # Base client class containing common MCP functionality
@@ -12,25 +14,20 @@ module ActionMCP
12
14
  include Roots
13
15
  include Logging
14
16
 
15
- attr_reader :logger, :type,
17
+ attr_reader :logger, :transport,
16
18
  :connection_error, :server,
17
19
  :server_capabilities, :session,
18
20
  :catalog, :blueprint,
19
21
  :prompt_book, :toolbox
20
22
 
21
- delegate :initialized?, to: :session
23
+ delegate :connected?, :ready?, to: :transport
22
24
 
23
- def initialize(logger: ActionMCP.logger)
25
+ def initialize(transport:, logger: ActionMCP.logger, **options)
24
26
  @logger = logger
25
- @connected = false
26
- @session = Session.from_client.new(
27
- protocol_version: PROTOCOL_VERSION,
28
- client_info: client_info,
29
- client_capabilities: client_capabilities
30
- )
27
+ @transport = transport
28
+ @session = nil # Session will be created/loaded based on server response
29
+ @session_id = options[:session_id] # Optional session ID for resumption
31
30
  @server_capabilities = nil
32
- @message_callback = nil
33
- @error_callback = nil
34
31
  @connection_error = nil
35
32
  @initialized = false
36
33
 
@@ -42,148 +39,157 @@ module ActionMCP
42
39
  @prompt_book = PromptBook.new([], self)
43
40
  # Tool objects
44
41
  @toolbox = Toolbox.new([], self)
45
- end
46
42
 
47
- def connected?
48
- @connected
43
+ setup_transport_callbacks
49
44
  end
50
45
 
51
- # Connect to the MCP server, if something went wrong at initialization
46
+ # Connect to the MCP server
52
47
  def connect
53
- return true if @connected
48
+ return true if connected?
54
49
 
55
50
  begin
56
- log_debug("Connecting to MCP server...")
51
+ log_debug("Connecting to MCP server via #{transport.class.name}...")
57
52
  @connection_error = nil
58
53
 
59
- # Start transport with proper error handling
60
- success = start_transport
61
-
54
+ success = @transport.connect
62
55
  unless success
63
- log_error("Failed to establish connection to MCP server")
56
+ log_error("Failed to establish transport connection")
64
57
  return false
65
58
  end
66
59
 
67
- @connected = true
68
60
  log_debug("Connected to MCP server")
69
-
70
- # Create handler only if it doesn't exist yet
71
- @json_rpc_handler ||= JsonRpcHandler.new(session, self)
72
-
73
- # Clear any existing message callback and set a new one
74
- @message_callback = lambda do |response|
75
- @json_rpc_handler.call(response)
76
- end
77
-
78
61
  true
79
62
  rescue StandardError => e
80
63
  @connection_error = e.message
81
64
  log_error("Failed to connect to MCP server: #{e.message}")
82
- @error_callback&.call(e)
83
65
  false
84
66
  end
85
67
  end
86
68
 
87
69
  # Disconnect from the MCP server
88
70
  def disconnect
89
- return true unless @connected
71
+ return true unless connected?
90
72
 
91
73
  begin
92
- stop_transport
93
- @connected = false
74
+ @transport.disconnect
94
75
  log_debug("Disconnected from MCP server")
95
76
  true
96
77
  rescue StandardError => e
97
78
  log_error("Error disconnecting from MCP server: #{e.message}")
98
- @error_callback&.call(e)
99
79
  false
100
80
  end
101
81
  end
102
82
 
103
- # Set a callback for incoming messages
104
- def on_message(&block)
105
- @message_callback = block
106
- end
107
-
108
- # Set a callback for errors
109
- def on_error(&block)
110
- @error_callback = block
111
- end
112
-
113
83
  # Send a request to the MCP server
114
84
  def write_message(payload)
115
- unless @connected
116
- log_error("Cannot send request - not connected")
85
+ unless ready?
86
+ log_error("Cannot send request - transport not ready")
117
87
  return false
118
88
  end
119
89
 
120
90
  begin
121
- session.write(payload)
91
+ # Only write to session if it exists (after initialization)
92
+ session.write(payload) if session
122
93
  data = payload.to_json unless payload.is_a?(String)
123
- send_message(data)
94
+ @transport.send_message(data)
124
95
  true
125
96
  rescue StandardError => e
126
97
  log_error("Failed to send request: #{e.message}")
127
- @error_callback&.call(e)
128
98
  false
129
99
  end
130
100
  end
131
101
 
132
- # Methods to be implemented by subclasses
133
- def start_transport
134
- raise NotImplementedError, "#{self.class} must implement #start_transport"
135
- end
136
-
137
- def stop_transport
138
- raise NotImplementedError, "#{self.class} must implement #stop_transport"
139
- end
140
-
141
- def send_message(json)
142
- raise NotImplementedError, "#{self.class} must implement #send_message"
143
- end
144
-
145
- def ready?
146
- raise NotImplementedError, "#{self.class} must implement #ready?"
147
- end
148
-
149
102
  def server=(server)
150
103
  @server = if server.is_a?(Client::Server)
151
104
  server
152
105
  else
153
106
  Client::Server.new(server)
154
107
  end
155
- session.server_capabilities = server.capabilities
156
- session.server_info = server.server_info
157
- session.save
108
+
109
+ # Only update session if it exists
110
+ if @session
111
+ @session.server_capabilities = server.capabilities
112
+ @session.server_info = server.server_info
113
+ @session.save
114
+ end
115
+ end
116
+
117
+ def initialized?
118
+ @initialized && @session&.initialized?
158
119
  end
159
120
 
160
121
  def inspect
161
- "#<#{self.class.name} server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities} , connected: #{connected?}, initialized: #{initialized?}, session: #{session.id}>"
122
+ session_info = @session ? "session: #{@session.id}" : "session: none"
123
+ "#<#{self.class.name} transport: #{transport.class.name}, server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities}, connected: #{connected?}, initialized: #{initialized?}, #{session_info}>"
162
124
  end
163
125
 
164
126
  protected
165
127
 
128
+ def setup_transport_callbacks
129
+ # Create JSON-RPC handler
130
+ @json_rpc_handler = JsonRpcHandler.new(session, self)
131
+
132
+ # Set up transport callbacks
133
+ @transport.on_message do |message|
134
+ handle_raw_message(message)
135
+ end
136
+
137
+ @transport.on_error do |error|
138
+ handle_transport_error(error)
139
+ end
140
+
141
+ @transport.on_connect do
142
+ handle_transport_connect
143
+ end
144
+
145
+ @transport.on_disconnect do
146
+ handle_transport_disconnect
147
+ end
148
+ end
149
+
166
150
  def handle_raw_message(raw)
167
- @message_callback&.call(raw)
151
+ @json_rpc_handler.call(raw)
168
152
  rescue MultiJson::ParseError => e
169
153
  log_error("JSON parse error: #{e} (raw: #{raw})")
170
- @error_callback&.call(e)
171
154
  rescue StandardError => e
172
155
  log_error("Error handling message: #{e} (raw: #{raw})")
173
- @error_callback&.call(e)
156
+ end
157
+
158
+ def handle_transport_error(error)
159
+ @connection_error = error.message
160
+ log_error("Transport error: #{error.message}")
161
+ end
162
+
163
+ def handle_transport_connect
164
+ log_debug("Transport connected")
165
+ # Send initial capabilities after connection
166
+ send_initial_capabilities
167
+ end
168
+
169
+ def handle_transport_disconnect
170
+ log_debug("Transport disconnected")
174
171
  end
175
172
 
176
173
  def send_initial_capabilities
177
174
  log_debug("Sending client capabilities")
178
- # We have contact! Let's send our CV to the recruiter.
179
- # We persist the session object to the database
180
- session.save
175
+
176
+ # If we have a session_id, we're trying to resume
177
+ if @session_id
178
+ log_debug("Attempting to resume session: #{@session_id}")
179
+ end
180
+
181
181
  params = {
182
- protocolVersion: session.protocol_version,
183
- capabilities: session.client_capabilities,
184
- clientInfo: session.client_info
182
+ protocolVersion: PROTOCOL_VERSION,
183
+ capabilities: client_capabilities,
184
+ clientInfo: client_info
185
185
  }
186
- send_jsonrpc_request("initialize", params: params, id: session.id)
186
+
187
+ # Include session_id if we're trying to resume
188
+ params[:sessionId] = @session_id if @session_id
189
+
190
+ # Use a unique request ID (not session ID since we don't have one yet)
191
+ request_id = SecureRandom.uuid
192
+ send_jsonrpc_request("initialize", params: params, id: request_id)
187
193
  end
188
194
 
189
195
  def client_capabilities
@@ -70,13 +70,16 @@ module ActionMCP
70
70
  end
71
71
 
72
72
  def process_response(id, result)
73
- if transport.id == id
74
- ## This initializes the transport
75
- client.server = Client::Server.new(result)
73
+ # Check if this is a response to an initialize request
74
+ # We need to check the actual request method, not just compare IDs
75
+ request = client.session ? transport.messages.requests.find_by(jsonrpc_id: id) : nil
76
+
77
+ # If no session yet, this might be the initialize response
78
+ if !client.session && result["serverInfo"]
79
+ handle_initialize_response(id, result)
76
80
  return send_initialized_notification
77
81
  end
78
82
 
79
- request = transport.messages.requests.find_by(jsonrpc_id: id)
80
83
  return unless request
81
84
 
82
85
  # Mark the request as acknowledged
@@ -113,8 +116,42 @@ module ActionMCP
113
116
  puts "\e[31mUnknown error: #{id} #{error}\e[0m"
114
117
  end
115
118
 
119
+ def handle_initialize_response(request_id, result)
120
+ # Session ID comes from HTTP headers, not the response body
121
+ # The transport should have already extracted it
122
+ session_id = transport.instance_variable_get(:@session_id)
123
+
124
+ if session_id.nil?
125
+ client.log_error("No session ID received from server")
126
+ return
127
+ end
128
+
129
+ # Check if we're resuming an existing session
130
+ if client.instance_variable_get(:@session_id) && session_id == client.instance_variable_get(:@session_id)
131
+ # We're resuming an existing session
132
+ client.instance_variable_set(:@session, ActionMCP::Session.find(session_id))
133
+ client.log_info("Resumed existing session: #{session_id}")
134
+ else
135
+ # Create a new session with the server-provided ID
136
+ client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
137
+ id: session_id,
138
+ protocol_version: result["protocolVersion"] || PROTOCOL_VERSION,
139
+ client_info: client.client_info,
140
+ client_capabilities: client.client_capabilities,
141
+ server_info: result["serverInfo"],
142
+ server_capabilities: result["capabilities"]
143
+ ))
144
+ client.session.save
145
+ client.log_info("Created new session: #{session_id}")
146
+ end
147
+
148
+ # Set the server info
149
+ client.server = Client::Server.new(result)
150
+ client.instance_variable_set(:@initialized, true)
151
+ end
152
+
116
153
  def send_initialized_notification
117
- transport.initialize!
154
+ transport.initialize! if transport.respond_to?(:initialize!)
118
155
  client.send_jsonrpc_notification("notifications/initialized")
119
156
  end
120
157
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Abstract interface for session storage
6
+ module SessionStore
7
+ # Load session data by ID
8
+ def load_session(session_id)
9
+ raise NotImplementedError, "#{self.class} must implement #load_session"
10
+ end
11
+
12
+ # Save session data
13
+ def save_session(session_id, session_data)
14
+ raise NotImplementedError, "#{self.class} must implement #save_session"
15
+ end
16
+
17
+ # Delete session
18
+ def delete_session(session_id)
19
+ raise NotImplementedError, "#{self.class} must implement #delete_session"
20
+ end
21
+
22
+ # Check if session exists
23
+ def session_exists?(session_id)
24
+ raise NotImplementedError, "#{self.class} must implement #session_exists?"
25
+ end
26
+
27
+ # Update specific session attributes
28
+ def update_session(session_id, attributes)
29
+ session_data = load_session(session_id)
30
+ return nil unless session_data
31
+
32
+ session_data.merge!(attributes)
33
+ save_session(session_id, session_data)
34
+ session_data
35
+ end
36
+ end
37
+
38
+ # In-memory session store for development/testing
39
+ class MemorySessionStore
40
+ include SessionStore
41
+
42
+ def initialize
43
+ @sessions = {}
44
+ @mutex = Mutex.new
45
+ end
46
+
47
+ def load_session(session_id)
48
+ @mutex.synchronize { @sessions[session_id] }
49
+ end
50
+
51
+ def save_session(session_id, session_data)
52
+ @mutex.synchronize { @sessions[session_id] = session_data.dup }
53
+ end
54
+
55
+ def delete_session(session_id)
56
+ @mutex.synchronize { @sessions.delete(session_id) }
57
+ end
58
+
59
+ def session_exists?(session_id)
60
+ @mutex.synchronize { @sessions.key?(session_id) }
61
+ end
62
+
63
+ def clear_all
64
+ @mutex.synchronize { @sessions.clear }
65
+ end
66
+
67
+ def session_count
68
+ @mutex.synchronize { @sessions.size }
69
+ end
70
+ end
71
+
72
+ # ActiveRecord-backed session store for production
73
+ class ActiveRecordSessionStore
74
+ include SessionStore
75
+
76
+ def load_session(session_id)
77
+ session = ActionMCP::Session.find_by(id: session_id)
78
+ return nil unless session
79
+
80
+ {
81
+ id: session.id,
82
+ protocol_version: session.protocol_version,
83
+ client_info: session.client_info,
84
+ client_capabilities: session.client_capabilities,
85
+ server_info: session.server_info,
86
+ server_capabilities: session.server_capabilities,
87
+ last_event_id: session.last_event_id,
88
+ session_data: session.session_data || {},
89
+ created_at: session.created_at,
90
+ updated_at: session.updated_at
91
+ }
92
+ end
93
+
94
+ def save_session(session_id, session_data)
95
+ session = ActionMCP::Session.find_or_initialize_by(id: session_id)
96
+
97
+ session.assign_attributes(
98
+ protocol_version: session_data[:protocol_version],
99
+ client_info: session_data[:client_info],
100
+ client_capabilities: session_data[:client_capabilities],
101
+ server_info: session_data[:server_info],
102
+ server_capabilities: session_data[:server_capabilities],
103
+ last_event_id: session_data[:last_event_id],
104
+ session_data: session_data[:session_data] || {}
105
+ )
106
+
107
+ session.save!
108
+ session_data
109
+ end
110
+
111
+ def delete_session(session_id)
112
+ ActionMCP::Session.find_by(id: session_id)&.destroy
113
+ end
114
+
115
+ def session_exists?(session_id)
116
+ ActionMCP::Session.exists?(id: session_id)
117
+ end
118
+
119
+ def cleanup_expired_sessions(older_than: 24.hours.ago)
120
+ ActionMCP::Session.where("updated_at < ?", older_than).delete_all
121
+ end
122
+ end
123
+
124
+ # Factory for creating session stores
125
+ class SessionStoreFactory
126
+ def self.create(type = nil, **options)
127
+ type ||= Rails.env.production? ? :active_record : :memory
128
+
129
+ case type.to_sym
130
+ when :memory
131
+ MemorySessionStore.new
132
+ when :active_record
133
+ ActiveRecordSessionStore.new
134
+ else
135
+ raise ArgumentError, "Unknown session store type: #{type}"
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,291 @@
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
17
+
18
+ def initialize(url, session_store:, session_id: nil, **options)
19
+ super(url, session_store: session_store, **options)
20
+ @session_id = session_id
21
+ @last_event_id = nil
22
+ @buffer = +""
23
+ @current_event = nil
24
+ @reconnect_attempts = 0
25
+ @max_reconnect_attempts = options[:max_reconnect_attempts] || 3
26
+ @reconnect_delay = options[:reconnect_delay] || 1.0
27
+
28
+ setup_http_client
29
+ end
30
+
31
+ def connect
32
+ log_debug("Connecting via StreamableHTTP to #{@url}")
33
+
34
+ # Load session if session_id provided
35
+ load_session_state if @session_id
36
+
37
+ # Start SSE stream if server supports it
38
+ start_sse_stream
39
+
40
+ set_connected(true)
41
+ set_ready(true)
42
+ log_debug("StreamableHTTP connection established")
43
+ true
44
+ rescue StandardError => e
45
+ handle_error(e)
46
+ false
47
+ end
48
+
49
+ def disconnect
50
+ return true unless connected?
51
+
52
+ log_debug("Disconnecting StreamableHTTP")
53
+ stop_sse_stream
54
+ save_session_state if @session_id
55
+ set_connected(false)
56
+ set_ready(false)
57
+ true
58
+ rescue StandardError => e
59
+ handle_error(e)
60
+ false
61
+ end
62
+
63
+ def send_message(message)
64
+ unless ready?
65
+ raise ConnectionError, "Transport not ready"
66
+ end
67
+
68
+ headers = build_post_headers
69
+ json_data = message.is_a?(String) ? message : message.to_json
70
+
71
+ log_debug("Sending message via POST")
72
+ response = @http_client.post(@url, json_data, headers)
73
+
74
+ handle_post_response(response, message)
75
+ true
76
+ rescue StandardError => e
77
+ handle_error(e)
78
+ false
79
+ end
80
+
81
+ private
82
+
83
+ def setup_http_client
84
+ require "faraday"
85
+ @http_client = Faraday.new do |f|
86
+ f.headers["User-Agent"] = user_agent
87
+ f.options.timeout = nil # No read timeout for SSE
88
+ f.options.open_timeout = SSE_TIMEOUT
89
+ f.adapter :net_http
90
+ end
91
+ end
92
+
93
+ def build_get_headers
94
+ headers = {
95
+ "Accept" => "text/event-stream",
96
+ "Cache-Control" => "no-cache"
97
+ }
98
+ headers["mcp-session-id"] = @session_id if @session_id
99
+ headers["Last-Event-ID"] = @last_event_id if @last_event_id
100
+ headers
101
+ end
102
+
103
+ def build_post_headers
104
+ headers = {
105
+ "Content-Type" => "application/json",
106
+ "Accept" => "application/json, text/event-stream"
107
+ }
108
+ headers["mcp-session-id"] = @session_id if @session_id
109
+ headers
110
+ end
111
+
112
+ def start_sse_stream
113
+ log_debug("Starting SSE stream")
114
+ @sse_thread = Thread.new { run_sse_stream }
115
+ end
116
+
117
+ def stop_sse_stream
118
+ return unless @sse_thread
119
+
120
+ log_debug("Stopping SSE stream")
121
+ @stop_requested = true
122
+ @sse_thread.kill if @sse_thread.alive?
123
+ @sse_thread = nil
124
+ @stop_requested = false
125
+ end
126
+
127
+ def run_sse_stream
128
+ headers = build_get_headers
129
+
130
+ @http_client.get(@url, nil, headers) do |req|
131
+ req.options.on_data = proc do |chunk, _bytes|
132
+ break if @stop_requested
133
+ process_sse_chunk(chunk)
134
+ end
135
+ end
136
+ rescue StandardError => e
137
+ handle_sse_error(e)
138
+ end
139
+
140
+ def process_sse_chunk(chunk)
141
+ @buffer << chunk
142
+ process_complete_events while @buffer.include?("\n\n")
143
+ end
144
+
145
+ def process_complete_events
146
+ event_data, _separator, rest = @buffer.partition("\n\n")
147
+ @buffer = rest
148
+
149
+ return if event_data.strip.empty?
150
+
151
+ parse_sse_event(event_data)
152
+ end
153
+
154
+ def parse_sse_event(event_data)
155
+ lines = event_data.split("\n")
156
+ event_id = nil
157
+ data_lines = []
158
+
159
+ lines.each do |line|
160
+ if line.start_with?("id:")
161
+ event_id = line[3..-1].strip
162
+ elsif line.start_with?("data:")
163
+ data_lines << line[5..-1].strip
164
+ end
165
+ end
166
+
167
+ return if data_lines.empty?
168
+
169
+ @last_event_id = event_id if event_id
170
+
171
+ begin
172
+ message_data = data_lines.join("\n")
173
+ message = MultiJson.load(message_data)
174
+ handle_message(message)
175
+ rescue MultiJson::ParseError => e
176
+ log_error("Failed to parse SSE message: #{e}")
177
+ end
178
+ end
179
+
180
+ def handle_post_response(response, original_message)
181
+ # Extract session ID from response headers
182
+ if response.headers["mcp-session-id"]
183
+ @session_id = response.headers["mcp-session-id"]
184
+ end
185
+
186
+ case response.status
187
+ when 200
188
+ handle_success_response(response)
189
+ when 202
190
+ # Accepted - message received, no immediate response
191
+ log_debug("Message accepted (202)")
192
+ when 401
193
+ raise AuthenticationError, "Authentication required"
194
+ when 405
195
+ # Method not allowed - server doesn't support this operation
196
+ log_debug("Server returned 405 - operation not supported")
197
+ else
198
+ handle_error_response(response)
199
+ end
200
+ end
201
+
202
+ def handle_success_response(response)
203
+ content_type = response.headers["content-type"]
204
+
205
+ if content_type&.include?("application/json")
206
+ # Direct JSON response
207
+ handle_json_response(response)
208
+ elsif content_type&.include?("text/event-stream")
209
+ # SSE response stream
210
+ handle_sse_response_stream(response)
211
+ end
212
+ end
213
+
214
+ def handle_json_response(response)
215
+ begin
216
+ message = MultiJson.load(response.body)
217
+ handle_message(message)
218
+ rescue MultiJson::ParseError => e
219
+ log_error("Failed to parse JSON response: #{e}")
220
+ end
221
+ end
222
+
223
+ def handle_sse_response_stream(response)
224
+ # Handle SSE stream from POST response
225
+ response.body.each_line do |line|
226
+ process_sse_chunk(line)
227
+ end
228
+ end
229
+
230
+ def handle_error_response(response)
231
+ error_msg = "HTTP #{response.status}: #{response.reason_phrase}"
232
+ if response.body && !response.body.empty?
233
+ error_msg << " - #{response.body}"
234
+ end
235
+ raise ConnectionError, error_msg
236
+ end
237
+
238
+ def handle_sse_error(error)
239
+ log_error("SSE stream error: #{error.message}")
240
+
241
+ if should_reconnect?
242
+ schedule_reconnect
243
+ else
244
+ handle_error(error)
245
+ end
246
+ end
247
+
248
+ def should_reconnect?
249
+ connected? && @reconnect_attempts < @max_reconnect_attempts
250
+ end
251
+
252
+ def schedule_reconnect
253
+ @reconnect_attempts += 1
254
+ delay = @reconnect_delay * @reconnect_attempts
255
+
256
+ log_debug("Scheduling SSE reconnect in #{delay}s (attempt #{@reconnect_attempts})")
257
+
258
+ Thread.new do
259
+ sleep(delay)
260
+ start_sse_stream unless @stop_requested
261
+ end
262
+ end
263
+
264
+ def load_session_state
265
+ session_data = @session_store.load_session(@session_id)
266
+ return unless session_data
267
+
268
+ @last_event_id = session_data[:last_event_id]
269
+ log_debug("Loaded session state: last_event_id=#{@last_event_id}")
270
+ end
271
+
272
+ def save_session_state
273
+ return unless @session_id
274
+
275
+ session_data = {
276
+ id: @session_id,
277
+ last_event_id: @last_event_id,
278
+ session_data: {},
279
+ protocol_version: PROTOCOL_VERSION
280
+ }
281
+
282
+ @session_store.save_session(@session_id, session_data)
283
+ log_debug("Saved session state")
284
+ end
285
+
286
+ def user_agent
287
+ "ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Base transport interface for MCP client connections
6
+ module Transport
7
+ # Called when transport should establish connection
8
+ def connect
9
+ raise NotImplementedError, "#{self.class} must implement #connect"
10
+ end
11
+
12
+ # Called when transport should close connection
13
+ def disconnect
14
+ raise NotImplementedError, "#{self.class} must implement #disconnect"
15
+ end
16
+
17
+ # Send a message through the transport
18
+ def send_message(message)
19
+ raise NotImplementedError, "#{self.class} must implement #send_message"
20
+ end
21
+
22
+ # Check if transport is ready to send/receive
23
+ def ready?
24
+ raise NotImplementedError, "#{self.class} must implement #ready?"
25
+ end
26
+
27
+ # Check if transport is connected
28
+ def connected?
29
+ raise NotImplementedError, "#{self.class} must implement #connected?"
30
+ end
31
+
32
+ # Set callback for received messages
33
+ def on_message(&block)
34
+ @message_callback = block
35
+ end
36
+
37
+ # Set callback for errors
38
+ def on_error(&block)
39
+ @error_callback = block
40
+ end
41
+
42
+ # Set callback for connection events
43
+ def on_connect(&block)
44
+ @connect_callback = block
45
+ end
46
+
47
+ # Set callback for disconnection events
48
+ def on_disconnect(&block)
49
+ @disconnect_callback = block
50
+ end
51
+
52
+ protected
53
+
54
+ def handle_message(message)
55
+ @message_callback&.call(message)
56
+ end
57
+
58
+ def handle_error(error)
59
+ @error_callback&.call(error)
60
+ end
61
+
62
+ def handle_connect
63
+ @connect_callback&.call
64
+ end
65
+
66
+ def handle_disconnect
67
+ @disconnect_callback&.call
68
+ end
69
+ end
70
+
71
+ # Base class for transport implementations
72
+ class TransportBase
73
+ include Transport
74
+ include Logging
75
+
76
+ attr_reader :url, :options, :session_store
77
+
78
+ def initialize(url, session_store:, logger: ActionMCP.logger, **options)
79
+ @url = url
80
+ @session_store = session_store
81
+ @logger = logger
82
+ @options = options
83
+ @connected = false
84
+ @ready = false
85
+ end
86
+
87
+ def connected?
88
+ @connected
89
+ end
90
+
91
+ def ready?
92
+ @ready
93
+ end
94
+
95
+ protected
96
+
97
+ def set_connected(state)
98
+ @connected = state
99
+ state ? handle_connect : handle_disconnect
100
+ end
101
+
102
+ def set_ready(state)
103
+ @ready = state
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,24 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client/transport"
4
+ require_relative "client/session_store"
5
+ require_relative "client/streamable_http_transport"
6
+
3
7
  module ActionMCP
4
8
  # Creates a client appropriate for the given endpoint.
5
9
  #
6
10
  # @param endpoint [String] The endpoint to connect to (URL).
11
+ # @param transport [Symbol] The transport type to use (:streamable_http, :sse for legacy)
12
+ # @param session_store [Symbol] The session store type (:memory, :active_record)
13
+ # @param session_id [String] Optional session ID for resuming connections
7
14
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
8
15
  # @param options [Hash] Additional options to pass to the client constructor.
9
16
  #
10
- # @return [Client::SSEClient] An instance of SSEClient for HTTP(S) endpoints.
17
+ # @return [Client::Base] An instance of the appropriate client.
11
18
  #
12
- # @example
19
+ # @example Basic usage
13
20
  # client = ActionMCP.create_client("http://127.0.0.1:3001/action_mcp")
14
21
  # client.connect
15
- def self.create_client(endpoint, logger: Logger.new($stdout), **options)
22
+ #
23
+ # @example With specific transport and session store
24
+ # client = ActionMCP.create_client(
25
+ # "http://127.0.0.1:3001/action_mcp",
26
+ # transport: :streamable_http,
27
+ # session_store: :active_record,
28
+ # session_id: "existing-session-123"
29
+ # )
30
+ #
31
+ # @example Memory-based for development
32
+ # client = ActionMCP.create_client(
33
+ # "http://127.0.0.1:3001/action_mcp",
34
+ # session_store: :memory
35
+ # )
36
+ def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, logger: Logger.new($stdout), **options)
16
37
  unless endpoint =~ %r{\Ahttps?://}
17
38
  raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
18
39
  end
19
40
 
20
- logger.info("Creating SSE client for endpoint: #{endpoint}")
21
- Client::SSEClient.new(endpoint, logger: logger, **options)
41
+ # Create session store
42
+ store = Client::SessionStoreFactory.create(session_store, **options)
43
+
44
+ # Create transport
45
+ transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, logger: logger, **options)
46
+
47
+ logger.info("Creating #{transport} client for endpoint: #{endpoint}")
48
+ # Pass session_id to the client
49
+ Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id, **options)
50
+ end
51
+
52
+ private_class_method def self.create_transport(type, endpoint, **options)
53
+ case type.to_sym
54
+ when :streamable_http
55
+ Client::StreamableHttpTransport.new(endpoint, **options)
56
+ when :sse
57
+ # Legacy SSE transport (wrapped for compatibility)
58
+ Client::LegacySSETransport.new(endpoint, **options)
59
+ else
60
+ raise ArgumentError, "Unknown transport type: #{type}"
61
+ end
22
62
  end
23
63
 
24
64
  module Client
@@ -13,6 +13,7 @@ module ActionMCP
13
13
  client_protocol_version = params["protocolVersion"]
14
14
  client_info = params["clientInfo"]
15
15
  client_capabilities = params["capabilities"]
16
+ session_id = params["sessionId"]
16
17
 
17
18
  unless client_protocol_version.is_a?(String) && client_protocol_version.present?
18
19
  return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
@@ -32,6 +33,28 @@ module ActionMCP
32
33
  return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
33
34
  end
34
35
 
36
+ # Handle session resumption if sessionId provided
37
+ if session_id
38
+ existing_session = ActionMCP::Session.find_by(id: session_id)
39
+ if existing_session && existing_session.initialized?
40
+ # Resume existing session - update transport reference
41
+ transport.instance_variable_set(:@session, existing_session)
42
+ Rails.logger.info("Resuming existing session: #{session_id}")
43
+
44
+ # Return existing session info
45
+ capabilities_payload = existing_session.server_capabilities_payload
46
+ capabilities_payload[:protocolVersion] = if ActionMCP.configuration.vibed_ignore_version
47
+ PROTOCOL_VERSION
48
+ else
49
+ client_protocol_version
50
+ end
51
+ return send_jsonrpc_response(request_id, result: capabilities_payload)
52
+ else
53
+ Rails.logger.warn("Session #{session_id} not found or not initialized, creating new session")
54
+ end
55
+ end
56
+
57
+ # Create new session if not resuming
35
58
  session.store_client_info(client_info)
36
59
  session.store_client_capabilities(client_capabilities)
37
60
  session.set_protocol_version(client_protocol_version)
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.50.13"
5
+ VERSION = "0.51.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.50.13
4
+ version: 0.51.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-05-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -137,9 +136,12 @@ files:
137
136
  - lib/action_mcp/client/resources.rb
138
137
  - lib/action_mcp/client/roots.rb
139
138
  - lib/action_mcp/client/server.rb
139
+ - lib/action_mcp/client/session_store.rb
140
140
  - lib/action_mcp/client/sse_client.rb
141
+ - lib/action_mcp/client/streamable_http_transport.rb
141
142
  - lib/action_mcp/client/toolbox.rb
142
143
  - lib/action_mcp/client/tools.rb
144
+ - lib/action_mcp/client/transport.rb
143
145
  - lib/action_mcp/configuration.rb
144
146
  - lib/action_mcp/console_detector.rb
145
147
  - lib/action_mcp/content.rb
@@ -220,7 +222,6 @@ metadata:
220
222
  source_code_uri: https://github.com/seuros/action_mcp
221
223
  changelog_uri: https://github.com/seuros/action_mcp/blob/master/CHANGELOG.md
222
224
  rubygems_mfa_required: 'true'
223
- post_install_message:
224
225
  rdoc_options: []
225
226
  require_paths:
226
227
  - lib
@@ -235,8 +236,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
235
236
  - !ruby/object:Gem::Version
236
237
  version: '0'
237
238
  requirements: []
238
- rubygems_version: 3.5.22
239
- signing_key:
239
+ rubygems_version: 3.6.9
240
240
  specification_version: 4
241
241
  summary: Provides essential tooling for building Model Context Protocol (MCP) capable
242
242
  servers