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,306 +0,0 @@
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, :protocol_version
17
-
18
- def initialize(url, session_store:, session_id: nil, protocol_version: nil, **options)
19
- super(url, session_store: session_store, **options)
20
- @session_id = session_id
21
- @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
22
- @negotiated_protocol_version = nil
23
- @last_event_id = nil
24
- @buffer = +""
25
- @current_event = nil
26
- @reconnect_attempts = 0
27
- @max_reconnect_attempts = options[:max_reconnect_attempts] || 3
28
- @reconnect_delay = options[:reconnect_delay] || 1.0
29
-
30
- setup_http_client
31
- end
32
-
33
- def connect
34
- log_debug("Connecting via StreamableHTTP to #{@url}")
35
-
36
- # Load session if session_id provided
37
- load_session_state if @session_id
38
-
39
- # Start SSE stream if server supports it
40
- start_sse_stream
41
-
42
- # Set ready first, then connected (so transport is ready when on_connect fires)
43
- set_ready(true)
44
- set_connected(true)
45
- log_debug("StreamableHTTP connection established")
46
- true
47
- rescue StandardError => e
48
- handle_error(e)
49
- false
50
- end
51
-
52
- def disconnect
53
- return true unless connected?
54
-
55
- log_debug("Disconnecting StreamableHTTP")
56
- stop_sse_stream
57
- save_session_state if @session_id
58
- set_connected(false)
59
- set_ready(false)
60
- true
61
- rescue StandardError => e
62
- handle_error(e)
63
- false
64
- end
65
-
66
- def send_message(message)
67
- raise ConnectionError, "Transport not ready" unless ready?
68
-
69
- headers = build_post_headers
70
- json_data = message.is_a?(String) ? message : message.to_json
71
-
72
- log_debug("Sending message via POST")
73
- response = @http_client.post(@url, json_data, headers)
74
-
75
- handle_post_response(response, message)
76
- true
77
- rescue StandardError => e
78
- handle_error(e)
79
- false
80
- end
81
-
82
- private
83
-
84
- def setup_http_client
85
- require "faraday"
86
- @http_client = Faraday.new do |f|
87
- f.headers["User-Agent"] = user_agent
88
- f.options.timeout = nil # No read timeout for SSE
89
- f.options.open_timeout = SSE_TIMEOUT
90
- f.adapter :net_http
91
- end
92
- end
93
-
94
- def build_get_headers
95
- headers = {
96
- "Accept" => "text/event-stream",
97
- "Cache-Control" => "no-cache"
98
- }
99
- headers["mcp-session-id"] = @session_id if @session_id
100
- headers["Last-Event-ID"] = @last_event_id if @last_event_id
101
-
102
- # Add MCP-Protocol-Version header for GET requests when we have a negotiated version
103
- headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
104
-
105
- log_debug("Final GET headers: #{headers}")
106
- headers
107
- end
108
-
109
- def build_post_headers
110
- headers = {
111
- "Content-Type" => "application/json",
112
- "Accept" => "application/json, text/event-stream"
113
- }
114
- headers["mcp-session-id"] = @session_id if @session_id
115
-
116
- # Add MCP-Protocol-Version header as per 2025-06-18 spec
117
- # Only include when we have a negotiated version from previous handshake
118
- headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
119
-
120
- log_debug("Final POST headers: #{headers}")
121
- headers
122
- end
123
-
124
- def start_sse_stream
125
- log_debug("Starting SSE stream")
126
- @sse_thread = Thread.new { run_sse_stream }
127
- end
128
-
129
- def stop_sse_stream
130
- return unless @sse_thread
131
-
132
- log_debug("Stopping SSE stream")
133
- @stop_requested = true
134
- @sse_thread.kill if @sse_thread.alive?
135
- @sse_thread = nil
136
- @stop_requested = false
137
- end
138
-
139
- def run_sse_stream
140
- headers = build_get_headers
141
-
142
- @http_client.get(@url, nil, headers) do |req|
143
- req.options.on_data = proc do |chunk, _bytes|
144
- break if @stop_requested
145
-
146
- process_sse_chunk(chunk)
147
- end
148
- end
149
- rescue StandardError => e
150
- handle_sse_error(e)
151
- end
152
-
153
- def process_sse_chunk(chunk)
154
- @buffer << chunk
155
- process_complete_events while @buffer.include?("\n\n")
156
- end
157
-
158
- def process_complete_events
159
- event_data, _separator, rest = @buffer.partition("\n\n")
160
- @buffer = rest
161
-
162
- return if event_data.strip.empty?
163
-
164
- parse_sse_event(event_data)
165
- end
166
-
167
- def parse_sse_event(event_data)
168
- lines = event_data.split("\n")
169
- event_id = nil
170
- data_lines = []
171
-
172
- lines.each do |line|
173
- if line.start_with?("id:")
174
- event_id = line[3..].strip
175
- elsif line.start_with?("data:")
176
- data_lines << line[5..].strip
177
- end
178
- end
179
-
180
- return if data_lines.empty?
181
-
182
- @last_event_id = event_id if event_id
183
-
184
- begin
185
- message_data = data_lines.join("\n")
186
- message = MultiJson.load(message_data)
187
- handle_message(message)
188
- rescue MultiJson::ParseError => e
189
- log_error("Failed to parse SSE message: #{e}")
190
- end
191
- end
192
-
193
- def handle_post_response(response, _original_message)
194
- # Extract session ID from response headers
195
- @session_id = response.headers["mcp-session-id"] if response.headers["mcp-session-id"]
196
-
197
- case response.status
198
- when 200
199
- handle_success_response(response)
200
- when 202
201
- # Accepted - message received, no immediate response
202
- log_debug("Message accepted (202)")
203
- when 401
204
- raise AuthenticationError, "Authentication required"
205
- when 405
206
- # Method not allowed - server doesn't support this operation
207
- log_debug("Server returned 405 - operation not supported")
208
- else
209
- handle_error_response(response)
210
- end
211
- end
212
-
213
- def handle_success_response(response)
214
- content_type = response.headers["content-type"]
215
-
216
- if content_type&.include?("application/json")
217
- # Direct JSON response
218
- handle_json_response(response)
219
- elsif content_type&.include?("text/event-stream")
220
- # SSE response stream
221
- handle_sse_response_stream(response)
222
- end
223
- end
224
-
225
- def handle_json_response(response)
226
- message = MultiJson.load(response.body)
227
-
228
- # Check if this is an initialize response to capture negotiated protocol version
229
- if message.is_a?(Hash) && message["result"] && message["result"]["protocolVersion"]
230
- @negotiated_protocol_version = message["result"]["protocolVersion"]
231
- log_debug("Negotiated protocol version: #{@negotiated_protocol_version}")
232
- end
233
-
234
- handle_message(message)
235
- rescue MultiJson::ParseError => e
236
- log_error("Failed to parse JSON response: #{e}")
237
- end
238
-
239
- def handle_sse_response_stream(response)
240
- # Handle SSE stream from POST response
241
- response.body.each_line do |line|
242
- process_sse_chunk(line)
243
- end
244
- end
245
-
246
- def handle_error_response(response)
247
- error_msg = +"HTTP #{response.status}: #{response.reason_phrase}"
248
- error_msg << " - #{response.body}" if response.body && !response.body.empty?
249
- raise ConnectionError, error_msg
250
- end
251
-
252
- def handle_sse_error(error)
253
- log_error("SSE stream error: #{error.message}")
254
-
255
- if should_reconnect?
256
- schedule_reconnect
257
- else
258
- handle_error(error)
259
- end
260
- end
261
-
262
- def should_reconnect?
263
- connected? && @reconnect_attempts < @max_reconnect_attempts
264
- end
265
-
266
- def schedule_reconnect
267
- @reconnect_attempts += 1
268
- delay = @reconnect_delay * @reconnect_attempts
269
-
270
- log_debug("Scheduling SSE reconnect in #{delay}s (attempt #{@reconnect_attempts})")
271
-
272
- Thread.new do
273
- sleep(delay)
274
- start_sse_stream unless @stop_requested
275
- end
276
- end
277
-
278
- def load_session_state
279
- session_data = @session_store.load_session(@session_id)
280
- return unless session_data
281
-
282
- @last_event_id = session_data[:last_event_id]
283
- log_debug("Loaded session state: last_event_id=#{@last_event_id}")
284
- end
285
-
286
- def save_session_state
287
- return unless @session_id
288
-
289
- session_data = {
290
- id: @session_id,
291
- last_event_id: @last_event_id,
292
- session_data: {},
293
- protocol_version: @protocol_version
294
- }
295
-
296
- @session_store.save_session(@session_id, session_data)
297
- log_debug("Saved session state")
298
- end
299
-
300
-
301
- def user_agent
302
- "ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
303
- end
304
- end
305
- end
306
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Test session store that tracks all operations for assertions
6
- class TestSessionStore < VolatileSessionStore
7
- attr_reader :operations, :saved_sessions, :loaded_sessions,
8
- :deleted_sessions, :updated_sessions
9
-
10
- def initialize
11
- super
12
- @operations = Concurrent::Array.new
13
- @saved_sessions = Concurrent::Array.new
14
- @loaded_sessions = Concurrent::Array.new
15
- @deleted_sessions = Concurrent::Array.new
16
- @updated_sessions = Concurrent::Array.new
17
- end
18
-
19
- def load_session(session_id)
20
- session = super
21
- @operations << { type: :load, session_id: session_id, found: !session.nil? }
22
- @loaded_sessions << session_id if session
23
- session
24
- end
25
-
26
- def save_session(session_id, session_data)
27
- super
28
- @operations << { type: :save, session_id: session_id, data: session_data }
29
- @saved_sessions << session_id
30
- end
31
-
32
- def delete_session(session_id)
33
- result = super
34
- @operations << { type: :delete, session_id: session_id }
35
- @deleted_sessions << session_id
36
- result
37
- end
38
-
39
- def update_session(session_id, attributes)
40
- result = super
41
- @operations << { type: :update, session_id: session_id, attributes: attributes }
42
- @updated_sessions << session_id if result
43
- result
44
- end
45
-
46
- # Test helper methods
47
- def session_saved?(session_id)
48
- @saved_sessions.include?(session_id)
49
- end
50
-
51
- def session_loaded?(session_id)
52
- @loaded_sessions.include?(session_id)
53
- end
54
-
55
- def session_deleted?(session_id)
56
- @deleted_sessions.include?(session_id)
57
- end
58
-
59
- def session_updated?(session_id)
60
- @updated_sessions.include?(session_id)
61
- end
62
-
63
- def operation_count(type = nil)
64
- if type
65
- @operations.count { |op| op[:type] == type }
66
- else
67
- @operations.size
68
- end
69
- end
70
-
71
- def last_saved_data(session_id)
72
- @operations.reverse.find { |op| op[:type] == :save && op[:session_id] == session_id }&.dig(:data)
73
- end
74
-
75
- def reset_tracking!
76
- @operations.clear
77
- @saved_sessions.clear
78
- @loaded_sessions.clear
79
- @deleted_sessions.clear
80
- @updated_sessions.clear
81
- end
82
- end
83
- end
84
- end
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Toolbox
6
- #
7
- # A collection that manages and provides access to tools from the server.
8
- # This class stores tool definitions along with their input schemas and
9
- # provides methods for retrieving, filtering, and accessing tools.
10
- #
11
- # Example usage:
12
- # tools_data = client.list_tools # Returns array of tool definitions
13
- # toolbox = Toolbox.new(tools_data)
14
- #
15
- # # Access a specific tool by name
16
- # weather_tool = toolbox.find("weather_forecast")
17
- #
18
- # # Get all tools matching a criteria
19
- # calculation_tools = toolbox.filter { |t| t.name.include?("calculate") }
20
- #
21
- class Toolbox < Collection
22
- # Initialize a new Toolbox with tool definitions
23
- #
24
- # @param tools [Array<Hash>] Array of tool definition hashes, each containing
25
- # name, description, and inputSchema keys
26
- def initialize(tools, client)
27
- super(tools, client)
28
- self.tools = @collection_data
29
- @load_method = :list_tools
30
- end
31
-
32
- # Find a tool by name
33
- #
34
- # @param name [String] Name of the tool to find
35
- # @return [Tool, nil] The tool with the given name, or nil if not found
36
- def find(name)
37
- all.find { |tool| tool.name == name }
38
- end
39
-
40
- # Filter tools based on a given block
41
- #
42
- # @yield [tool] Block that determines whether to include a tool
43
- # @yieldparam tool [Tool] A tool from the collection
44
- # @yieldreturn [Boolean] true to include the tool, false to exclude it
45
- # @return [Array<Tool>] Tools that match the filter criteria
46
- def filter(&block)
47
- all.select(&block)
48
- end
49
-
50
- # Get a list of all tool names
51
- #
52
- # @return [Array<String>] Names of all tools in the collection
53
- def names
54
- all.map(&:name)
55
- end
56
-
57
- # Number of tools in the collection
58
- #
59
- # @return [Integer] The number of tools
60
- def size
61
- all.size
62
- end
63
-
64
- # Check if the collection contains a tool with the given name
65
- #
66
- # @param name [String] The tool name to check for
67
- # @return [Boolean] true if a tool with the name exists
68
- def contains?(name)
69
- all.any? { |tool| tool.name == name }
70
- end
71
-
72
- # Get tools by category or type
73
- #
74
- # @param keyword [String] Keyword to search for in tool names and descriptions
75
- # @return [Array<Tool>] Tools containing the keyword
76
- def search(keyword)
77
- all.select do |tool|
78
- tool.name.include?(keyword) ||
79
- tool.description&.downcase&.include?(keyword.downcase)
80
- end
81
- end
82
-
83
- # Generate a hash representation of all tools in the collection based on provider format
84
- #
85
- # @param provider [Symbol] The provider format to use (:claude, :openai, or :default)
86
- # @return [Hash] Hash containing all tools formatted for the specified provider
87
- def to_h(provider = :default)
88
- case provider
89
- when :claude
90
- # Claude format
91
- { "tools" => all.map(&:to_claude_h) }
92
- when :openai
93
- # OpenAI format
94
- { "tools" => all.map(&:to_openai_h) }
95
- else
96
- # Default format (same as original)
97
- { "tools" => all.map(&:to_h) }
98
- end
99
- end
100
-
101
- def tools=(tools)
102
- @collection_data = tools.map { |tool_data| Tool.new(tool_data) }
103
- end
104
-
105
- # Internal Tool class to represent individual tools
106
- class Tool
107
- attr_reader :name, :description, :input_schema, :annotations
108
-
109
- # Initialize a new Tool instance
110
- #
111
- # @param data [Hash] Tool definition hash containing name, description, and inputSchema
112
- # and optionally annotations
113
- def initialize(data)
114
- @name = data["name"]
115
- @description = data["description"]
116
- @input_schema = data["inputSchema"] || {}
117
- @annotations = data["annotations"] || {}
118
- end
119
-
120
- # Get all required properties for this tool
121
- #
122
- # @return [Array<String>] Array of required property names
123
- def required_properties
124
- @input_schema["required"] || []
125
- end
126
-
127
- # Get all properties for this tool
128
- #
129
- # @return [Hash] Hash of property definitions
130
- def properties
131
- @input_schema["properties"] || {}
132
- end
133
-
134
- # Check if the tool requires a specific property
135
- #
136
- # @param name [String] Name of the property to check
137
- # @return [Boolean] true if the property is required
138
- def requires?(name)
139
- required_properties.include?(name)
140
- end
141
-
142
- # Check if the tool has a specific property
143
- #
144
- # @param name [String] Name of the property to check
145
- # @return [Boolean] true if the property exists
146
- def has_property?(name)
147
- properties.key?(name)
148
- end
149
-
150
- # Get property details by name
151
- #
152
- # @param name [String] Name of the property
153
- # @return [Hash, nil] Property details or nil if not found
154
- def property(name)
155
- properties[name]
156
- end
157
-
158
- # Generate a hash representation of the tool (default format)
159
- #
160
- # @return [Hash] Hash containing tool details
161
- def to_h
162
- {
163
- "name" => @name,
164
- "description" => @description,
165
- "inputSchema" => @input_schema,
166
- "annotations" => @annotations
167
- }
168
- end
169
-
170
- # Generate a hash representation of the tool in Claude format
171
- #
172
- # @return [Hash] Hash containing tool details formatted for Claude
173
- def to_claude_h
174
- {
175
- "name" => @name,
176
- "description" => @description,
177
- "input_schema" => @input_schema.transform_keys { |k| k == "inputSchema" ? "input_schema" : k },
178
- "annotations" => @annotations
179
- }
180
- end
181
-
182
- # Generate a hash representation of the tool in OpenAI format
183
- #
184
- # @return [Hash] Hash containing tool details formatted for OpenAI
185
- def to_openai_h
186
- {
187
- "type" => "function",
188
- "function" => {
189
- "name" => @name,
190
- "description" => @description,
191
- "parameters" => @input_schema,
192
- "annotations" => @annotations
193
- }
194
- }
195
- end
196
- end
197
- end
198
- end
199
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- module Tools
6
- # List all available tools 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_tools(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("tools/list",
20
- params: request_params.empty? ? nil : request_params,
21
- id: request_id)
22
-
23
- # Return request ID for timeout tracking
24
- request_id
25
- end
26
-
27
- # Call a specific tool on the server
28
- # @param name [String] Name of the tool to call
29
- # @param arguments [Hash] Arguments to pass to the tool
30
- # @return [String] Request ID for tracking the request
31
- def call_tool(name, arguments)
32
- request_id = SecureRandom.uuid_v7
33
-
34
- # Send request
35
- send_jsonrpc_request("tools/call",
36
- params: {
37
- name: name,
38
- arguments: arguments
39
- },
40
- id: request_id)
41
-
42
- # Return request ID for tracking the request
43
- request_id
44
- end
45
- end
46
- end
47
- end