actionmcp 0.5.1 → 0.7.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: 2a6e51c000b677de70fd80f03788ba8334fc9e65bf7ad9344aea0bab75aebbdf
4
- data.tar.gz: 3205256c52a5bf43316ac17c1c50634df3afb138c72b68994db071ac8a15722f
3
+ metadata.gz: 328575eb20b901f6a8b7aa2caf13b1a23c1bd2021eb74408d2f36314e0fda829
4
+ data.tar.gz: da1a9c14363f3d9104cecc61af3e4dac402d623a0054c1b4318cc54e16a0dd0a
5
5
  SHA512:
6
- metadata.gz: af4c15acb851edb2906b9a6b164ed0abf54bb2cb2ad96752c907779f51d805c7a8835775e569b367341fa1d219a74581283680876fc261ab179ce782d5fc677f
7
- data.tar.gz: fc5953ed45ceeea4ae7038134bea63b6fee0ed36e90bb9f3bd1b1bdb6397f6a3ac7e5875f85f458fae4155135405e481f78c24df37d63a5b565bdc2d40e0706e
6
+ metadata.gz: dcdd5da1fa8d6e48f373cd6ae1219b80870aae0d965456f4a6ab9be837dea71245604d6e2f2f7d2a5cd4066ac04d672283c7415f507a50c0a93a0f8c38a65c7f
7
+ data.tar.gz: a09c09d16754784de58995065089fce4fadea61df562358485ea794998cda9662f22dad89fff72cd313906350da22927b24584dfa761d4dc28134e9a82f0040c
@@ -27,7 +27,7 @@ module ActionMCP
27
27
  # Heartbeat loop
28
28
  until response.stream.closed?
29
29
  sleep HEARTBEAT_INTERVAL
30
- mcp_session.send_ping!
30
+ # mcp_session.send_ping!
31
31
  end
32
32
  else
33
33
  Rails.logger.error "Listener failed to activate for session: #{session_id}"
@@ -14,6 +14,11 @@ module ActionMCP
14
14
  attr_reader :data
15
15
 
16
16
  after_create_commit :broadcast_message, if: :outgoing_message?
17
+ # Set is_ping on responses if the original request was a ping
18
+ after_create :handle_ping_response, if: -> { %w[response error].include?(message_type) }
19
+
20
+ # Scope to exclude both "ping" requests and their responses
21
+ scope :without_pings, -> { where(is_ping: false) }
17
22
 
18
23
  # @param payload [String, Hash]
19
24
  def data=(payload)
@@ -22,17 +27,14 @@ module ActionMCP
22
27
  # Store original version and attempt to determine type
23
28
  if payload.is_a?(String)
24
29
  self.message_text = payload
25
-
26
30
  begin
27
31
  parsed_json = MultiJson.load(payload)
28
32
  self.message_json = parsed_json
29
33
  process_json_content(parsed_json)
30
34
  rescue MultiJson::ParseError
31
- # Not valid JSON, just store as text
32
35
  self.message_type = "text"
33
36
  end
34
37
  else
35
- # Handle Hash or other JSON-serializable input
36
38
  self.message_json = payload
37
39
  self.message_text = MultiJson.dump(payload)
38
40
  process_json_content(payload)
@@ -43,7 +45,7 @@ module ActionMCP
43
45
  message_json.presence || message_text
44
46
  end
45
47
 
46
- # Helper method to check if message is a particular type
48
+ # Helper methods
47
49
  def request?
48
50
  message_type == "request"
49
51
  end
@@ -67,19 +69,20 @@ module ActionMCP
67
69
  end
68
70
 
69
71
  def process_json_content(content)
70
- # Determine message type based on JSON-RPC spec
71
72
  if content.is_a?(Hash) && content["jsonrpc"] == "2.0"
72
73
  if content.key?("id") && content.key?("method")
73
74
  self.message_type = "request"
74
- self.jsonrpc_id = content["id"]
75
+ self.jsonrpc_id = content["id"].to_s
76
+ # Set is_ping to true if the method is "ping"
77
+ self.is_ping = true if content["method"] == "ping"
75
78
  elsif content.key?("method") && !content.key?("id")
76
79
  self.message_type = "notification"
77
80
  elsif content.key?("id") && content.key?("result")
78
81
  self.message_type = "response"
79
- self.jsonrpc_id = content["id"]
82
+ self.jsonrpc_id = content["id"].to_s
80
83
  elsif content.key?("id") && content.key?("error")
81
84
  self.message_type = "error"
82
- self.jsonrpc_id = content["id"]
85
+ self.jsonrpc_id = content["id"].to_s
83
86
  else
84
87
  self.message_type = "invalid_jsonrpc"
85
88
  end
@@ -87,5 +90,18 @@ module ActionMCP
87
90
  self.message_type = "non_jsonrpc_json"
88
91
  end
89
92
  end
93
+
94
+ def handle_ping_response
95
+ return unless jsonrpc_id.present?
96
+ request_message = session.messages.find_by(
97
+ jsonrpc_id: jsonrpc_id,
98
+ message_type: "request"
99
+ )
100
+ if request_message&.is_ping
101
+ self.is_ping = true
102
+ request_message.update(ping_acknowledged: true)
103
+ save! if changed?
104
+ end
105
+ end
90
106
  end
91
107
  end
@@ -73,7 +73,7 @@ module ActionMCP
73
73
  end
74
74
 
75
75
  def message_flow
76
- messages.order(created_at: :asc).map do |message|
76
+ messages.without_pings.order(created_at: :asc).map do |message|
77
77
  {
78
78
  direction: message.direction,
79
79
  data: message.data,
@@ -83,7 +83,9 @@ module ActionMCP
83
83
  end
84
84
 
85
85
  def send_ping!
86
- write(JsonRpc::Request.new(id: Time.now.to_i, method: "ping"))
86
+ Session.logger.silence do
87
+ write(JsonRpc::Request.new(id: Time.now.to_i, method: "ping"))
88
+ end
87
89
  end
88
90
 
89
91
  private
@@ -0,0 +1,6 @@
1
+ class AddIsPingToSessionMessage < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false
4
+ add_column :action_mcp_session_messages, :ping_acknowledged, :boolean, default: false, null: false
5
+ end
6
+ end
@@ -33,7 +33,7 @@ module ActionMCP
33
33
  resource_data[:text] = @text if @text
34
34
  resource_data[:blob] = @blob if @blob
35
35
 
36
- super.merge(resource: resource_data)
36
+ resource_data
37
37
  end
38
38
  end
39
39
  end
@@ -73,7 +73,7 @@ module ActionMCP
73
73
  # Returns a hash formatted for a JSON-RPC error response.
74
74
  #
75
75
  # @return [Hash] The error hash.
76
- def as_json
76
+ def to_h
77
77
  hash = { code: code, message: message }
78
78
  hash[:data] = data if data
79
79
  hash
@@ -84,7 +84,7 @@ module ActionMCP
84
84
  # @param _args [Array] Arguments passed to MultiJson.dump.
85
85
  # @return [String] The JSON string.
86
86
  def to_json(*_args)
87
- MultiJson.dump(as_json, *args)
87
+ MultiJson.dump(to_h, *args)
88
88
  end
89
89
  end
90
90
  end
@@ -3,6 +3,27 @@
3
3
  module ActionMCP
4
4
  # Module for rendering content.
5
5
  module Renderable
6
+ # Renders content for Model Context Protocol responses.
7
+ #
8
+ # @param text [String, nil] Text content to render
9
+ # @param audio [String, nil] Audio content to render
10
+ # @param image [String, nil] Image content to render
11
+ # @param resource [String, nil] Resource content to render
12
+ # @param error [Array, nil] Array of error messages to render
13
+ # @param mime_type [String, nil] MIME type for audio, image, or resource content
14
+ # @param uri [String, nil] URI for resource content
15
+ # @param blob [String, nil] Binary data for resource content
16
+ #
17
+ # @return [Content::Text, Content::Audio, Content::Image, Content::Resource, Hash]
18
+ # The rendered content object or error hash
19
+ #
20
+ # @raise [ArgumentError] If no valid content parameters are provided
21
+ #
22
+ # @example Render text content
23
+ # render(text: "Hello, world!")
24
+ #
25
+ # @example Render an error
26
+ # render(error: ["Invalid input", "Please try again"])
6
27
  def render(text: nil, audio: nil, image: nil, resource: nil, error: nil, mime_type: nil, uri: nil, blob: nil)
7
28
  if text
8
29
  Content::Text.new(text)
@@ -1,8 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_model"
4
+
3
5
  module ActionMCP
4
6
  class ResourceTemplate
7
+ # Add ActiveModel capabilities
8
+ include ActiveModel::Model
9
+ include ActiveModel::Validations
10
+
11
+ # Track all registered templates
12
+ @registered_templates = []
13
+
5
14
  class << self
15
+ attr_reader :registered_templates
16
+
6
17
  def abstract?
7
18
  @abstract ||= false
8
19
  end
@@ -14,15 +25,31 @@ module ActionMCP
14
25
  def inherited(subclass)
15
26
  super
16
27
  subclass.instance_variable_set(:@abstract, false)
28
+ # Create a copy of validation requirements for subclasses
29
+ subclass.instance_variable_set(:@required_parameters, [])
17
30
  end
18
31
 
19
32
  attr_reader :description, :uri_template, :mime_type, :template_name, :parameters
20
33
 
21
- def parameter(name, description:, required: false)
34
+ # Track required parameters for validation
35
+ def required_parameters
36
+ @required_parameters ||= []
37
+ end
38
+
39
+ def parameter(name, description:, required: false, **options)
22
40
  @parameters ||= {}
23
- @parameters[name] = { description: description, required: required }
41
+ @parameters[name] = { description: description, required: required, **options }
42
+
43
+ # Define attribute accessor if not already defined
44
+ attr_accessor name unless method_defined?(name) && method_defined?("#{name}=")
45
+
46
+ # Track required parameters for validation
47
+ required_parameters << name if required
24
48
  end
25
49
 
50
+ # Alias parameter to attribute for clarity
51
+ alias_method :attribute, :parameter
52
+
26
53
  def parameters
27
54
  @parameters || {}
28
55
  end
@@ -32,7 +59,14 @@ module ActionMCP
32
59
  end
33
60
 
34
61
  def uri_template(value = nil)
35
- value ? @uri_template = value : @uri_template
62
+ if value
63
+ validate_unique_uri_template(value)
64
+ @uri_template = value
65
+ # Only register if not abstract and uri_template is set
66
+ ResourceTemplate.registered_templates << self unless abstract?
67
+ else
68
+ @uri_template
69
+ end
36
70
  end
37
71
 
38
72
  def template_name(value = nil)
@@ -53,16 +87,166 @@ module ActionMCP
53
87
  mimeType: @mime_type
54
88
  }.compact
55
89
  end
90
+ def capability_name
91
+ @capability_name ||= name.demodulize.underscore.sub(/_template$/, "")
92
+ end
93
+
94
+ # Process a URI string to create a template instance
95
+ def process(uri_string)
96
+ return nil unless @uri_template
97
+
98
+ # Extract parameters from URI using pattern matching
99
+ params = extract_params_from_uri(uri_string)
100
+ return new if params.nil? # Return invalid template for bad URI
56
101
 
57
- def retrieve(_params)
58
- raise NotImplementedError, "Subclasses must implement the retrieve method"
102
+ # Create new instance with the extracted parameters
103
+ new(params)
59
104
  end
60
105
 
61
- def capability_name
62
- name.demodulize.underscore.sub(/_template$/, "")
106
+ private
107
+
108
+ # Extract parameters from a URI using the template pattern
109
+ def extract_params_from_uri(uri_string)
110
+ # Convert template parameters to named capture groups
111
+ regex_parts = []
112
+ current_pos = 0
113
+ param_names = []
114
+
115
+ # Find all template parameters like {param_name}
116
+ @uri_template.scan(/\{([^}]+)\}/) do |param_name|
117
+ param_names << param_name[0]
118
+
119
+ # Get the position of this parameter in the template
120
+ param_start = @uri_template.index("{#{param_name[0]}}", current_pos)
121
+
122
+ # Add the text before the parameter (escaped)
123
+ if param_start > current_pos
124
+ prefix = Regexp.escape(@uri_template[current_pos...param_start])
125
+ regex_parts << prefix
126
+ end
127
+
128
+ # Add the named capture group
129
+ regex_parts << "(?<#{param_name[0]}>[^/]+)"
130
+
131
+ # Update current position
132
+ current_pos = param_start + param_name[0].length + 2 # +2 for { and }
133
+ end
134
+
135
+ # Add any remaining text after the last parameter
136
+ if current_pos < @uri_template.length
137
+ suffix = Regexp.escape(@uri_template[current_pos..-1])
138
+ regex_parts << suffix
139
+ end
140
+
141
+ # Build the final regex
142
+ regex_pattern = regex_parts.join
143
+ regex = Regexp.new("^#{regex_pattern}$")
144
+
145
+ # Try to match the URI
146
+ match_data = regex.match(uri_string)
147
+ return nil unless match_data
148
+
149
+ # Extract named captures as parameters
150
+ params = {}
151
+ param_names.each do |name|
152
+ params[name.to_sym] = match_data[name] if match_data[name]
153
+ end
154
+
155
+ params
156
+ end
157
+
158
+ # Extract the schema and pattern from a URI template
159
+ def parse_uri_template(template)
160
+ # Parse the URI template to get schema and pattern
161
+ # Format: schema://path/{param1}/{param2}...
162
+ if template =~ /^([^:]+):\/\/(.+)$/
163
+ schema = $1
164
+ pattern = $2
165
+
166
+ # Replace parameter placeholders with generic markers to compare structure
167
+ normalized_pattern = pattern.gsub(/\{[^}]+\}/, "{param}")
168
+
169
+ return { schema: schema, pattern: normalized_pattern, original: template }
170
+ end
171
+
172
+ raise ArgumentError, "Invalid URI template format: #{template}"
173
+ end
174
+
175
+ # Validate that the URI template is unique
176
+ def validate_unique_uri_template(new_template)
177
+ new_template_data = parse_uri_template(new_template)
178
+
179
+ ResourceTemplate.registered_templates.each do |registered_class|
180
+ next if registered_class == self || registered_class.abstract?
181
+ next unless registered_class.uri_template
182
+
183
+ existing_template_data = parse_uri_template(registered_class.uri_template)
184
+
185
+ # Check if schema and structure are the same
186
+ if new_template_data[:schema] == existing_template_data[:schema] &&
187
+ are_potentially_ambiguous?(new_template_data[:pattern], existing_template_data[:pattern])
188
+
189
+ # Use a consistent error message format for all conflicts
190
+ raise ArgumentError, "URI template conflict detected: '#{new_template}' conflicts with existing template '#{registered_class.uri_template}' registered by #{registered_class.name}"
191
+ end
192
+ end
193
+ end
194
+
195
+ # Determine if two normalized patterns could be ambiguous
196
+ def are_potentially_ambiguous?(pattern1, pattern2)
197
+ # If the patterns are exactly the same, they're definitely ambiguous
198
+ return true if pattern1 == pattern2
199
+
200
+ # Split into segments to compare structure
201
+ segments1 = pattern1.split("/")
202
+ segments2 = pattern2.split("/")
203
+
204
+ # If different number of segments, they can't be ambiguous
205
+ return false if segments1.size != segments2.size
206
+
207
+ # Count parameter segments
208
+ param_segments1 = segments1.count { |s| s.include?("{param}") }
209
+ param_segments2 = segments2.count { |s| s.include?("{param}") }
210
+
211
+ # If they have different number of parameter segments, they're not ambiguous
212
+ return false if param_segments1 != param_segments2
213
+
214
+ # If we have the same number of segments and same number of parameters,
215
+ # but the patterns aren't identical, they could be ambiguous
216
+ # due to parameter position swapping
217
+ if param_segments1 > 0 && param_segments1 == param_segments2
218
+ # Create pattern maps (P for param, S for static)
219
+ pattern_map1 = segments1.map { |s| s.include?("{param}") ? "P" : "S" }
220
+ pattern_map2 = segments2.map { |s| s.include?("{param}") ? "P" : "S" }
221
+
222
+ # If pattern maps are different but have same param count, potentially ambiguous
223
+ return pattern_map1 != pattern_map2
224
+ end
225
+
226
+ false
227
+ end
228
+ end
229
+
230
+ # Initialize with attribute values
231
+ def initialize(attributes = {})
232
+ super(attributes)
233
+ validate!
234
+ end
235
+
236
+ # Override validate! to not raise exceptions
237
+ def validate!
238
+ valid?
239
+ end
240
+
241
+ # Add custom validation for required parameters
242
+ validate do |template|
243
+ self.class.required_parameters.each do |param|
244
+ if self.send(param).nil? || self.send(param).to_s.empty?
245
+ errors.add(param, "can't be blank")
246
+ end
63
247
  end
64
248
  end
65
249
 
66
250
  attr_reader :description, :uri_template, :mime_type
67
251
  end
68
- end
252
+ end
@@ -21,6 +21,139 @@ module ActionMCP
21
21
  def item_klass
22
22
  ResourceTemplate
23
23
  end
24
+
25
+ # Find the most specific template for a given URI
26
+ def find_template_for_uri(uri)
27
+ parse_result = parse_uri(uri)
28
+ return nil unless parse_result
29
+
30
+ schema = parse_result[:schema]
31
+ path = parse_result[:path]
32
+ path_segments = path.split("/")
33
+
34
+ matching_templates = ResourceTemplate.registered_templates.select do |template|
35
+ next unless template.uri_template
36
+
37
+ # Parse the template
38
+ template_data = parse_uri_template(template.uri_template)
39
+ next unless template_data && template_data[:schema] == schema
40
+
41
+ # Split template into segments and check if structure matches
42
+ template_segments = template_data[:path].split("/")
43
+ next unless template_segments.length == path_segments.length
44
+
45
+ # Check if each segment matches (either static match or a parameter)
46
+ segments_match = true
47
+
48
+ template_segments.each_with_index do |template_segment, index|
49
+ path_segment = path_segments[index]
50
+
51
+ if template_segment.start_with?("{") && template_segment.end_with?("}")
52
+ # This is a parameter segment, it matches any value
53
+ next
54
+ elsif template_segment != path_segment
55
+ # Static segment doesn't match
56
+ segments_match = false
57
+ break
58
+ end
59
+ end
60
+
61
+ segments_match
62
+ end
63
+
64
+ # If multiple templates match, select the most specific one
65
+ # (the one with the most static segments)
66
+ if matching_templates.size > 1
67
+ matching_templates.max_by do |template|
68
+ template_data = parse_uri_template(template.uri_template)
69
+ template_segments = template_data[:path].split("/")
70
+
71
+ # Count static segments (not parameters)
72
+ template_segments.count { |segment| !segment.start_with?("{") }
73
+ end
74
+ elsif matching_templates.size == 1
75
+ matching_templates.first
76
+ else
77
+ nil
78
+ end
79
+ end
80
+
81
+ # Check if a URI matches a specific template
82
+ def uri_matches_template?(uri, template)
83
+ uri_data = parse_uri(uri)
84
+ template_data = parse_uri_template(template.uri_template)
85
+
86
+ return false unless uri_data && template_data && uri_data[:schema] == template_data[:schema]
87
+
88
+ uri_segments = uri_data[:path].split("/")
89
+ template_segments = template_data[:path].split("/")
90
+
91
+ return false unless uri_segments.length == template_segments.length
92
+
93
+ # Check each segment
94
+ template_segments.each_with_index do |template_segment, index|
95
+ uri_segment = uri_segments[index]
96
+
97
+ # If template segment is a parameter, it matches anything
98
+ next if template_segment.start_with?("{") && template_segment.end_with?("}")
99
+
100
+ # Otherwise, segments must match exactly
101
+ return false if template_segment != uri_segment
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ # Extract parameter values from a URI based on a template
108
+ def extract_parameters(uri, template)
109
+ return {} unless uri_matches_template?(uri, template)
110
+
111
+ uri_data = parse_uri(uri)
112
+ template_data = parse_uri_template(template.uri_template)
113
+
114
+ uri_segments = uri_data[:path].split("/")
115
+ template_segments = template_data[:path].split("/")
116
+
117
+ # Extract parameters
118
+ params = {}
119
+ template_segments.each_with_index do |template_segment, index|
120
+ if template_segment.start_with?("{") && template_segment.end_with?("}")
121
+ # Extract parameter name without braces
122
+ param_name = template_segment[1...-1].to_sym
123
+ params[param_name] = uri_segments[index]
124
+ end
125
+ end
126
+
127
+ params
128
+ end
129
+
130
+ private
131
+
132
+ # Parse a concrete URI
133
+ def parse_uri(uri)
134
+ if uri =~ /^([^:]+):\/\/(.+)$/
135
+ {
136
+ schema: $1,
137
+ path: $2,
138
+ original: uri
139
+ }
140
+ else
141
+ nil
142
+ end
143
+ end
144
+
145
+ # Parse a URI template
146
+ def parse_uri_template(template)
147
+ if template =~ /^([^:]+):\/\/(.+)$/
148
+ {
149
+ schema: $1,
150
+ path: $2,
151
+ original: template
152
+ }
153
+ else
154
+ nil
155
+ end
156
+ end
24
157
  end
25
158
  end
26
159
  end
@@ -16,22 +16,46 @@ module ActionMCP
16
16
  # @param _metadata [Hash] Optional metadata.
17
17
  # @return [Hash] A hash containing the tool's response.
18
18
  def tool_call(tool_name, arguments, _metadata = {})
19
- tool = find(tool_name)
20
- tool = tool.new(arguments)
21
- tool.validate
22
- if tool.valid?
23
- { content: [ tool.call ] }
24
- else
25
- {
26
- content: tool.errors.full_messages.map { |msg| Content::Text.new(msg) },
27
- isError: true
28
- }
29
- end
19
+ tool_class = find(tool_name)
20
+ tool = tool_class.new(arguments)
21
+
22
+ return error_response(tool.errors.full_messages) unless tool.valid?
23
+
24
+ process_result(tool.call)
25
+ rescue StandardError => e
26
+ error_response([ "Tool execution failed: #{e.message}" ])
30
27
  end
31
28
 
32
29
  def item_klass
33
30
  Tool
34
31
  end
32
+
33
+ private
34
+
35
+ def process_result(result)
36
+ case result
37
+ when Hash
38
+ return result if result[:isError]
39
+ success_response([ result ])
40
+ when String
41
+ success_response([ Content::Text.new(result) ])
42
+ when Array
43
+ success_response(result)
44
+ else
45
+ success_response([ result ])
46
+ end
47
+ end
48
+
49
+ def success_response(content)
50
+ { content: content }
51
+ end
52
+
53
+ def error_response(messages)
54
+ {
55
+ content: messages.map { |msg| Content::Text.new(msg) },
56
+ isError: true
57
+ }
58
+ end
35
59
  end
36
60
  end
37
61
  end
@@ -15,6 +15,12 @@ module ActionMCP
15
15
  notification = JsonRpc::Notification.new(method: method, params: params)
16
16
  write_message(notification)
17
17
  end
18
+
19
+ def send_jsonrpc_error(request_id, symbol, message, data = nil)
20
+ error = JsonRpc::JsonRpcError.new(symbol, message:, data:)
21
+ response = JsonRpc::Response.new(id: request_id, error:)
22
+ write_message(response)
23
+ end
18
24
  end
19
25
  end
20
26
  end
@@ -3,10 +3,28 @@
3
3
  module ActionMCP
4
4
  module Transport
5
5
  module Resources
6
+ # Send list of available resources to the client
7
+ #
8
+ # @param request_id [String, Integer] The ID of the request to respond to
9
+ #
10
+ # @example Input:
11
+ # request_id = "req-123"
12
+ #
13
+ # @example Output:
14
+ # # Sends: {"jsonrpc":"2.0","id":"req-123","result":{"resources":[]}}
6
15
  def send_resources_list(request_id)
7
16
  send_jsonrpc_response(request_id, result: { resources: [] })
8
17
  end
9
18
 
19
+ # Send list of resource templates to the client
20
+ #
21
+ # @param request_id [String, Integer] The ID of the request to respond to
22
+ #
23
+ # @example Input:
24
+ # request_id = "req-456"
25
+ #
26
+ # @example Output:
27
+ # # Sends: {"jsonrpc":"2.0","id":"req-456","result":{"resourceTemplates":[{"uriTemplate":"db://{table}","name":"Database Table"}]}}
10
28
  def send_resource_templates_list(request_id)
11
29
  templates = ActionMCP::ResourceTemplatesRegistry.resource_templates.values.map do |template|
12
30
  template.to_h
@@ -17,10 +35,41 @@ module ActionMCP
17
35
  send_jsonrpc_response(request_id, result: { resourceTemplates: templates })
18
36
  end
19
37
 
38
+ # Read and return the contents of a resource
39
+ #
40
+ # @param id [String, Integer] The ID of the request to respond to
41
+ # @param params [Hash] Parameters specifying which resource to read
42
+ #
43
+ # @example Input:
44
+ # id = "req-789"
45
+ # params = { uri: "file:///example.txt" }
46
+ #
47
+ # @example Output:
48
+ # # Sends: {"jsonrpc":"2.0","id":"req-789","result":{"contents":[{"uri":"file:///example.txt","text":"Example content"}]}}
20
49
  def send_resource_read(id, params)
21
- send_jsonrpc_response(id, result: {})
50
+ if (template = ResourceTemplatesRegistry.find_template_for_uri(params[:uri]))
51
+ record = template.process(params[:uri])
52
+ if (resource = record.fetch)
53
+ # if resource is a array or a collection, return each item then it ok
54
+ # else wrap it in a array
55
+ resource = [ resource ] unless resource.respond_to?(:each)
56
+ content = resource.map(&:to_h)
57
+ send_jsonrpc_response(id, result: { contents: content })
58
+ else
59
+ send_jsonrpc_error(id, :resource_not_found, "Resource not found")
60
+ end
61
+ else
62
+ send_jsonrpc_error(id, :invalid_params, "Invalid resource URI")
63
+ end
22
64
  end
23
65
 
66
+ # Log all registered resource templates
67
+ #
68
+ # @example Input:
69
+ # # No parameters
70
+ #
71
+ # @example Output:
72
+ # # Logs: "Registered Resource Templates: ["db://{table}", "file://{path}"]"
24
73
  def log_resource_templates
25
74
  Rails.logger.info("Registered Resource Templates: #{ActionMCP::ResourceTemplatesRegistry.resource_templates.keys}")
26
75
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.5.1"
5
+ VERSION = "0.7.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
@@ -7,8 +7,22 @@ class <%= class_name %> < MCPResourceTemplate
7
7
  parameter :product_id,
8
8
  description: "Product identifier",
9
9
  required: true
10
-
11
- def self.retrieve(params)
12
- raise NotImplementedError, "resolve must be implemented in the subclass"
10
+
11
+ def fetch
12
+ # Fetch the product from the database or api or whatever
13
+ # Ruby Magic here
14
+ if (product = Product.find_by(id: product_id))
15
+
16
+ resource = ActionMCP::Content::Resource.new(
17
+ "ecommerce://orders/#{product_id}",
18
+ "application/json",
19
+ text: product.to_json,
20
+ )
21
+
22
+ resource
23
+
24
+ else
25
+ nil # Return nil if resource not found
26
+ end
13
27
  end
14
28
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-03-14 00:00:00.000000000 Z
10
+ date: 2025-03-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -114,6 +114,7 @@ files:
114
114
  - app/models/action_mcp/session/message.rb
115
115
  - config/routes.rb
116
116
  - db/migrate/20250308122801_create_action_mcp_sessions.rb
117
+ - db/migrate/20250314230152_add_is_ping_to_session_message.rb
117
118
  - exe/actionmcp_cli
118
119
  - lib/action_mcp.rb
119
120
  - lib/action_mcp/capability.rb