actionmcp 0.102.0 → 0.104.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -3
  3. data/app/models/action_mcp/session.rb +6 -5
  4. data/lib/action_mcp/configuration.rb +44 -8
  5. data/lib/action_mcp/server/base_session.rb +5 -1
  6. data/lib/action_mcp/test_helper/session_store_assertions.rb +0 -70
  7. data/lib/action_mcp/version.rb +1 -1
  8. data/lib/action_mcp.rb +0 -1
  9. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +4 -4
  10. data/lib/generators/action_mcp/install/templates/mcp.yml +11 -1
  11. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +2 -2
  12. metadata +1 -26
  13. data/lib/action_mcp/client/active_record_session_store.rb +0 -57
  14. data/lib/action_mcp/client/base.rb +0 -225
  15. data/lib/action_mcp/client/blueprint.rb +0 -163
  16. data/lib/action_mcp/client/catalog.rb +0 -164
  17. data/lib/action_mcp/client/collection.rb +0 -168
  18. data/lib/action_mcp/client/elicitation.rb +0 -34
  19. data/lib/action_mcp/client/json_rpc_handler.rb +0 -202
  20. data/lib/action_mcp/client/logging.rb +0 -19
  21. data/lib/action_mcp/client/messaging.rb +0 -28
  22. data/lib/action_mcp/client/prompt_book.rb +0 -117
  23. data/lib/action_mcp/client/prompts.rb +0 -47
  24. data/lib/action_mcp/client/request_timeouts.rb +0 -74
  25. data/lib/action_mcp/client/resources.rb +0 -100
  26. data/lib/action_mcp/client/roots.rb +0 -13
  27. data/lib/action_mcp/client/server.rb +0 -60
  28. data/lib/action_mcp/client/session_store.rb +0 -39
  29. data/lib/action_mcp/client/session_store_factory.rb +0 -27
  30. data/lib/action_mcp/client/streamable_client.rb +0 -264
  31. data/lib/action_mcp/client/streamable_http_transport.rb +0 -306
  32. data/lib/action_mcp/client/test_session_store.rb +0 -84
  33. data/lib/action_mcp/client/toolbox.rb +0 -199
  34. data/lib/action_mcp/client/tools.rb +0 -47
  35. data/lib/action_mcp/client/transport.rb +0 -137
  36. data/lib/action_mcp/client/volatile_session_store.rb +0 -38
  37. data/lib/action_mcp/client.rb +0 -71
@@ -1,100 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- module Resources
6
- # List all available resources from the server
7
- # @param params [Hash] Optional parameters for pagination
8
- # @option params [String] :cursor Pagination cursor for fetching next page
9
- # @option params [Integer] :limit Maximum number of items to return
10
- # @return [String] Request ID for tracking the request
11
- def list_resources(params = {})
12
- request_id = SecureRandom.uuid_v7
13
-
14
- # Send request with pagination parameters if provided
15
- request_params = {}
16
- request_params[:cursor] = params[:cursor] if params[:cursor]
17
- request_params[:limit] = params[:limit] if params[:limit]
18
-
19
- send_jsonrpc_request("resources/list",
20
- params: request_params.empty? ? nil : request_params,
21
- id: request_id)
22
-
23
- # Return request ID for tracking the request
24
- request_id
25
- end
26
-
27
- # List resource templates from the server
28
- # @param params [Hash] Optional parameters for pagination
29
- # @option params [String] :cursor Pagination cursor for fetching next page
30
- # @option params [Integer] :limit Maximum number of items to return
31
- # @return [String] Request ID for tracking the request
32
- def list_resource_templates(params = {})
33
- request_id = SecureRandom.uuid_v7
34
-
35
- # Send request with pagination parameters if provided
36
- request_params = {}
37
- request_params[:cursor] = params[:cursor] if params[:cursor]
38
- request_params[:limit] = params[:limit] if params[:limit]
39
-
40
- send_jsonrpc_request("resources/templates/list",
41
- params: request_params.empty? ? nil : request_params,
42
- id: request_id)
43
-
44
- # Return request ID for tracking the request
45
- request_id
46
- end
47
-
48
- # Read a specific resource
49
- # @param uri [String] URI of the resource to read
50
- # @return [String] Request ID for tracking the request
51
- def read_resource(uri)
52
- request_id = SecureRandom.uuid_v7
53
-
54
- # Send request
55
- send_jsonrpc_request("resources/read",
56
- params: { uri: uri },
57
- id: request_id)
58
-
59
- # Return request ID for tracking the request
60
- request_id
61
- end
62
-
63
- # Subscribe to updates for a specific resource
64
- # @param uri [String] URI of the resource to subscribe to
65
- # @param update_callback [Proc] Callback for resource updates
66
- # @return [String] Request ID for tracking the request
67
- def subscribe_resource(uri, update_callback)
68
- @resource_subscriptions ||= {}
69
- @resource_subscriptions[uri] = update_callback
70
-
71
- request_id = SecureRandom.uuid_v7
72
-
73
- # Send request
74
- send_jsonrpc_request("resources/subscribe",
75
- params: { uri: uri },
76
- id: request_id)
77
-
78
- # Return request ID for tracking the request
79
- request_id
80
- end
81
-
82
- # Unsubscribe from updates for a specific resource
83
- # @param uri [String] URI of the resource to unsubscribe from
84
- # @return [String] Request ID for tracking the request
85
- def unsubscribe_resource(uri)
86
- @resource_subscriptions&.delete(uri)
87
-
88
- request_id = SecureRandom.uuid_v7
89
-
90
- # Send request
91
- send_jsonrpc_request("resources/unsubscribe",
92
- params: { uri: uri },
93
- id: request_id)
94
-
95
- # Return request ID for tracking the request
96
- request_id
97
- end
98
- end
99
- end
100
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- module Roots
6
- # Notify the server that the roots list has changed
7
- def roots_list_changed_notification
8
- send_jsonrpc_notification("notifications/roots/list_changed")
9
- true
10
- end
11
- end
12
- end
13
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- class Server
6
- attr_reader :name, :version, :server_info, :capabilities
7
-
8
- def initialize(data)
9
- # Store protocol version if needed for later use
10
- @protocol_version = data["protocolVersion"]
11
-
12
- # Extract server information
13
- @server_info = data["serverInfo"] || {}
14
- @name = server_info["name"]
15
- @version = server_info["version"]
16
-
17
- # Store capabilities for dynamic checking
18
- @capabilities = data["capabilities"] || {}
19
- end
20
-
21
- # Check if 'tools' capability is present
22
- def tools?
23
- @capabilities.key?("tools")
24
- end
25
-
26
- # Check if 'prompts' capability is present
27
- def prompts?
28
- @capabilities.key?("prompts")
29
- end
30
-
31
- # Check if tools have a dynamic state based on listChanged flag
32
- def dynamic_tools?
33
- tool_cap = @capabilities["tools"] || {}
34
- tool_cap["listChanged"] == true
35
- end
36
-
37
- # Check if logging capability exists
38
- def logging?
39
- @capabilities.key?("logging")
40
- end
41
-
42
- # Check if resources capability exists
43
- def resources?
44
- @capabilities.key?("resources")
45
- end
46
-
47
- # Check if resources have a dynamic state based on listChanged flag
48
- def dynamic_resources?
49
- resources_cap = @capabilities["resources"] || {}
50
- resources_cap["listChanged"] == true
51
- end
52
-
53
- def inspect
54
- "#<#{self.class.name} name: #{name}, version: #{version} with resources: #{resources?}, tools: #{tools?}, prompts: #{prompts?}, logging: #{logging?}>"
55
- end
56
-
57
- alias to_s inspect
58
- end
59
- end
60
- end
@@ -1,39 +0,0 @@
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
- end
39
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Factory for creating session stores
6
- class SessionStoreFactory
7
- def self.create(type = nil, **_options)
8
- type ||= default_type
9
-
10
- case type.to_sym
11
- when :volatile, :memory
12
- VolatileSessionStore.new
13
- when :active_record, :persistent
14
- ActiveRecordSessionStore.new
15
- when :test
16
- TestSessionStore.new
17
- else
18
- raise ArgumentError, "Unknown session store type: #{type}"
19
- end
20
- end
21
-
22
- def self.default_type
23
- ActionMCP.configuration.client_session_store_type
24
- end
25
- end
26
- end
27
- end
@@ -1,264 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # MCP client using Server-Sent Events (SSE) transport
6
- class StreamableClient < Base
7
- # Define a custom error class for connection issues
8
- class ConnectionError < StandardError; end
9
-
10
- SSE_TIMEOUT = 10
11
- ENDPOINT_TIMEOUT = 5 # Seconds
12
-
13
- attr_reader :base_url, :sse_path, :post_url, :session
14
-
15
- def initialize(url, connect: true, logger: ActionMCP.logger, **_options)
16
- gem "faraday", ">= 2.0"
17
- require "faraday"
18
- require "uri"
19
- super(logger: logger)
20
- @type = :sse
21
- setup_connection(url)
22
- @buffer = +""
23
- @stop_requested = false
24
- @endpoint_received = false
25
- @endpoint_mutex = Mutex.new
26
- @endpoint_condition = ConditionVariable.new
27
- @connection_mutex = Mutex.new
28
- @connection_condition = ConditionVariable.new
29
-
30
- self.connect if connect
31
- end
32
-
33
- protected
34
-
35
- def start_transport
36
- log_debug("Connecting to #{@base_url}#{@sse_path}...")
37
- @stop_requested = false
38
-
39
- # Reset connection state before starting
40
- @connection_mutex.synchronize do
41
- @connected = false
42
- @connection_error = nil
43
- end
44
-
45
- # Start connection thread
46
- @sse_thread = Thread.new { listen_sse }
47
-
48
- # Wait for endpoint
49
- wait_for_endpoint
50
- end
51
-
52
- def stop_transport
53
- log_debug("Stopping SSE connection...")
54
- @stop_requested = true
55
- cleanup_sse_thread
56
- end
57
-
58
- def send_message(json_rpc)
59
- response = @conn.post(post_url,
60
- json_rpc,
61
- { "Content-Type" => "application/json" })
62
- response.success?
63
- end
64
-
65
- def ready?
66
- endpoint_ready?
67
- end
68
-
69
- private
70
-
71
- def setup_connection(url)
72
- uri = URI.parse(url)
73
- @base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
74
- @sse_path = uri.path
75
-
76
- @conn = Faraday.new(url: @base_url) do |f|
77
- f.headers["User-Agent"] = user_agent
78
- f.options.timeout = nil # No read timeout
79
- f.options.open_timeout = SSE_TIMEOUT # Connection timeout
80
-
81
- # Use Net::HTTP adapter explicitly as it works well with streaming
82
- f.adapter :net_http do |http|
83
- http.read_timeout = nil # No read timeout at adapter level too
84
- http.open_timeout = SSE_TIMEOUT # Connection timeout
85
- end
86
- end
87
-
88
- @post_url = nil
89
- end
90
-
91
- def wait_for_endpoint
92
- success = false
93
- error = nil
94
-
95
- @endpoint_mutex.synchronize do
96
- unless @endpoint_received
97
- # Wait with timeout for endpoint
98
- timeout = @endpoint_condition.wait(@endpoint_mutex, ENDPOINT_TIMEOUT)
99
-
100
- # Handle timeout
101
- unless timeout || @endpoint_received
102
- error = "Timeout waiting for MCP endpoint (#{ENDPOINT_TIMEOUT} seconds)"
103
- end
104
- end
105
-
106
- success = @endpoint_received
107
- end
108
-
109
- if error
110
- log_error(error)
111
- raise ConnectionError, error
112
- end
113
-
114
- # If we have the endpoint, consider the connection successful
115
- if success
116
- @connection_mutex.synchronize do
117
- @connected = true
118
- @connection_condition.broadcast
119
- end
120
- end
121
-
122
- success
123
- end
124
-
125
- def endpoint_ready?
126
- @endpoint_mutex.synchronize { @endpoint_received }
127
- end
128
-
129
- def listen_sse
130
- log_debug("Starting SSE listener...")
131
-
132
- begin
133
- @conn.get(@sse_path) do |req|
134
- req.headers["Accept"] = "text/event-stream"
135
- req.headers["Cache-Control"] = "no-cache"
136
-
137
- req.options.on_data = proc do |chunk, bytes|
138
- handle_sse_data(chunk, bytes)
139
- end
140
- end
141
-
142
- # This should never be reached during normal operation
143
- # as the SSE connection stays open
144
- rescue Faraday::ConnectionFailed => e
145
- handle_connection_error(format_connection_error(e))
146
- end
147
- end
148
-
149
- def format_connection_error(error)
150
- if error.message.include?("Connection refused")
151
- "Connection refused - server at #{@base_url} is not running or not accepting connections"
152
- else
153
- "Connection failed: #{error.message}"
154
- end
155
- end
156
-
157
- def connection_error=(message)
158
- @connection_mutex.synchronize do
159
- @connection_error = message
160
- @connection_condition.broadcast
161
- end
162
- end
163
-
164
- def handle_connection_error(message)
165
- log_error("SSE connection failed: #{message}")
166
- self.connection_error = message
167
- @connected = false
168
- @error_callback&.call(StandardError.new(message))
169
- end
170
-
171
- def handle_sse_data(chunk, _overall_bytes)
172
- process_chunk(chunk)
173
- throw :halt if @stop_requested
174
- end
175
-
176
- def process_chunk(chunk)
177
- @buffer << chunk
178
- # If the buffer does not contain a newline but appears to be a complete JSON object,
179
- # flush it as a complete event.
180
- if @buffer.strip.match?(/^\{.*\}$/)
181
- (@current_event ||= []) << @buffer.strip
182
- @buffer = +""
183
- return handle_complete_event
184
- end
185
- process_buffer while @buffer.include?("\n")
186
- end
187
-
188
- def process_buffer
189
- line, _sep, rest = @buffer.partition("\n")
190
- @buffer = rest
191
-
192
- if line.strip.empty?
193
- handle_complete_event
194
- else
195
- (@current_event ||= []) << line.strip
196
- end
197
- end
198
-
199
- def handle_complete_event
200
- return unless @current_event
201
-
202
- handle_event(@current_event)
203
- @current_event = nil
204
- end
205
-
206
- def handle_event(lines)
207
- event_data = parse_event(lines)
208
- process_event(event_data)
209
- end
210
-
211
- def parse_event(lines)
212
- event_data = { type: "message", data: +"" }
213
- has_data_prefix = false
214
-
215
- lines.each do |line|
216
- if line.start_with?("event:")
217
- event_data[:type] = line.split(":", 2)[1].strip
218
- elsif line.start_with?("data:")
219
- has_data_prefix = true
220
- event_data[:data] << line.split(":", 2)[1].strip
221
- end
222
- end
223
-
224
- # If no "data:" prefix was found, treat the entire event as data
225
- event_data[:data] = lines.join("\n") unless has_data_prefix
226
- event_data
227
- end
228
-
229
- def process_event(event_data)
230
- case event_data[:type]
231
- when "endpoint" then set_post_endpoint(event_data[:data])
232
- when "message" then handle_raw_message(event_data[:data])
233
- else log_error("Unknown event type: #{event_data[:type]}")
234
- end
235
- end
236
-
237
- def set_post_endpoint(endpoint_path)
238
- @post_url = build_post_url(endpoint_path)
239
- log_debug("Received POST endpoint: #{post_url}")
240
-
241
- # Signal that we have received the endpoint
242
- @endpoint_mutex.synchronize do
243
- @endpoint_received = true
244
- @endpoint_condition.broadcast
245
- end
246
-
247
- # Now that we have the endpoint, send initial capabilities
248
- send_initial_capabilities
249
- end
250
-
251
- def build_post_url(endpoint_path)
252
- URI.join(@base_url, endpoint_path).to_s
253
- rescue StandardError
254
- "#{@base_url}#{endpoint_path}"
255
- end
256
-
257
- def cleanup_sse_thread
258
- return unless @sse_thread
259
-
260
- @sse_thread.join(SSE_TIMEOUT) || @sse_thread.kill
261
- end
262
- end
263
- end
264
- end