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.
- checksums.yaml +4 -4
- data/README.md +83 -0
- data/app/controllers/action_mcp/application_controller.rb +12 -6
- data/app/models/action_mcp/session.rb +5 -1
- data/lib/action_mcp/client/base.rb +88 -82
- data/lib/action_mcp/client/json_rpc_handler.rb +42 -5
- data/lib/action_mcp/client/session_store.rb +231 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +291 -0
- data/lib/action_mcp/client/transport.rb +107 -0
- data/lib/action_mcp/client.rb +45 -5
- data/lib/action_mcp/configuration.rb +16 -1
- data/lib/action_mcp/current.rb +19 -0
- data/lib/action_mcp/current_helpers.rb +19 -0
- data/lib/action_mcp/gateway.rb +85 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
- data/lib/action_mcp/jwt_decoder.rb +26 -0
- data/lib/action_mcp/prompt.rb +1 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_messaging.rb +14 -0
- data/lib/action_mcp/server/capabilities.rb +23 -0
- data/lib/action_mcp/server/error_aware.rb +8 -1
- data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
- data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
- data/lib/action_mcp/server/messaging.rb +12 -1
- data/lib/action_mcp/server/registry_management.rb +0 -1
- data/lib/action_mcp/server/response_collector.rb +40 -0
- data/lib/action_mcp/server/session_store.rb +762 -0
- data/lib/action_mcp/server/tools.rb +14 -3
- data/lib/action_mcp/server/transport_handler.rb +9 -5
- data/lib/action_mcp/server.rb +7 -0
- data/lib/action_mcp/tagged_stream_logging.rb +0 -4
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
- data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
- data/lib/action_mcp/test_helper.rb +4 -0
- data/lib/action_mcp/tool.rb +1 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- data/lib/generators/action_mcp/install/install_generator.rb +4 -0
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
- 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
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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::
|
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
|
-
|
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
|
-
|
21
|
-
Client::
|
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
|