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 +4 -4
- 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 +140 -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/server/capabilities.rb +23 -0
- data/lib/action_mcp/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 88b1f1dba04f05f96a9cf1c4ab928412400cb0a796d6635277924e57aa91a3c6
|
4
|
+
data.tar.gz: 163a43c7d4e3e247ba6f1084250e9452e9f36ee0e2ba2ee4a1d6fe67ca95f147
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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, :
|
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 :
|
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
|
-
@
|
26
|
-
@session = Session
|
27
|
-
|
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
|
-
|
48
|
-
@connected
|
43
|
+
setup_transport_callbacks
|
49
44
|
end
|
50
45
|
|
51
|
-
# Connect to the MCP server
|
46
|
+
# Connect to the MCP server
|
52
47
|
def connect
|
53
|
-
return true if
|
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
|
-
|
60
|
-
success = start_transport
|
61
|
-
|
54
|
+
success = @transport.connect
|
62
55
|
unless success
|
63
|
-
log_error("Failed to establish connection
|
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
|
71
|
+
return true unless connected?
|
90
72
|
|
91
73
|
begin
|
92
|
-
|
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
|
116
|
-
log_error("Cannot send request - not
|
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
|
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
|
-
|
156
|
-
session
|
157
|
-
session
|
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
|
-
|
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
|
-
@
|
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
|
-
|
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
|
-
|
179
|
-
#
|
180
|
-
|
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:
|
183
|
-
capabilities:
|
184
|
-
clientInfo:
|
182
|
+
protocolVersion: PROTOCOL_VERSION,
|
183
|
+
capabilities: client_capabilities,
|
184
|
+
clientInfo: client_info
|
185
185
|
}
|
186
|
-
|
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
|
74
|
-
|
75
|
-
|
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
|
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
|
@@ -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)
|
data/lib/action_mcp/version.rb
CHANGED
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.
|
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:
|
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.
|
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
|