actionmcp 0.20.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/action_mcp/messages_controller.rb +2 -2
  3. data/app/models/action_mcp/session/message.rb +21 -5
  4. data/app/models/action_mcp/session.rb +8 -4
  5. data/lib/action_mcp/capability.rb +2 -3
  6. data/lib/action_mcp/client/base.rb +222 -0
  7. data/lib/action_mcp/client/blueprint.rb +161 -0
  8. data/lib/action_mcp/client/catalog.rb +160 -0
  9. data/lib/action_mcp/client/collection.rb +93 -0
  10. data/lib/action_mcp/client/json_rpc_handler.rb +114 -0
  11. data/lib/action_mcp/client/logging.rb +20 -0
  12. data/lib/action_mcp/{transport → client}/messaging.rb +1 -1
  13. data/lib/action_mcp/client/prompt_book.rb +117 -0
  14. data/lib/action_mcp/client/prompts.rb +39 -0
  15. data/lib/action_mcp/client/request_timeouts.rb +76 -0
  16. data/lib/action_mcp/client/resources.rb +85 -0
  17. data/lib/action_mcp/client/roots.rb +13 -0
  18. data/lib/action_mcp/client/server.rb +60 -0
  19. data/lib/action_mcp/{transport → client}/sse_client.rb +70 -111
  20. data/lib/action_mcp/{transport → client}/stdio_client.rb +38 -38
  21. data/lib/action_mcp/client/toolbox.rb +194 -0
  22. data/lib/action_mcp/client/tools.rb +39 -0
  23. data/lib/action_mcp/client.rb +20 -231
  24. data/lib/action_mcp/engine.rb +1 -3
  25. data/lib/action_mcp/instrumentation/controller_runtime.rb +1 -1
  26. data/lib/action_mcp/instrumentation/instrumentation.rb +2 -0
  27. data/lib/action_mcp/instrumentation/resource_instrumentation.rb +1 -0
  28. data/lib/action_mcp/json_rpc_handler_base.rb +106 -0
  29. data/lib/action_mcp/log_subscriber.rb +2 -0
  30. data/lib/action_mcp/logging.rb +1 -1
  31. data/lib/action_mcp/{transport → server}/capabilities.rb +2 -2
  32. data/lib/action_mcp/server/json_rpc_handler.rb +121 -0
  33. data/lib/action_mcp/server/messaging.rb +28 -0
  34. data/lib/action_mcp/{transport → server}/notifications.rb +1 -1
  35. data/lib/action_mcp/{transport → server}/prompts.rb +1 -1
  36. data/lib/action_mcp/{transport → server}/resources.rb +1 -18
  37. data/lib/action_mcp/{transport → server}/roots.rb +1 -1
  38. data/lib/action_mcp/{transport → server}/sampling.rb +1 -1
  39. data/lib/action_mcp/server/sampling_request.rb +115 -0
  40. data/lib/action_mcp/{transport → server}/tools.rb +1 -1
  41. data/lib/action_mcp/server/transport_handler.rb +41 -0
  42. data/lib/action_mcp/uri_ambiguity_checker.rb +6 -10
  43. data/lib/action_mcp/version.rb +1 -1
  44. data/lib/action_mcp.rb +2 -1
  45. metadata +31 -33
  46. data/lib/action_mcp/base_json_rpc_handler.rb +0 -97
  47. data/lib/action_mcp/client_json_rpc_handler.rb +0 -69
  48. data/lib/action_mcp/json_rpc_handler.rb +0 -229
  49. data/lib/action_mcp/sampling_request.rb +0 -113
  50. data/lib/action_mcp/server_json_rpc_handler.rb +0 -90
  51. data/lib/action_mcp/transport/transport_base.rb +0 -126
  52. data/lib/action_mcp/transport_handler.rb +0 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86bd5d34d17ae66d5fcae2cf75c8231f78d6c08f88199f4835e42faed93e9589
4
- data.tar.gz: 239b2fc880942da6f7b4c1b6bf1b498ab12a7866d1bfb34e719bb7f46e206985
3
+ metadata.gz: 6b50744d4dcd16237d18d1b5c787f721e56281c82d1037c776201226a06cc60c
4
+ data.tar.gz: ed2b466652be17b4443508719da7b474b1bfc941047815f9a45bc4acf39ca2d6
5
5
  SHA512:
6
- metadata.gz: ded4b3ae254173fa102465c03e665606ebd1f8acea443e117a2190c1d1115ee53b30401e94c9b9842d469493002213b32a21e58612f74f46a4e0956885f8f4b6
7
- data.tar.gz: 730190f2a425916ea1c719cbdbdcd427ff606a0539b66e88226225f638d242f61c2663c9dcd0b38201ebe0856a4aa44ed3527f26399885e9658ee4319407c22d
6
+ metadata.gz: ba4f687a5368fb04a2f986b61859e19d05658c00e39dc2b600984db39f34fd3e0f56259e6d08c425d140c1f1641d55ee209ec0ae69109bde1e0ec5312b01cf57
7
+ data.tar.gz: 6daf198545188c392453e6a53b905fafd3aa6c84ab508114c563ef59ff1c8b0e44712d5c8c82dc8643497daef7136bc3c284baec482d9fd8193c3cf1a32d4fe0
@@ -17,11 +17,11 @@ module ActionMCP
17
17
  private
18
18
 
19
19
  def transport_handler
20
- TransportHandler.new(mcp_session)
20
+ Server::TransportHandler.new(mcp_session)
21
21
  end
22
22
 
23
23
  def json_rpc_handler
24
- @json_rpc_handler ||= ActionMCP::JsonRpcHandler.new(transport_handler)
24
+ @json_rpc_handler ||= Server::JsonRpcHandler.new(transport_handler)
25
25
  end
26
26
 
27
27
  def handle_post_message(params, response)
@@ -48,11 +48,15 @@ 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) }
55
55
 
56
+ scope :requests, -> { where(message_type: "request") }
57
+ scope :notifications, -> { where(message_type: "notification") }
58
+ scope :responses, -> { where(message_type: "response") }
59
+
56
60
  # @param payload [String, Hash]
57
61
  def data=(payload)
58
62
  @data = payload
@@ -92,6 +96,11 @@ module ActionMCP
92
96
  message_type == "response"
93
97
  end
94
98
 
99
+ def rpc_method
100
+ return false unless request?
101
+ data["method"]
102
+ end
103
+
95
104
  private
96
105
 
97
106
  def outgoing_message?
@@ -99,7 +108,9 @@ module ActionMCP
99
108
  end
100
109
 
101
110
  def broadcast_message
102
- adapter.broadcast(session_key, data.to_json)
111
+ if adapter.present?
112
+ adapter.broadcast(session_key, data.to_json)
113
+ end
103
114
  end
104
115
 
105
116
  def process_json_content(content)
@@ -125,17 +136,22 @@ module ActionMCP
125
136
  end
126
137
  end
127
138
 
128
- def handle_ping_response
139
+ def acknowledge_request
129
140
  return unless jsonrpc_id.present?
130
141
 
131
142
  request_message = session.messages.find_by(
132
143
  jsonrpc_id: jsonrpc_id,
133
144
  message_type: "request"
134
145
  )
135
- return unless request_message&.is_ping
136
146
 
137
- 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
138
153
  request_message.update(request_acknowledged: true)
154
+
139
155
  save! if changed?
140
156
  end
141
157
  end
@@ -46,8 +46,11 @@ module ActionMCP
46
46
  scope :closed, -> { where(status: "closed") }
47
47
  scope :without_messages, -> { includes(:messages).where(action_mcp_session_messages: { id: nil }) }
48
48
 
49
- before_create :set_server_info
50
- before_create :set_server_capabilities
49
+ scope :from_server, -> { where(role: "server") }
50
+ scope :from_client, -> { where(role: "client") }
51
+
52
+ before_create :set_server_info, if: -> { role == "server" }
53
+ before_create :set_server_capabilities, if: -> { role == "server" }
51
54
 
52
55
  validates :protocol_version, inclusion: { in: [ PROTOCOL_VERSION ] }, allow_nil: true
53
56
 
@@ -58,6 +61,7 @@ module ActionMCP
58
61
  subscriptions.delete_all # delete all subscriptions
59
62
  end
60
63
 
64
+ # MESSAGING dispatch
61
65
  def write(data)
62
66
  if data.is_a?(JsonRpc::Request) || data.is_a?(JsonRpc::Response) || data.is_a?(JsonRpc::Notification)
63
67
  data = data.to_json
@@ -100,8 +104,8 @@ module ActionMCP
100
104
  end
101
105
 
102
106
  def initialize!
103
- # update the session initialized to true if client_capabilities are present
104
- return unless client_capabilities.present?
107
+ # update the session initialized to true
108
+ return false if initialized?
105
109
 
106
110
  update!(initialized: true,
107
111
  status: "initialized")
@@ -23,8 +23,8 @@ module ActionMCP
23
23
  @abstract_capability ||= false # Default to false, unique to each class
24
24
  end
25
25
 
26
- def self.abstract_capability=(value)
27
- @abstract_capability = value
26
+ class << self
27
+ attr_writer :abstract_capability
28
28
  end
29
29
 
30
30
  # Marks this tool as abstract so that it won’t be available for use.
@@ -49,6 +49,5 @@ module ActionMCP
49
49
  _description
50
50
  end
51
51
  end
52
- ActiveSupport.run_load_hooks(:active_mcp, self)
53
52
  end
54
53
  end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Base client class containing common MCP functionality
6
+ class Base
7
+ # Include all transport protocol modules
8
+ include Messaging
9
+ include Tools
10
+ include Prompts
11
+ include Resources
12
+ include Roots
13
+ include Logging
14
+
15
+ attr_reader :logger, :type,
16
+ :connection_error, :server,
17
+ :server_capabilities, :session,
18
+ :catalog, :blueprint,
19
+ :prompt_book, :toolbox
20
+ delegate :initialized?, to: :session
21
+
22
+ def initialize(logger: ActionMCP.logger)
23
+ @logger = logger
24
+ @connected = false
25
+ @session = Session.from_client.new(
26
+ protocol_version: PROTOCOL_VERSION,
27
+ client_info: client_info,
28
+ client_capabilities: client_capabilities
29
+ )
30
+ @server_capabilities = nil
31
+ @message_callback = nil
32
+ @error_callback = nil
33
+ @connection_error = nil
34
+ @initialized = false
35
+
36
+ # Resource objects
37
+ @catalog = Catalog.new([], self)
38
+ # Resource template objects
39
+ @blueprint = Blueprint.new([], self)
40
+ # Prompt objects
41
+ @prompt_book = PromptBook.new([], self)
42
+ # Tool objects
43
+ @toolbox = Toolbox.new([], self)
44
+ end
45
+
46
+ def connected?
47
+ @connected
48
+ end
49
+
50
+ # Connect to the MCP server, if something went wrong at initialization
51
+ def connect
52
+ return true if @connected
53
+
54
+ begin
55
+ log_debug("Connecting to MCP server...")
56
+ @connection_error = nil
57
+
58
+ # Start transport with proper error handling
59
+ success = start_transport
60
+
61
+ unless success
62
+ log_error("Failed to establish connection to MCP server")
63
+ return false
64
+ end
65
+
66
+ @connected = true
67
+ log_debug("Connected to MCP server")
68
+
69
+ # Create handler only if it doesn't exist yet
70
+ @json_rpc_handler ||= JsonRpcHandler.new(session, self)
71
+
72
+ # Clear any existing message callback and set a new one
73
+ @message_callback = lambda do |response|
74
+ @json_rpc_handler.call(response)
75
+ end
76
+
77
+ true
78
+ rescue StandardError => e
79
+ @connection_error = e.message
80
+ log_error("Failed to connect to MCP server: #{e.message}")
81
+ @error_callback&.call(e)
82
+ false
83
+ end
84
+ end
85
+
86
+ # Disconnect from the MCP server
87
+ def disconnect
88
+ return true unless @connected
89
+
90
+ begin
91
+ stop_transport
92
+ @connected = false
93
+ log_debug("Disconnected from MCP server")
94
+ true
95
+ rescue StandardError => e
96
+ log_error("Error disconnecting from MCP server: #{e.message}")
97
+ @error_callback&.call(e)
98
+ false
99
+ end
100
+ end
101
+
102
+ # Set a callback for incoming messages
103
+ def on_message(&block)
104
+ @message_callback = block
105
+ end
106
+
107
+ # Set a callback for errors
108
+ def on_error(&block)
109
+ @error_callback = block
110
+ end
111
+
112
+ # Send a request to the MCP server
113
+ def write_message(payload)
114
+ unless @connected
115
+ log_error("Cannot send request - not connected")
116
+ return false
117
+ end
118
+
119
+ begin
120
+ session.write(payload)
121
+ data = payload.to_json unless payload.is_a?(String)
122
+ send_message(data)
123
+ true
124
+ rescue StandardError => e
125
+ log_error("Failed to send request: #{e.message}")
126
+ @error_callback&.call(e)
127
+ false
128
+ end
129
+ end
130
+
131
+ # Methods to be implemented by subclasses
132
+ def start_transport
133
+ raise NotImplementedError, "#{self.class} must implement #start_transport"
134
+ end
135
+
136
+ def stop_transport
137
+ raise NotImplementedError, "#{self.class} must implement #stop_transport"
138
+ end
139
+
140
+ def send_message(json)
141
+ raise NotImplementedError, "#{self.class} must implement #send_message"
142
+ end
143
+
144
+ def ready?
145
+ raise NotImplementedError, "#{self.class} must implement #ready?"
146
+ end
147
+
148
+ def server=(server)
149
+ if server.is_a?(Client::Server)
150
+ @server = server
151
+ else
152
+ @server = Client::Server.new(server)
153
+ end
154
+ session.server_capabilities = server.capabilities
155
+ session.server_info = server.server_info
156
+ session.save
157
+ server
158
+ end
159
+
160
+ def inspect
161
+ "#<#{self.class.name} server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities} , connected: #{connected?}, initialized: #{initialized?}, session: #{session.id}>"
162
+ end
163
+
164
+ protected
165
+
166
+ def handle_raw_message(raw)
167
+ begin
168
+ @message_callback&.call(raw)
169
+ rescue MultiJson::ParseError => e
170
+ log_error("JSON parse error: #{e} (raw: #{raw})")
171
+ @error_callback&.call(e)
172
+ rescue StandardError => e
173
+ log_error("Error handling message: #{e} (raw: #{raw})")
174
+ @error_callback&.call(e)
175
+ end
176
+ end
177
+
178
+ def send_initial_capabilities
179
+ log_debug("Sending client capabilities")
180
+ # We have contact! Let's send our CV to the recruiter.
181
+ # We persist the session object to the database
182
+ session.save
183
+ params= {
184
+ protocolVersion: session.protocol_version,
185
+ capabilities: session.client_capabilities,
186
+ clientInfo: session.client_info
187
+ }
188
+ send_jsonrpc_request("initialize", params: params, id: session.id)
189
+ end
190
+
191
+ def client_capabilities
192
+ {
193
+ # Base client capabilities can be defined here
194
+ # TODO
195
+ }
196
+ end
197
+
198
+ def user_agent
199
+ "ActionMCP-Client"
200
+ end
201
+
202
+ def client_info
203
+ {
204
+ name: user_agent,
205
+ version: ActionMCP.gem_version.to_s
206
+ }
207
+ end
208
+
209
+ def log_debug(message)
210
+ logger.debug("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
211
+ end
212
+
213
+ def log_info(message)
214
+ logger.info("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
215
+ end
216
+
217
+ def log_error(message)
218
+ logger.error("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Blueprints
6
+ #
7
+ # A collection that manages and provides access to URI templates (blueprints) for Model Context Protocol (MCP)
8
+ # resource discovery. These blueprints allow dynamic construction of resource URIs by filling in
9
+ # variable placeholders with specific values. The class supports lazy loading of templates when
10
+ # initialized with a client.
11
+ #
12
+ # Example usage:
13
+ # # Eager loading
14
+ # template_data = client.list_resource_templates # Returns array of URI template definitions
15
+ # blueprints = Blueprint.new(template_data)
16
+ #
17
+ # # Lazy loading
18
+ # blueprints = Blueprint.new([], client)
19
+ # templates = blueprints.all # Templates are loaded here
20
+ #
21
+ # # Access a specific blueprint by pattern
22
+ # file_blueprint = Blueprint.find_by_pattern("file://{path}")
23
+ #
24
+ # # Generate a concrete URI from a blueprint with parameters
25
+ # uri = Blueprint.construct("file://{path}", { path: "/logs/app.log" })
26
+ #
27
+ class Blueprint < Collection
28
+ # Initialize a new Blueprints collection with URI template definitions
29
+ #
30
+ # @param templates [Array<Hash>] Array of URI template definition hashes, each containing
31
+ # uriTemplate, name, description, and optionally mimeType keys
32
+ # @param client [Object, nil] Optional client for lazy loading of templates
33
+ def initialize(templates, client)
34
+ super(templates, client)
35
+ self.templates = @collection_data
36
+ @load_method = :list_resource_templates
37
+ end
38
+
39
+ # Find a blueprint by its URI pattern
40
+ #
41
+ # @param pattern [String] URI template pattern to find
42
+ # @return [Blueprint, nil] The blueprint with the given pattern, or nil if not found
43
+ def find_by_pattern(pattern)
44
+ all.find { |blueprint| blueprint.pattern == pattern }
45
+ end
46
+
47
+ # Find blueprints by name
48
+ #
49
+ # @param name [String] Name of the blueprints to find
50
+ # @return [Array<Blueprint>] Blueprints with the given name
51
+ def find_by_name(name)
52
+ all.select { |blueprint| blueprint.name == name }
53
+ end
54
+
55
+ # Construct a concrete URI by applying parameters to a blueprint
56
+ #
57
+ # @param pattern [String] URI template pattern to use
58
+ # @param params [Hash] Parameters to substitute into the pattern
59
+ # @return [String] The constructed URI with parameters applied
60
+ # @raise [KeyError] If a required parameter is missing
61
+ def construct(pattern, params)
62
+ blueprint = find_by_pattern(pattern)
63
+ raise ArgumentError, "Unknown blueprint pattern: #{pattern}" unless blueprint
64
+
65
+ blueprint.construct(params)
66
+ end
67
+
68
+ # Check if the collection contains a blueprint with the given pattern
69
+ #
70
+ # @param pattern [String] The blueprint pattern to check for
71
+ # @return [Boolean] true if a blueprint with the pattern exists
72
+ def contains?(pattern)
73
+ all.any? { |blueprint| blueprint.pattern == pattern }
74
+ end
75
+
76
+ # Group blueprints by their base protocol
77
+ #
78
+ # @return [Hash<String, Array<Blueprint>>] Hash mapping protocols to arrays of blueprints
79
+ def group_by_protocol
80
+ all.group_by(&:protocol)
81
+ end
82
+
83
+ # Convert raw template data into ResourceTemplate objects
84
+ #
85
+ # @param templates [Array<Hash>] Array of template definition hashes
86
+ def templates=(templates)
87
+ @collection_data = templates.map { |template_data| ResourceTemplate.new(template_data) }
88
+ end
89
+
90
+ # Internal Blueprint class to represent individual URI templates
91
+ class ResourceTemplate
92
+ attr_reader :pattern, :name, :description, :mime_type
93
+
94
+ # Initialize a new ResourceTemplate instance
95
+ #
96
+ # @param data [Hash] ResourceTemplate definition hash containing uriTemplate, name, description,
97
+ # and optionally mimeType
98
+ def initialize(data)
99
+ @pattern = data["uriTemplate"]
100
+ @name = data["name"]
101
+ @description = data["description"]
102
+ @mime_type = data["mimeType"]
103
+ @variable_pattern = /{([^}]+)}/
104
+ end
105
+
106
+ # Extract variable names from the template pattern
107
+ #
108
+ # @return [Array<String>] List of variable names in the pattern
109
+ def variables
110
+ @pattern.scan(@variable_pattern).flatten
111
+ end
112
+
113
+ # Get the protocol part of the URI template
114
+ #
115
+ # @return [String] The protocol (scheme) of the URI template
116
+ def protocol
117
+ @pattern.split("://").first
118
+ end
119
+
120
+ # Construct a concrete URI by substituting parameters into the template pattern
121
+ #
122
+ # @param params [Hash] Parameters to substitute into the pattern
123
+ # @return [String] The constructed URI with parameters applied
124
+ # @raise [KeyError] If a required parameter is missing
125
+ def construct(params)
126
+ result = @pattern.dup
127
+
128
+ variables.each do |var|
129
+ raise KeyError, "Missing required parameter: #{var}" unless params.key?(var.to_sym) || params.key?(var)
130
+
131
+ value = params[var.to_sym] || params[var]
132
+ result.gsub!("{#{var}}", value.to_s)
133
+ end
134
+
135
+ result
136
+ end
137
+
138
+ # Check if this template is compatible with a set of parameters
139
+ #
140
+ # @param params [Hash] Parameters to check
141
+ # @return [Boolean] true if all required variables have corresponding parameters
142
+ def compatible_with?(params)
143
+ symbolized_params = params.transform_keys(&:to_sym)
144
+ variables.all? { |var| symbolized_params.key?(var.to_sym) }
145
+ end
146
+
147
+ # Generate a hash representation of the blueprint
148
+ #
149
+ # @return [Hash] Hash containing blueprint details
150
+ def to_h
151
+ {
152
+ "uriTemplate" => @pattern,
153
+ "name" => @name,
154
+ "description" => @description,
155
+ "mimeType" => @mime_type
156
+ }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,160 @@
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 && 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
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
+ end
117
+
118
+ # Get the file extension from the resource name
119
+ #
120
+ # @return [String, nil] The file extension or nil if no extension
121
+ def extension
122
+ File.extname(@name)[1..-1] if @name.include?(".")
123
+ end
124
+
125
+ # Check if this resource is a text file based on MIME type
126
+ #
127
+ # @return [Boolean] true if the resource is a text file
128
+ def text?
129
+ @mime_type&.start_with?("text/")
130
+ end
131
+
132
+ # Check if this resource is an image based on MIME type
133
+ #
134
+ # @return [Boolean] true if the resource is an image
135
+ def image?
136
+ @mime_type&.start_with?("image/")
137
+ end
138
+
139
+ # Get the path portion of the URI
140
+ #
141
+ # @return [String, nil] The path component of the URI
142
+ def path
143
+ URI(@uri).path rescue nil
144
+ end
145
+
146
+ # Generate a hash representation of the resource
147
+ #
148
+ # @return [Hash] Hash containing resource details
149
+ def to_h
150
+ {
151
+ "uri" => @uri,
152
+ "name" => @name,
153
+ "description" => @description,
154
+ "mimeType" => @mime_type
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end