actionmcp 0.22.0 → 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c997c4c99f4ecf6409fb3749bb58efab0eb03622e70d9a447781125620e6c82
4
- data.tar.gz: dd9fc6be1b7364f306a6cbcfe0cd9e8db3b00530e46056c6ad49e6995ab7810b
3
+ metadata.gz: 6b50744d4dcd16237d18d1b5c787f721e56281c82d1037c776201226a06cc60c
4
+ data.tar.gz: ed2b466652be17b4443508719da7b474b1bfc941047815f9a45bc4acf39ca2d6
5
5
  SHA512:
6
- metadata.gz: c6de9245cf20071a23b0f35181d1eb20db9a2fb96298c6afc165a05aa31f14c3f8035b2b3dbd9ac9b04319f0981ce26609f8afde1ab67ef99a055a070ce3018c
7
- data.tar.gz: e8b0006312a943b8228408ab0fe5b859d4a638bfbbe89ddcb6be0d38465476fdb9977f6d2b322676eb1ddc1608abcc7531c9352495c7bde11bb04868586edae1
6
+ metadata.gz: ba4f687a5368fb04a2f986b61859e19d05658c00e39dc2b600984db39f34fd3e0f56259e6d08c425d140c1f1641d55ee209ec0ae69109bde1e0ec5312b01cf57
7
+ data.tar.gz: 6daf198545188c392453e6a53b905fafd3aa6c84ab508114c563ef59ff1c8b0e44712d5c8c82dc8643497daef7136bc3c284baec482d9fd8193c3cf1a32d4fe0
@@ -48,7 +48,7 @@ module ActionMCP
48
48
 
49
49
  after_create_commit :broadcast_message, if: :outgoing_message?
50
50
  # Set is_ping on responses if the original request was a ping
51
- after_create :handle_ping_response, if: -> { %w[response error].include?(message_type) }
51
+ after_create :acknowledge_request, if: -> { %w[response error].include?(message_type) }
52
52
 
53
53
  # Scope to exclude both "ping" requests and their responses
54
54
  scope :without_pings, -> { where(is_ping: false) }
@@ -136,17 +136,22 @@ module ActionMCP
136
136
  end
137
137
  end
138
138
 
139
- def handle_ping_response
139
+ def acknowledge_request
140
140
  return unless jsonrpc_id.present?
141
141
 
142
142
  request_message = session.messages.find_by(
143
143
  jsonrpc_id: jsonrpc_id,
144
144
  message_type: "request"
145
145
  )
146
- return unless request_message&.is_ping
147
146
 
148
- self.is_ping = true
147
+ return unless request_message
148
+
149
+ # Set is_ping based on the request
150
+ self.is_ping = request_message.is_ping
151
+
152
+ # Mark the request as acknowledged for all responses
149
153
  request_message.update(request_acknowledged: true)
154
+
150
155
  save! if changed?
151
156
  end
152
157
  end
@@ -24,35 +24,16 @@ module ActionMCP
24
24
  # # Generate a concrete URI from a blueprint with parameters
25
25
  # uri = Blueprint.construct("file://{path}", { path: "/logs/app.log" })
26
26
  #
27
- class Blueprint
27
+ class Blueprint < Collection
28
28
  # Initialize a new Blueprints collection with URI template definitions
29
29
  #
30
30
  # @param templates [Array<Hash>] Array of URI template definition hashes, each containing
31
31
  # uriTemplate, name, description, and optionally mimeType keys
32
32
  # @param client [Object, nil] Optional client for lazy loading of templates
33
- attr_reader :client
34
-
35
33
  def initialize(templates, client)
36
- self.templates = templates
37
- @client = client
38
- @loaded = !templates.empty?
39
- end
40
-
41
- # Return all URI templates in the collection. If initialized with a client and templates
42
- # haven't been loaded yet, this will trigger lazy loading from the client.
43
- #
44
- # @return [Array<Blueprint>] All blueprint objects in the collection
45
- def all
46
- load_templates unless @loaded
47
- @templates
48
- end
49
-
50
- # Force reload all templates from the client and return them
51
- #
52
- # @return [Array<Blueprint>] All blueprint objects in the collection
53
- def all!
54
- load_templates(force: true)
55
- @templates
34
+ super(templates, client)
35
+ self.templates = @collection_data
36
+ @load_method = :list_resource_templates
56
37
  end
57
38
 
58
39
  # Find a blueprint by its URI pattern
@@ -84,23 +65,6 @@ module ActionMCP
84
65
  blueprint.construct(params)
85
66
  end
86
67
 
87
- # Filter blueprints based on a given block
88
- #
89
- # @yield [blueprint] Block that determines whether to include a blueprint
90
- # @yieldparam blueprint [Blueprint] A blueprint from the collection
91
- # @yieldreturn [Boolean] true to include the blueprint, false to exclude it
92
- # @return [Array<Blueprint>] Blueprints that match the filter criteria
93
- def filter(&block)
94
- all.select(&block)
95
- end
96
-
97
- # Number of blueprints in the collection
98
- #
99
- # @return [Integer] The number of blueprints
100
- def size
101
- all.size
102
- end
103
-
104
68
  # Check if the collection contains a blueprint with the given pattern
105
69
  #
106
70
  # @param pattern [String] The blueprint pattern to check for
@@ -116,41 +80,11 @@ module ActionMCP
116
80
  all.group_by(&:protocol)
117
81
  end
118
82
 
119
- # Implements enumerable functionality for the collection
120
- include Enumerable
121
-
122
- # Yield each blueprint in the collection to the given block
123
- #
124
- # @yield [blueprint] Block to execute for each blueprint
125
- # @yieldparam blueprint [Blueprint] A blueprint from the collection
126
- # @return [Enumerator] If no block is given
127
- def each(&block)
128
- all.each(&block)
129
- end
130
-
131
83
  # Convert raw template data into ResourceTemplate objects
132
84
  #
133
85
  # @param templates [Array<Hash>] Array of template definition hashes
134
86
  def templates=(templates)
135
- @templates = templates.map { |template_data| ResourceTemplate.new(template_data) }
136
- end
137
-
138
- private
139
-
140
- # Load or reload templates using the client
141
- #
142
- # @param force [Boolean] Whether to force reload even if templates are already loaded
143
- # @return [void]
144
- def load_templates(force: false)
145
- return if @loaded && !force
146
-
147
- begin
148
- @client.list_resource_templates
149
- @loaded = true
150
- rescue StandardError => e
151
- Rails.logger.error("Failed to load templates: #{e.message}")
152
- @loaded = true unless @templates.empty?
153
- end
87
+ @collection_data = templates.map { |template_data| ResourceTemplate.new(template_data) }
154
88
  end
155
89
 
156
90
  # Internal Blueprint class to represent individual URI templates
@@ -24,35 +24,16 @@ module ActionMCP
24
24
  # # Get all resources matching a criteria
25
25
  # rust_files = catalog.filter { |r| r.mime_type == "text/x-rust" }
26
26
  #
27
- class Catalog
27
+ class Catalog < Collection
28
28
  # Initialize a new Catalog with resource definitions
29
29
  #
30
30
  # @param resources [Array<Hash>] Array of resource definition hashes, each containing
31
31
  # uri, name, description, and mimeType keys
32
32
  # @param client [Object, nil] Optional client for lazy loading of resources
33
- attr_reader :client
34
-
35
33
  def initialize(resources, client)
36
- self.resources = resources
37
- @client = client
38
- @loaded = !resources.empty?
39
- end
40
-
41
- # Return all resources in the collection. If initialized with a client and resources
42
- # haven't been loaded yet, this will trigger lazy loading from the client.
43
- #
44
- # @return [Array<Resource>] All resource objects in the collection
45
- def all
46
- load_resources unless @loaded
47
- @resources
48
- end
49
-
50
- # Force reload all resources from the client and return them
51
- #
52
- # @return [Array<Resource>] All resource objects in the collection
53
- def all!
54
- load_resources(force: true)
55
- @resources
34
+ super(resources, client)
35
+ self.resources = @collection_data
36
+ @load_method = :list_resources
56
37
  end
57
38
 
58
39
  # Find a resource by URI
@@ -79,16 +60,6 @@ module ActionMCP
79
60
  all.select { |resource| resource.mime_type == mime_type }
80
61
  end
81
62
 
82
- # Filter resources based on a given block
83
- #
84
- # @yield [resource] Block that determines whether to include a resource
85
- # @yieldparam resource [Resource] A resource from the collection
86
- # @yieldreturn [Boolean] true to include the resource, false to exclude it
87
- # @return [Array<Resource>] Resources that match the filter criteria
88
- def filter(&block)
89
- all.select(&block)
90
- end
91
-
92
63
  # Get a list of all resource URIs
93
64
  #
94
65
  # @return [Array<String>] URIs of all resources in the collection
@@ -96,13 +67,6 @@ module ActionMCP
96
67
  all.map(&:uri)
97
68
  end
98
69
 
99
- # Number of resources in the collection
100
- #
101
- # @return [Integer] The number of resources
102
- def size
103
- all.size
104
- end
105
-
106
70
  # Check if the collection contains a resource with the given URI
107
71
  #
108
72
  # @param uri [String] The resource URI to check for
@@ -130,41 +94,11 @@ module ActionMCP
130
94
  end
131
95
  end
132
96
 
133
- # Implements enumerable functionality for the collection
134
- include Enumerable
135
-
136
- # Yield each resource in the collection to the given block
137
- #
138
- # @yield [resource] Block to execute for each resource
139
- # @yieldparam resource [Resource] A resource from the collection
140
- # @return [Enumerator] If no block is given
141
- def each(&block)
142
- all.each(&block)
143
- end
144
-
145
97
  # Convert raw resource data into Resource objects
146
98
  #
147
99
  # @param raw_resources [Array<Hash>] Array of resource definition hashes
148
100
  def resources=(raw_resources)
149
- @resources = raw_resources.map { |resource_data| Resource.new(resource_data) }
150
- end
151
-
152
- private
153
-
154
- # Load or reload resources using the client
155
- #
156
- # @param force [Boolean] Whether to force reload even if resources are already loaded
157
- # @return [void]
158
- def load_resources(force: false)
159
- return if @loaded && !force
160
-
161
- begin
162
- @client.list_resources
163
- @loaded = true
164
- rescue StandardError => e
165
- Rails.logger.error("Failed to load resources: #{e.message}")
166
- @loaded = true unless @resources.empty?
167
- end
101
+ @collection_data = raw_resources.map { |resource_data| Resource.new(resource_data) }
168
102
  end
169
103
 
170
104
  # Internal Resource class to represent individual resources
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Base collection class for MCP client collections
6
+ class Collection
7
+ include RequestTimeouts
8
+
9
+ attr_reader :client, :loaded
10
+
11
+ def initialize(items, client, silence_sql: true)
12
+ @collection_data = items || []
13
+ @client = client
14
+ @loaded = !(@collection_data.empty?)
15
+ @silence_sql = silence_sql
16
+ end
17
+
18
+ def all
19
+ silence_logs { load_items unless @loaded }
20
+ @collection_data
21
+ end
22
+
23
+ def all!(timeout: DEFAULT_TIMEOUT)
24
+ silence_logs { load_items(force: true, timeout: timeout) }
25
+ @collection_data
26
+ end
27
+
28
+ # Filter items based on a given block
29
+ #
30
+ # @yield [item] Block that determines whether to include an item
31
+ # @yieldparam item [Object] An item from the collection
32
+ # @yieldreturn [Boolean] true to include the item, false to exclude it
33
+ # @return [Array<Object>] Items that match the filter criteria
34
+ def filter(&block)
35
+ all.select(&block)
36
+ end
37
+
38
+ # Number of items in the collection
39
+ #
40
+ # @return [Integer] The number of items
41
+ def size
42
+ all.size
43
+ end
44
+
45
+ # Implements enumerable functionality
46
+ include Enumerable
47
+
48
+ def each(&block)
49
+ all.each(&block)
50
+ end
51
+
52
+ alias loaded? loaded
53
+
54
+ protected
55
+
56
+ def load_items(force: false, timeout: DEFAULT_TIMEOUT)
57
+ return if @loaded && !force
58
+
59
+ # Make sure @load_method is defined in the subclass
60
+ raise NotImplementedError, "Subclass must define @load_method" unless defined?(@load_method)
61
+
62
+ # Use the RequestTimeouts module to handle the request
63
+ load_with_timeout(@load_method, force: force, timeout: timeout)
64
+ end
65
+
66
+ private
67
+
68
+ def silence_logs
69
+ return yield unless @silence_sql
70
+
71
+ original_log_level = Session.logger&.level
72
+ begin
73
+ # Temporarily increase log level to suppress SQL queries
74
+ Session.logger.level = Logger::WARN if Session.logger
75
+ yield
76
+ ensure
77
+ # Restore original log level
78
+ Session.logger.level = original_log_level if Session.logger
79
+ end
80
+ end
81
+
82
+ def log_error(message)
83
+ # Safely handle logging - don't assume Rails.logger exists
84
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
85
+ Rails.logger.error("[#{self.class.name}] #{message}")
86
+ else
87
+ # Fall back to puts if Rails.logger is not available
88
+ puts "[ERROR] [#{self.class.name}] #{message}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -70,25 +70,30 @@ module ActionMCP
70
70
 
71
71
  def process_response(id, result)
72
72
  if transport.id == id
73
- ## This initialize the transport
73
+ ## This initializes the transport
74
74
  client.server = Client::Server.new(result)
75
75
  return send_initialized_notification
76
76
  end
77
+
77
78
  request = transport.messages.requests.find_by(jsonrpc_id: id)
78
79
  return unless request
80
+
81
+ # Mark the request as acknowledged
82
+ request.update(request_acknowledged: true)
83
+
79
84
  case request.rpc_method
80
85
  when "tools/list"
81
- client.toolbox.tools = result["tools"]
82
- return client.toolbox.all
86
+ client.toolbox.tools = result["tools"]
87
+ return true
83
88
  when "prompts/list"
84
89
  client.prompt_book.prompts = result["prompts"]
85
- return client.prompt_book.all
90
+ return true
86
91
  when "resources/list"
87
92
  client.catalog.resources = result["resources"]
88
- return client.catalog.all
93
+ return true
89
94
  when "resources/templates/list"
90
95
  client.blueprint.templates = result["resourceTemplates"]
91
- return client.blueprint.all
96
+ return true
92
97
  end
93
98
 
94
99
  puts "\e[31mUnknown response: #{id} #{result}\e[0m"
@@ -24,27 +24,16 @@ module ActionMCP
24
24
  # # Get all prompts matching a criteria
25
25
  # text_prompts = book.filter { |p| p.name.include?("text") }
26
26
  #
27
- class PromptBook
27
+ class PromptBook < Collection
28
28
  # Initialize a new PromptBook with prompt definitions
29
29
  #
30
30
  # @param prompts [Array<Hash>] Array of prompt definition hashes, each containing
31
31
  # name, description, and arguments keys
32
32
  # @param client [Object, nil] Optional client for lazy loading of prompts
33
- attr_reader :client
34
-
35
33
  def initialize(prompts, client)
36
- self.prompts = prompts
37
- @client = client
38
- @loaded = !prompts.empty?
39
- end
40
-
41
- # Return all prompts in the collection. If initialized with a client and prompts
42
- # haven't been loaded yet, this will trigger lazy loading from the client.
43
- #
44
- # @return [Array<Prompt>] All prompt objects in the collection
45
- def all
46
- load_prompts unless @loaded
47
- @prompts
34
+ super(prompts, client)
35
+ self.prompts = @collection_data
36
+ @load_method = :list_prompts
48
37
  end
49
38
 
50
39
  # Find a prompt by name
@@ -55,16 +44,6 @@ module ActionMCP
55
44
  all.find { |prompt| prompt.name == name }
56
45
  end
57
46
 
58
- # Filter prompts based on a given block
59
- #
60
- # @yield [prompt] Block that determines whether to include a prompt
61
- # @yieldparam prompt [Prompt] A prompt from the collection
62
- # @yieldreturn [Boolean] true to include the prompt, false to exclude it
63
- # @return [Array<Prompt>] Prompts that match the filter criteria
64
- def filter(&block)
65
- all.select(&block)
66
- end
67
-
68
47
  # Get a list of all prompt names
69
48
  #
70
49
  # @return [Array<String>] Names of all prompts in the collection
@@ -72,13 +51,6 @@ module ActionMCP
72
51
  all.map(&:name)
73
52
  end
74
53
 
75
- # Number of prompts in the collection
76
- #
77
- # @return [Integer] The number of prompts
78
- def size
79
- all.size
80
- end
81
-
82
54
  # Check if the collection contains a prompt with the given name
83
55
  #
84
56
  # @param name [String] The prompt name to check for
@@ -87,49 +59,11 @@ module ActionMCP
87
59
  all.any? { |prompt| prompt.name == name }
88
60
  end
89
61
 
90
- # Implements enumerable functionality for the collection
91
- include Enumerable
92
-
93
- # Yield each prompt in the collection to the given block
94
- #
95
- # @yield [prompt] Block to execute for each prompt
96
- # @yieldparam prompt [Prompt] A prompt from the collection
97
- # @return [Enumerator] If no block is given
98
- def each(&block)
99
- all.each(&block)
100
- end
101
-
102
- # Force reload all prompts from the client and return them
103
- #
104
- # @return [Array<Prompt>] All prompt objects in the collection
105
- def all!
106
- load_prompts(force: true)
107
- all
108
- end
109
-
110
62
  # Convert raw prompt data into Prompt objects
111
63
  #
112
64
  # @param prompts [Array<Hash>] Array of prompt definition hashes
113
65
  def prompts=(prompts)
114
- @prompts = prompts.map { |data| Prompt.new(data) }
115
- end
116
-
117
- private
118
-
119
- # Load or reload prompts using the client
120
- #
121
- # @param force [Boolean] Whether to force reload even if prompts are already loaded
122
- # @return [void]
123
- def load_prompts(force: false)
124
- return if @loaded && !force
125
-
126
- begin
127
- @client.list_prompts
128
- @loaded = true
129
- rescue StandardError => e
130
- Rails.logger.error("Failed to load prompts: #{e.message}")
131
- @loaded = true unless @prompts.empty?
132
- end
66
+ @collection_data = prompts.map { |data| Prompt.new(data) }
133
67
  end
134
68
 
135
69
  # Internal Prompt class to represent individual prompts
@@ -4,18 +4,21 @@ module ActionMCP
4
4
  module Client
5
5
  module Prompts
6
6
  # List all available prompts from the server
7
- # @return [Array<Hash>] List of available prompts with their metadata
7
+ # @return [String] Request ID for tracking the request
8
8
  def list_prompts
9
9
  request_id = SecureRandom.uuid_v7
10
10
 
11
11
  # Send request
12
12
  send_jsonrpc_request("prompts/list", id: request_id)
13
+
14
+ # Return request ID for tracking the request
15
+ request_id
13
16
  end
14
17
 
15
18
  # Get a specific prompt with arguments
16
19
  # @param name [String] Name of the prompt to get
17
20
  # @param arguments [Hash] Arguments to pass to the prompt
18
- # @return [Hash] Prompt content with messages
21
+ # @return [String] Request ID for tracking the request
19
22
  def get_prompt(name, arguments = {})
20
23
  request_id = SecureRandom.uuid_v7
21
24
 
@@ -27,6 +30,9 @@ module ActionMCP
27
30
  },
28
31
  id: request_id
29
32
  )
33
+
34
+ # Return request ID for tracking the request
35
+ request_id
30
36
  end
31
37
  end
32
38
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module RequestTimeouts
6
+ # Default timeout in seconds
7
+ DEFAULT_TIMEOUT = 1.0
8
+
9
+ # Load resources with timeout support - blocking until response or timeout
10
+ # @param method_name [Symbol] The method to call for loading (e.g., :list_resources)
11
+ # @param force [Boolean] Whether to force reload even if already loaded
12
+ # @param timeout [Float] Timeout in seconds
13
+ # @return [Boolean] Success status
14
+ def load_with_timeout(method_name, force: false, timeout: DEFAULT_TIMEOUT)
15
+ return true if @loaded && !force
16
+
17
+ # Make the request and store its ID
18
+ request_id = client.send(method_name)
19
+
20
+ start_time = Time.now
21
+
22
+ # Wait until either:
23
+ # 1. The collection is loaded (@loaded becomes true from JsonRpcHandler)
24
+ # 2. The timeout is reached
25
+ while !@loaded && (Time.now - start_time) < timeout
26
+ sleep(0.1)
27
+ end
28
+
29
+ # If we timed out
30
+ unless @loaded
31
+ request = client.session.messages.requests.find_by(jsonrpc_id: request_id)
32
+
33
+ if request && !request.request_acknowledged?
34
+ # Send cancel notification
35
+ client.send_jsonrpc_notification("notifications/cancelled", {
36
+ requestId: request_id,
37
+ reason: "Request timed out after #{timeout} seconds"
38
+ })
39
+
40
+ # Mark as cancelled in the database
41
+ request.update(request_cancelled: true)
42
+
43
+ log_error("Request #{method_name} timed out after #{timeout} seconds")
44
+ end
45
+
46
+ # Mark as loaded even though we timed out
47
+ @loaded = true
48
+ return false
49
+ end
50
+
51
+ # Collection was successfully loaded
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def handle_timeout(request_id, method_name, timeout)
58
+ # Find the request
59
+ request = client.session.messages.requests.find_by(jsonrpc_id: request_id)
60
+
61
+ if request && !request.request_acknowledged?
62
+ # Send cancel notification
63
+ client.send_jsonrpc_notification("notifications/cancelled", {
64
+ requestId: request_id,
65
+ reason: "Request timed out after #{timeout} seconds"
66
+ })
67
+
68
+ # Mark as cancelled in the database
69
+ request.update(request_cancelled: true)
70
+
71
+ log_error("Request #{method_name} timed out after #{timeout} seconds")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,26 +4,32 @@ module ActionMCP
4
4
  module Client
5
5
  module Resources
6
6
  # List all available resources from the server
7
- # @return [Array<Hash>] List of available resources with their metadata
7
+ # @return [String] Request ID for tracking the request
8
8
  def list_resources
9
9
  request_id = SecureRandom.uuid_v7
10
10
 
11
11
  # Send request
12
12
  send_jsonrpc_request("resources/list", id: request_id)
13
+
14
+ # Return request ID for tracking the request
15
+ request_id
13
16
  end
14
17
 
15
18
  # List resource templates from the server
16
- # @return [Array<Hash>] List of resource templates
19
+ # @return [String] Request ID for tracking the request
17
20
  def list_resource_templates
18
21
  request_id = SecureRandom.uuid_v7
19
22
 
20
23
  # Send request
21
24
  send_jsonrpc_request("resources/templates/list", id: request_id)
25
+
26
+ # Return request ID for tracking the request
27
+ request_id
22
28
  end
23
29
 
24
30
  # Read a specific resource
25
31
  # @param uri [String] URI of the resource to read
26
- # @return [Hash] Resource content
32
+ # @return [String] Request ID for tracking the request
27
33
  def read_resource(uri)
28
34
  request_id = SecureRandom.uuid_v7
29
35
 
@@ -32,12 +38,15 @@ module ActionMCP
32
38
  params: { uri: uri },
33
39
  id: request_id
34
40
  )
41
+
42
+ # Return request ID for tracking the request
43
+ request_id
35
44
  end
36
45
 
37
46
  # Subscribe to updates for a specific resource
38
47
  # @param uri [String] URI of the resource to subscribe to
39
48
  # @param update_callback [Proc] Callback for resource updates
40
- # @return [Boolean] Success status
49
+ # @return [String] Request ID for tracking the request
41
50
  def subscribe_resource(uri, update_callback)
42
51
  @resource_subscriptions ||= {}
43
52
  @resource_subscriptions[uri] = update_callback
@@ -49,11 +58,14 @@ module ActionMCP
49
58
  params: { uri: uri },
50
59
  id: request_id
51
60
  )
61
+
62
+ # Return request ID for tracking the request
63
+ request_id
52
64
  end
53
65
 
54
66
  # Unsubscribe from updates for a specific resource
55
67
  # @param uri [String] URI of the resource to unsubscribe from
56
- # @return [Boolean] Success status
68
+ # @return [String] Request ID for tracking the request
57
69
  def unsubscribe_resource(uri)
58
70
  @resource_subscriptions&.delete(uri)
59
71
 
@@ -64,6 +76,9 @@ module ActionMCP
64
76
  params: { uri: uri },
65
77
  id: request_id
66
78
  )
79
+
80
+ # Return request ID for tracking the request
81
+ request_id
67
82
  end
68
83
  end
69
84
  end
@@ -18,30 +18,15 @@ module ActionMCP
18
18
  # # Get all tools matching a criteria
19
19
  # calculation_tools = toolbox.filter { |t| t.name.include?("calculate") }
20
20
  #
21
- class Toolbox
22
- attr_reader :client
23
-
21
+ class Toolbox < Collection
24
22
  # Initialize a new Toolbox with tool definitions
25
23
  #
26
24
  # @param tools [Array<Hash>] Array of tool definition hashes, each containing
27
25
  # name, description, and inputSchema keys
28
26
  def initialize(tools, client)
29
- self.tools = tools
30
- @client = client
31
- @loaded = !tools.empty?
32
- end
33
-
34
- # Return all tools in the collection
35
- #
36
- # @return [Array<Tool>] All tool objects in the collection
37
- def all
38
- load_tools unless @loaded
39
- @tools
40
- end
41
-
42
- def all!
43
- load_tools(force: true)
44
- @tools
27
+ super(tools, client)
28
+ self.tools = @collection_data
29
+ @load_method = :list_tools
45
30
  end
46
31
 
47
32
  # Find a tool by name
@@ -89,8 +74,9 @@ module ActionMCP
89
74
  # @param keyword [String] Keyword to search for in tool names and descriptions
90
75
  # @return [Array<Tool>] Tools containing the keyword
91
76
  def search(keyword)
92
- @tools.select do |tool|
93
- tool.name.include?(keyword) || tool.description.downcase.include?(keyword.downcase)
77
+ all.select do |tool|
78
+ tool.name.include?(keyword) ||
79
+ (tool.description && tool.description.downcase.include?(keyword.downcase))
94
80
  end
95
81
  end
96
82
 
@@ -102,46 +88,18 @@ module ActionMCP
102
88
  case provider
103
89
  when :claude
104
90
  # Claude format
105
- { "tools" => @tools.map(&:to_claude_h) }
91
+ { "tools" => all.map(&:to_claude_h) }
106
92
  when :openai
107
93
  # OpenAI format
108
- { "tools" => @tools.map(&:to_openai_h) }
94
+ { "tools" => all.map(&:to_openai_h) }
109
95
  else
110
96
  # Default format (same as original)
111
- { "tools" => @tools.map(&:to_h) }
97
+ { "tools" => all.map(&:to_h) }
112
98
  end
113
99
  end
114
100
 
115
- # Implements enumerable functionality for the collection
116
- include Enumerable
117
-
118
- # Yield each tool in the collection to the given block
119
- #
120
- # @yield [tool] Block to execute for each tool
121
- # @yieldparam tool [Tool] A tool from the collection
122
- # @return [Enumerator] If no block is given
123
- def each(&block)
124
- @tools.each(&block)
125
- end
126
-
127
101
  def tools=(tools)
128
- @tools = tools.map { |tool_data| Tool.new(tool_data) }
129
- end
130
-
131
- private
132
-
133
- def load_tools(force: false)
134
- return if @loaded && !force
135
-
136
- begin
137
- @client.list_tools
138
- @loaded = true
139
- rescue StandardError => e
140
- # Handle error appropriately
141
- Rails.logger.error("Failed to load tools: #{e.message}")
142
- # Still mark as loaded but with empty list?
143
- @loaded = true unless @tools.empty?
144
- end
102
+ @collection_data = tools.map { |tool_data| Tool.new(tool_data) }
145
103
  end
146
104
 
147
105
  # Internal Tool class to represent individual tools
@@ -4,18 +4,21 @@ module ActionMCP
4
4
  module Client
5
5
  module Tools
6
6
  # List all available tools from the server
7
- # @return [Array<Hash>] List of available tools with their metadata
7
+ # @return [String] Request ID for tracking the request
8
8
  def list_tools
9
9
  request_id = SecureRandom.uuid_v7
10
10
 
11
11
  # Send request
12
12
  send_jsonrpc_request("tools/list", id: request_id)
13
+
14
+ # Return request ID for timeout tracking
15
+ request_id
13
16
  end
14
17
 
15
18
  # Call a specific tool on the server
16
19
  # @param name [String] Name of the tool to call
17
20
  # @param arguments [Hash] Arguments to pass to the tool
18
- # @return [Hash] The result of the tool execution
21
+ # @return [String] Request ID for tracking the request
19
22
  def call_tool(name, arguments)
20
23
  request_id = SecureRandom.uuid_v7
21
24
 
@@ -27,6 +30,9 @@ module ActionMCP
27
30
  },
28
31
  id: request_id
29
32
  )
33
+
34
+ # Return request ID for tracking the request
35
+ request_id
30
36
  end
31
37
  end
32
38
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.22.0"
5
+ VERSION = "0.24.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -115,11 +115,13 @@ files:
115
115
  - lib/action_mcp/client/base.rb
116
116
  - lib/action_mcp/client/blueprint.rb
117
117
  - lib/action_mcp/client/catalog.rb
118
+ - lib/action_mcp/client/collection.rb
118
119
  - lib/action_mcp/client/json_rpc_handler.rb
119
120
  - lib/action_mcp/client/logging.rb
120
121
  - lib/action_mcp/client/messaging.rb
121
122
  - lib/action_mcp/client/prompt_book.rb
122
123
  - lib/action_mcp/client/prompts.rb
124
+ - lib/action_mcp/client/request_timeouts.rb
123
125
  - lib/action_mcp/client/resources.rb
124
126
  - lib/action_mcp/client/roots.rb
125
127
  - lib/action_mcp/client/server.rb