actionmcp 0.50.13 → 0.52.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -0
  3. data/app/controllers/action_mcp/application_controller.rb +12 -6
  4. data/app/models/action_mcp/session.rb +5 -1
  5. data/lib/action_mcp/client/base.rb +88 -82
  6. data/lib/action_mcp/client/json_rpc_handler.rb +42 -5
  7. data/lib/action_mcp/client/session_store.rb +231 -0
  8. data/lib/action_mcp/client/streamable_http_transport.rb +291 -0
  9. data/lib/action_mcp/client/transport.rb +107 -0
  10. data/lib/action_mcp/client.rb +45 -5
  11. data/lib/action_mcp/configuration.rb +16 -1
  12. data/lib/action_mcp/current.rb +19 -0
  13. data/lib/action_mcp/current_helpers.rb +19 -0
  14. data/lib/action_mcp/gateway.rb +85 -0
  15. data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
  16. data/lib/action_mcp/jwt_decoder.rb +26 -0
  17. data/lib/action_mcp/prompt.rb +1 -0
  18. data/lib/action_mcp/resource_template.rb +1 -0
  19. data/lib/action_mcp/server/base_messaging.rb +14 -0
  20. data/lib/action_mcp/server/capabilities.rb +23 -0
  21. data/lib/action_mcp/server/error_aware.rb +8 -1
  22. data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
  23. data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
  24. data/lib/action_mcp/server/messaging.rb +12 -1
  25. data/lib/action_mcp/server/registry_management.rb +0 -1
  26. data/lib/action_mcp/server/response_collector.rb +40 -0
  27. data/lib/action_mcp/server/session_store.rb +762 -0
  28. data/lib/action_mcp/server/tools.rb +14 -3
  29. data/lib/action_mcp/server/transport_handler.rb +9 -5
  30. data/lib/action_mcp/server.rb +7 -0
  31. data/lib/action_mcp/tagged_stream_logging.rb +0 -4
  32. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
  33. data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
  34. data/lib/action_mcp/test_helper.rb +4 -0
  35. data/lib/action_mcp/tool.rb +1 -0
  36. data/lib/action_mcp/version.rb +1 -1
  37. data/lib/action_mcp.rb +0 -1
  38. data/lib/generators/action_mcp/install/install_generator.rb +4 -0
  39. data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
  40. metadata +30 -6
@@ -0,0 +1,231 @@
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
+ # Return the reloaded session to get the actual saved values
35
+ load_session(session_id)
36
+ end
37
+ end
38
+
39
+ # Volatile session store for development (data lost on restart)
40
+ class VolatileSessionStore
41
+ include SessionStore
42
+
43
+ def initialize
44
+ @sessions = Concurrent::Hash.new
45
+ end
46
+
47
+ def load_session(session_id)
48
+ @sessions[session_id]
49
+ end
50
+
51
+ def save_session(session_id, session_data)
52
+ @sessions[session_id] = session_data.dup
53
+ end
54
+
55
+ def delete_session(session_id)
56
+ @sessions.delete(session_id)
57
+ end
58
+
59
+ def session_exists?(session_id)
60
+ @sessions.key?(session_id)
61
+ end
62
+
63
+ def clear_all
64
+ @sessions.clear
65
+ end
66
+
67
+ def session_count
68
+ @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
+ created_at: session.created_at,
88
+ updated_at: session.updated_at
89
+ }
90
+ end
91
+
92
+ def save_session(session_id, session_data)
93
+ session = ActionMCP::Session.find_or_initialize_by(id: session_id)
94
+
95
+ # Only assign attributes that exist in the database
96
+ attributes = {}
97
+ attributes[:protocol_version] = session_data[:protocol_version] if session_data.key?(:protocol_version)
98
+ attributes[:client_info] = session_data[:client_info] if session_data.key?(:client_info)
99
+ attributes[:client_capabilities] = session_data[:client_capabilities] if session_data.key?(:client_capabilities)
100
+ attributes[:server_info] = session_data[:server_info] if session_data.key?(:server_info)
101
+ attributes[:server_capabilities] = session_data[:server_capabilities] if session_data.key?(:server_capabilities)
102
+
103
+ # Store any extra data in a jsonb column if available
104
+ # For now, we'll skip last_event_id and session_data as they don't exist in the DB
105
+
106
+ session.assign_attributes(attributes)
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
+ # Test session store that tracks all operations for assertions
125
+ class TestSessionStore < VolatileSessionStore
126
+ attr_reader :operations, :saved_sessions, :loaded_sessions,
127
+ :deleted_sessions, :updated_sessions
128
+
129
+ def initialize
130
+ super
131
+ @operations = Concurrent::Array.new
132
+ @saved_sessions = Concurrent::Array.new
133
+ @loaded_sessions = Concurrent::Array.new
134
+ @deleted_sessions = Concurrent::Array.new
135
+ @updated_sessions = Concurrent::Array.new
136
+ end
137
+
138
+ def load_session(session_id)
139
+ session = super
140
+ @operations << { type: :load, session_id: session_id, found: !session.nil? }
141
+ @loaded_sessions << session_id if session
142
+ session
143
+ end
144
+
145
+ def save_session(session_id, session_data)
146
+ super
147
+ @operations << { type: :save, session_id: session_id, data: session_data }
148
+ @saved_sessions << session_id
149
+ end
150
+
151
+ def delete_session(session_id)
152
+ result = super
153
+ @operations << { type: :delete, session_id: session_id }
154
+ @deleted_sessions << session_id
155
+ result
156
+ end
157
+
158
+ def update_session(session_id, attributes)
159
+ result = super
160
+ @operations << { type: :update, session_id: session_id, attributes: attributes }
161
+ @updated_sessions << session_id if result
162
+ result
163
+ end
164
+
165
+ # Test helper methods
166
+ def session_saved?(session_id)
167
+ @saved_sessions.include?(session_id)
168
+ end
169
+
170
+ def session_loaded?(session_id)
171
+ @loaded_sessions.include?(session_id)
172
+ end
173
+
174
+ def session_deleted?(session_id)
175
+ @deleted_sessions.include?(session_id)
176
+ end
177
+
178
+ def session_updated?(session_id)
179
+ @updated_sessions.include?(session_id)
180
+ end
181
+
182
+ def operation_count(type = nil)
183
+ if type
184
+ @operations.count { |op| op[:type] == type }
185
+ else
186
+ @operations.size
187
+ end
188
+ end
189
+
190
+ def last_saved_data(session_id)
191
+ @operations.reverse.find { |op| op[:type] == :save && op[:session_id] == session_id }&.dig(:data)
192
+ end
193
+
194
+ def reset_tracking!
195
+ @operations.clear
196
+ @saved_sessions.clear
197
+ @loaded_sessions.clear
198
+ @deleted_sessions.clear
199
+ @updated_sessions.clear
200
+ end
201
+ end
202
+
203
+ # Factory for creating session stores
204
+ class SessionStoreFactory
205
+ def self.create(type = nil, **options)
206
+ type ||= default_type
207
+
208
+ case type.to_sym
209
+ when :volatile, :memory
210
+ VolatileSessionStore.new
211
+ when :active_record, :persistent
212
+ ActiveRecordSessionStore.new
213
+ when :test
214
+ TestSessionStore.new
215
+ else
216
+ raise ArgumentError, "Unknown session store type: #{type}"
217
+ end
218
+ end
219
+
220
+ def self.default_type
221
+ if Rails.env.test?
222
+ :volatile # Use volatile for tests unless explicitly using :test
223
+ elsif Rails.env.production?
224
+ :active_record
225
+ else
226
+ :volatile
227
+ end
228
+ end
229
+ end
230
+ end
231
+ 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