actionmcp 0.101.0 → 0.103.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -3
  3. data/lib/action_mcp/content/resource.rb +7 -4
  4. data/lib/action_mcp/test_helper/session_store_assertions.rb +0 -70
  5. data/lib/action_mcp/version.rb +1 -1
  6. data/lib/action_mcp.rb +0 -1
  7. metadata +2 -27
  8. data/lib/action_mcp/client/active_record_session_store.rb +0 -57
  9. data/lib/action_mcp/client/base.rb +0 -225
  10. data/lib/action_mcp/client/blueprint.rb +0 -163
  11. data/lib/action_mcp/client/catalog.rb +0 -164
  12. data/lib/action_mcp/client/collection.rb +0 -168
  13. data/lib/action_mcp/client/elicitation.rb +0 -34
  14. data/lib/action_mcp/client/json_rpc_handler.rb +0 -202
  15. data/lib/action_mcp/client/logging.rb +0 -19
  16. data/lib/action_mcp/client/messaging.rb +0 -28
  17. data/lib/action_mcp/client/prompt_book.rb +0 -117
  18. data/lib/action_mcp/client/prompts.rb +0 -47
  19. data/lib/action_mcp/client/request_timeouts.rb +0 -74
  20. data/lib/action_mcp/client/resources.rb +0 -100
  21. data/lib/action_mcp/client/roots.rb +0 -13
  22. data/lib/action_mcp/client/server.rb +0 -60
  23. data/lib/action_mcp/client/session_store.rb +0 -39
  24. data/lib/action_mcp/client/session_store_factory.rb +0 -27
  25. data/lib/action_mcp/client/streamable_client.rb +0 -264
  26. data/lib/action_mcp/client/streamable_http_transport.rb +0 -306
  27. data/lib/action_mcp/client/test_session_store.rb +0 -84
  28. data/lib/action_mcp/client/toolbox.rb +0 -199
  29. data/lib/action_mcp/client/tools.rb +0 -47
  30. data/lib/action_mcp/client/transport.rb +0 -137
  31. data/lib/action_mcp/client/volatile_session_store.rb +0 -38
  32. data/lib/action_mcp/client.rb +0 -71
@@ -1,164 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Catalog
6
- #
7
- # A collection that manages and provides access to resources.
8
- # This class stores resource definitions and provides methods for
9
- # retrieving, filtering, and accessing resources by URI or other attributes.
10
- # It supports lazy loading of resources when initialized with a client.
11
- #
12
- # Example usage:
13
- # # Eager loading
14
- # resources_data = client.list_resources # Returns array of resource definitions
15
- # catalog = Catalog.new(resources_data)
16
- #
17
- # # Lazy loading
18
- # catalog = Catalog.new([], client)
19
- # resources = catalog.all # Resources are loaded here
20
- #
21
- # # Access a specific resource by URI
22
- # main_file = catalog.find_by_uri("file:///project/src/main.rs")
23
- #
24
- # # Get all resources matching a criteria
25
- # rust_files = catalog.filter { |r| r.mime_type == "text/x-rust" }
26
- #
27
- class Catalog < Collection
28
- # Initialize a new Catalog with resource definitions
29
- #
30
- # @param resources [Array<Hash>] Array of resource definition hashes, each containing
31
- # uri, name, description, and mimeType keys
32
- # @param client [Object, nil] Optional client for lazy loading of resources
33
- def initialize(resources, client)
34
- super(resources, client)
35
- self.resources = @collection_data
36
- @load_method = :list_resources
37
- end
38
-
39
- # Find a resource by URI
40
- #
41
- # @param uri [String] URI of the resource to find
42
- # @return [Resource, nil] The resource with the given URI, or nil if not found
43
- def find_by_uri(uri)
44
- all.find { |resource| resource.uri == uri }
45
- end
46
-
47
- # Find resources by name
48
- #
49
- # @param name [String] Name of the resources to find
50
- # @return [Array<Resource>] Resources with the given name
51
- def find_by_name(name)
52
- all.select { |resource| resource.name == name }
53
- end
54
-
55
- # Find resources by MIME type
56
- #
57
- # @param mime_type [String] MIME type to search for
58
- # @return [Array<Resource>] Resources with the given MIME type
59
- def find_by_mime_type(mime_type)
60
- all.select { |resource| resource.mime_type == mime_type }
61
- end
62
-
63
- # Get a list of all resource URIs
64
- #
65
- # @return [Array<String>] URIs of all resources in the collection
66
- def uris
67
- all.map(&:uri)
68
- end
69
-
70
- # Check if the collection contains a resource with the given URI
71
- #
72
- # @param uri [String] The resource URI to check for
73
- # @return [Boolean] true if a resource with the URI exists
74
- def contains_uri?(uri)
75
- all.any? { |resource| resource.uri == uri }
76
- end
77
-
78
- # Group resources by MIME type
79
- #
80
- # @return [Hash<String, Array<Resource>>] Hash mapping MIME types to arrays of resources
81
- def group_by_mime_type
82
- all.group_by(&:mime_type)
83
- end
84
-
85
- # Search resources by keyword in name or description
86
- #
87
- # @param keyword [String] Keyword to search for
88
- # @return [Array<Resource>] Resources matching the search term
89
- def search(keyword)
90
- keyword = keyword.downcase
91
- all.select do |resource|
92
- resource.name.downcase.include?(keyword) ||
93
- resource.description&.downcase&.include?(keyword)
94
- end
95
- end
96
-
97
- # Convert raw resource data into Resource objects
98
- #
99
- # @param raw_resources [Array<Hash>] Array of resource definition hashes
100
- def resources=(raw_resources)
101
- @collection_data = raw_resources.map { |resource_data| Resource.new(resource_data) }
102
- end
103
-
104
- # Internal Resource class to represent individual resources
105
- class Resource
106
- attr_reader :uri, :name, :description, :mime_type, :annotations
107
-
108
- # Initialize a new Resource instance
109
- #
110
- # @param data [Hash] Resource definition hash containing uri, name, description, and mimeType
111
- def initialize(data = [])
112
- @uri = data["uri"]
113
- @name = data["name"]
114
- @description = data["description"]
115
- @mime_type = data["mimeType"]
116
- @annotations = data["annotations"] || {}
117
- end
118
-
119
- # Get the file extension from the resource name
120
- #
121
- # @return [String, nil] The file extension or nil if no extension
122
- def extension
123
- File.extname(@name)[1..] if @name.include?(".")
124
- end
125
-
126
- # Check if this resource is a text file based on MIME type
127
- #
128
- # @return [Boolean] true if the resource is a text file
129
- def text?
130
- @mime_type&.start_with?("text/")
131
- end
132
-
133
- # Check if this resource is an image based on MIME type
134
- #
135
- # @return [Boolean] true if the resource is an image
136
- def image?
137
- @mime_type&.start_with?("image/")
138
- end
139
-
140
- # Get the path portion of the URI
141
- #
142
- # @return [String, nil] The path component of the URI
143
- def path
144
- URI(@uri).path
145
- rescue StandardError
146
- nil
147
- end
148
-
149
- # Generate a hash representation of the resource
150
- #
151
- # @return [Hash] Hash containing resource details
152
- def to_h
153
- {
154
- "uri" => @uri,
155
- "name" => @name,
156
- "description" => @description,
157
- "mimeType" => @mime_type,
158
- "annotations" => @annotations
159
- }
160
- end
161
- end
162
- end
163
- end
164
- end
@@ -1,168 +0,0 @@
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, :next_cursor, :total
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
- @next_cursor = nil
17
- @total = items&.size || 0
18
- end
19
-
20
- def all(limit: nil)
21
- if limit
22
- # If a limit is provided, use pagination
23
- result = []
24
- each_page(limit: limit) { |page| result.concat(page) }
25
- result
26
- else
27
- # Otherwise, maintain the old behavior
28
- silence_logs { load_items unless @loaded }
29
- @collection_data
30
- end
31
- end
32
-
33
- def all!(timeout: DEFAULT_TIMEOUT)
34
- silence_logs { load_items(force: true, timeout: timeout) }
35
- @collection_data
36
- end
37
-
38
- # Fetch a single page of results
39
- #
40
- # @param cursor [String, nil] Optional cursor for pagination
41
- # @param limit [Integer, nil] Optional limit for page size
42
- # @return [Array<Object>] The page of items
43
- def page(cursor: nil, limit: nil)
44
- silence_logs { load_page(cursor: cursor, limit: limit) }
45
- @collection_data
46
- end
47
-
48
- # Check if there are more pages available
49
- #
50
- # @return [Boolean] true if there are more pages to fetch
51
- def has_more_pages?
52
- !@next_cursor.nil?
53
- end
54
-
55
- # Fetch the next page of results
56
- #
57
- # @param limit [Integer, nil] Optional limit for page size
58
- # @return [Array<Object>] The next page of items, or empty array if no more pages
59
- def next_page(limit: nil)
60
- return [] unless has_more_pages?
61
-
62
- page(cursor: @next_cursor, limit: limit)
63
- end
64
-
65
- # Iterate through all pages of results
66
- #
67
- # @param limit [Integer, nil] Optional limit for page size
68
- # @yield [page] Block to process each page
69
- # @yieldparam page [Array<Object>] A page of items
70
- def each_page(limit: nil)
71
- return unless block_given?
72
-
73
- current_page = page(limit: limit)
74
- yield current_page
75
-
76
- while has_more_pages?
77
- current_page = next_page(limit: limit)
78
- yield current_page unless current_page.empty?
79
- end
80
- end
81
-
82
- # Filter items based on a given block
83
- #
84
- # @yield [item] Block that determines whether to include an item
85
- # @yieldparam item [Object] An item from the collection
86
- # @yieldreturn [Boolean] true to include the item, false to exclude it
87
- # @return [Array<Object>] Items that match the filter criteria
88
- def filter(&block)
89
- all.select(&block)
90
- end
91
-
92
- # Number of items in the collection
93
- #
94
- # @return [Integer] The number of items
95
- def size
96
- all.size
97
- end
98
-
99
- # Implements enumerable functionality
100
- include Enumerable
101
-
102
- def each(&block)
103
- all.each(&block)
104
- end
105
-
106
- alias loaded? loaded
107
-
108
- protected
109
-
110
- def load_items(force: false, timeout: DEFAULT_TIMEOUT)
111
- return if @loaded && !force
112
-
113
- # Make sure @load_method is defined in the subclass
114
- raise NotImplementedError, "Subclass must define @load_method" unless defined?(@load_method)
115
-
116
- # Use the RequestTimeouts module to handle the request
117
- load_with_timeout(@load_method, force: force, timeout: timeout)
118
- end
119
-
120
- def load_page(cursor: nil, limit: nil, timeout: DEFAULT_TIMEOUT)
121
- # Make sure @load_method is defined in the subclass
122
- raise NotImplementedError, "Subclass must define @load_method" unless defined?(@load_method)
123
-
124
- # Use the RequestTimeouts module to handle the request with pagination params
125
- params = {}
126
- params[:cursor] = cursor if cursor
127
- params[:limit] = limit if limit
128
-
129
- client.send(@load_method, params)
130
-
131
- start_time = Time.now
132
- sleep(0.1) while !@loaded && (Time.now - start_time) < timeout
133
-
134
- # Update @loaded status even if we timed out
135
- @loaded = true
136
-
137
- # Return the loaded data
138
- @collection_data
139
- end
140
-
141
- private
142
-
143
- def silence_logs
144
- return yield unless @silence_sql
145
-
146
- original_log_level = ActionMCP::Session.logger&.level
147
- begin
148
- # Temporarily increase log level to suppress SQL queries
149
- ActionMCP::Session.logger.level = Logger::WARN if ActionMCP::Session.logger
150
- yield
151
- ensure
152
- # Restore original log level
153
- ActionMCP::Session.logger.level = original_log_level if ActionMCP::Session.logger && original_log_level
154
- end
155
- end
156
-
157
- def log_error(message)
158
- # Safely handle logging - don't assume Rails.logger exists
159
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
160
- Rails.logger.error("[#{self.class.name}] #{message}")
161
- else
162
- # Fall back to puts if Rails.logger is not available
163
- puts "[ERROR] [#{self.class.name}] #{message}"
164
- end
165
- end
166
- end
167
- end
168
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Handles elicitation requests from servers
6
- module Elicitation
7
- # Process elicitation request from server
8
- # @param id [String, Integer] The request ID
9
- # @param params [Hash] The elicitation parameters
10
- def process_elicitation_request(id, params)
11
- params["message"]
12
- params["requestedSchema"]
13
-
14
- # In a real implementation, this would prompt the user
15
- # For now, we'll just return a decline response
16
- # Actual implementations should override this method
17
- send_jsonrpc_response(id, result: {
18
- action: "decline"
19
- })
20
- end
21
-
22
- # Send elicitation response
23
- # @param id [String, Integer] The request ID
24
- # @param action [String] The action taken ("accept", "decline", "cancel")
25
- # @param content [Hash, nil] The form data if action is "accept"
26
- def send_elicitation_response(id, action:, content: nil)
27
- result = { action: action }
28
- result[:content] = content if action == "accept" && content
29
-
30
- send_jsonrpc_response(id, result: result)
31
- end
32
- end
33
- end
34
- end
@@ -1,202 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- class JsonRpcHandler < JsonRpcHandlerBase
6
- attr_reader :client
7
-
8
- def initialize(transport, client)
9
- super(transport)
10
- @client = client
11
- end
12
-
13
- # Handle client-side JSON-RPC requests/responses
14
- # @param request [JSON_RPC::Request, JSON_RPC::Notification, JSON_RPC::Response]
15
- def call(request)
16
- case request
17
- when JSON_RPC::Request
18
- handle_request(request)
19
- when JSON_RPC::Notification
20
- handle_notification(request)
21
- when JSON_RPC::Response
22
- handle_response(request)
23
- end
24
- end
25
-
26
- private
27
-
28
- def handle_request(request)
29
- id = request.id
30
- rpc_method = request.method
31
- params = request.params
32
-
33
- handle_method(rpc_method, id, params)
34
- end
35
-
36
- def handle_notification(notification)
37
- # Handle server notifications to client
38
- client.log_debug("Received notification: #{notification.method}")
39
- end
40
-
41
- def handle_response(response)
42
- # Handle server responses to client requests
43
- client.log_debug("Received response: #{response.id} - #{response.result ? 'success' : 'error'}")
44
- end
45
-
46
- protected
47
-
48
- # Handle client-specific methods
49
- # @param rpc_method [String]
50
- # @param id [String, Integer]
51
- # @param params [Hash]
52
- def handle_method(rpc_method, id, params)
53
- case rpc_method
54
- when Methods::ELICITATION_CREATE
55
- client.process_elicitation_request(id, params)
56
- when %r{^roots/}
57
- process_roots(rpc_method, id)
58
- when %r{^sampling/}
59
- process_sampling(rpc_method, id, params)
60
- else
61
- common_result = handle_common_methods(rpc_method, id, params)
62
- client.log_warn("Unknown server method: #{rpc_method} #{id} #{params}") if common_result.nil?
63
- end
64
- end
65
-
66
- # @param rpc_method [String]
67
- # @param id [String]
68
- def process_roots(rpc_method, id)
69
- case rpc_method
70
- when "roots/list" # List available roots
71
- transport.send_roots_list(id)
72
- else
73
- Rails.logger.warn("Unknown roots method: #{rpc_method}")
74
- end
75
- end
76
-
77
- # @param rpc_method [String]
78
- # @param id [String]
79
- # @param params [Hash]
80
- def process_sampling(rpc_method, id, params)
81
- case rpc_method
82
- when "sampling/createMessage" # Create a message using AI
83
- # @param id [String]
84
- # @param params [SamplingRequest]
85
- transport.send_sampling_create_message(id, params)
86
- else
87
- Rails.logger.warn("Unknown sampling method: #{rpc_method}")
88
- end
89
- end
90
-
91
- # @param rpc_method [String]
92
- def process_notifications(rpc_method, params)
93
- case rpc_method
94
- when "notifications/resources/updated" # Resource update notification
95
- client.log_debug("Resource #{params['uri']} was updated")
96
- # Handle resource update notification
97
- # TODO: fetch updated resource or mark it as stale
98
- when "notifications/tools/list_changed" # Tool list change notification
99
- client.log_debug("Tool list has changed")
100
- # Handle tool list change notification
101
- # TODO: fetch new tools or mark them as stale
102
- when "notifications/prompts/list_changed" # Prompt list change notification
103
- client.log_debug("Prompt list has changed")
104
- # Handle prompt list change notification
105
- # TODO: fetch new prompts or mark them as stale
106
- when "notifications/resources/list_changed" # Resource list change notification
107
- client.log_debug("Resource list has changed")
108
- # Handle resource list change notification
109
- # TODO: fetch new resources or mark them as stale
110
- else
111
- super
112
- end
113
- end
114
-
115
- def process_response(id, result)
116
- # Check if this is a response to an initialize request
117
- # We need to check the actual request method, not just compare IDs
118
- request = client.session ? transport.messages.requests.find_by(jsonrpc_id: id) : nil
119
-
120
- # If no session yet, this might be the initialize response
121
- if !client.session && result["serverInfo"]
122
- handle_initialize_response(id, result)
123
- return send_initialized_notification
124
- end
125
-
126
- return unless request
127
-
128
- # Mark the request as acknowledged
129
- request.update(request_acknowledged: true)
130
-
131
- case request.rpc_method
132
- when "tools/list"
133
- client.toolbox.tools = result["tools"]
134
- client.toolbox.instance_variable_set(:@next_cursor, result["nextCursor"])
135
- client.toolbox.instance_variable_set(:@total, result["tools"]&.size || 0)
136
- return true
137
- when "prompts/list"
138
- client.prompt_book.prompts = result["prompts"]
139
- client.prompt_book.instance_variable_set(:@next_cursor, result["nextCursor"])
140
- client.prompt_book.instance_variable_set(:@total, result["prompts"]&.size || 0)
141
- return true
142
- when "resources/list"
143
- client.catalog.resources = result["resources"]
144
- client.catalog.instance_variable_set(:@next_cursor, result["nextCursor"])
145
- client.catalog.instance_variable_set(:@total, result["resources"]&.size || 0)
146
- return true
147
- when "resources/templates/list"
148
- client.blueprint.templates = result["resourceTemplates"]
149
- client.blueprint.instance_variable_set(:@next_cursor, result["nextCursor"])
150
- client.blueprint.instance_variable_set(:@total, result["resourceTemplates"]&.size || 0)
151
- return true
152
- end
153
-
154
- client.log_warn("Unknown response: #{id} #{result}")
155
- end
156
-
157
- def process_error(id, error)
158
- # Do something ?
159
- client.log_error("Unknown error: #{id} #{error}")
160
- end
161
-
162
- def handle_initialize_response(_request_id, result)
163
- # Session ID comes from HTTP headers, not the response body
164
- # The transport should have already extracted it
165
- session_id = transport.instance_variable_get(:@session_id)
166
-
167
- if session_id.nil?
168
- client.log_error("No session ID received from server")
169
- return
170
- end
171
-
172
- # Check if we're resuming an existing session
173
- if client.instance_variable_get(:@session_id) && session_id == client.instance_variable_get(:@session_id)
174
- # We're resuming an existing session
175
- client.instance_variable_set(:@session, ActionMCP::Session.find(session_id))
176
- client.log_info("Resumed existing session: #{session_id}")
177
- else
178
- # Create a new session with the server-provided ID
179
- client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
180
- id: session_id,
181
- protocol_version: result["protocolVersion"] || ActionMCP::DEFAULT_PROTOCOL_VERSION,
182
- client_info: client.client_info,
183
- client_capabilities: client.client_capabilities,
184
- server_info: result["serverInfo"],
185
- server_capabilities: result["capabilities"]
186
- ))
187
- client.session.save
188
- client.log_info("Created new session: #{session_id}")
189
- end
190
-
191
- # Set the server info
192
- client.server = Client::Server.new(result)
193
- client.instance_variable_set(:@initialized, true)
194
- end
195
-
196
- def send_initialized_notification
197
- transport.initialize! if transport.respond_to?(:initialize!)
198
- client.send_jsonrpc_notification("notifications/initialized")
199
- end
200
- end
201
- end
202
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- module Logging
6
- # Set the client's logging level
7
- # @param level [String] Logging level (debug, info, warning, error, etc.)
8
- # @return [Boolean] Success status
9
- def set_logging_level(level)
10
- request_id = SecureRandom.uuid_v7
11
-
12
- # Send request
13
- send_jsonrpc_request("client/setLoggingLevel",
14
- params: { level: level },
15
- id: request_id)
16
- end
17
- end
18
- end
19
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- module Messaging
6
- def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
7
- request = JSON_RPC::Request.new(id: id, method: method, params: params)
8
- write_message(request)
9
- end
10
-
11
- def send_jsonrpc_response(request_id, result: nil, error: nil)
12
- response = JSON_RPC::Response.new(id: request_id, result: result, error: error)
13
- write_message(response)
14
- end
15
-
16
- def send_jsonrpc_notification(method, params = nil)
17
- notification = JSON_RPC::Notification.new(method: method, params: params)
18
- write_message(notification)
19
- end
20
-
21
- def send_jsonrpc_error(request_id, symbol, message, data = nil)
22
- error = JSON_RPC::JsonRpcError.new(symbol, message:, data:)
23
- response = JSON_RPC::Response.new(id: request_id, error:)
24
- write_message(response)
25
- end
26
- end
27
- end
28
- end