actionmcp 0.111.0 → 0.200.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -18
  3. data/Rakefile +3 -0
  4. data/app/controllers/action_mcp/application_controller.rb +166 -109
  5. data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
  6. data/app/helpers/action_mcp/apps_helper.rb +48 -0
  7. data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
  8. data/app/models/action_mcp/session/message.rb +8 -3
  9. data/app/models/action_mcp/session/task.rb +15 -5
  10. data/app/models/action_mcp/session.rb +93 -19
  11. data/config/routes.rb +1 -1
  12. data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
  13. data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
  14. data/lib/action_mcp/apps/view_manifest.rb +170 -0
  15. data/lib/action_mcp/apps.rb +17 -2
  16. data/lib/action_mcp/configuration.rb +14 -30
  17. data/lib/action_mcp/content/audio.rb +4 -1
  18. data/lib/action_mcp/content/base.rb +3 -1
  19. data/lib/action_mcp/content/image.rb +4 -1
  20. data/lib/action_mcp/content/resource.rb +15 -14
  21. data/lib/action_mcp/content/resource_link.rb +15 -5
  22. data/lib/action_mcp/content/text.rb +2 -1
  23. data/lib/action_mcp/content/validation.rb +253 -0
  24. data/lib/action_mcp/content.rb +3 -1
  25. data/lib/action_mcp/dev/runner.rb +203 -0
  26. data/lib/action_mcp/engine.rb +24 -2
  27. data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
  28. data/lib/action_mcp/logging/logger.rb +16 -11
  29. data/lib/action_mcp/logging.rb +34 -1
  30. data/lib/action_mcp/middleware/origin_validation.rb +9 -13
  31. data/lib/action_mcp/output_schema_builder.rb +2 -0
  32. data/lib/action_mcp/prompt_response.rb +12 -2
  33. data/lib/action_mcp/protocol_validator.rb +513 -0
  34. data/lib/action_mcp/renderable.rb +4 -4
  35. data/lib/action_mcp/resource.rb +15 -11
  36. data/lib/action_mcp/resource_template.rb +105 -90
  37. data/lib/action_mcp/resource_templates_registry.rb +31 -97
  38. data/lib/action_mcp/schema_validator.rb +43 -0
  39. data/lib/action_mcp/server/base_session.rb +134 -28
  40. data/lib/action_mcp/server/base_session_store.rb +1 -1
  41. data/lib/action_mcp/server/capabilities.rb +10 -30
  42. data/lib/action_mcp/server/client_notifications.rb +63 -0
  43. data/lib/action_mcp/server/completions.rb +68 -0
  44. data/lib/action_mcp/server/elicitation.rb +47 -25
  45. data/lib/action_mcp/server/elicitation_request.rb +136 -51
  46. data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
  47. data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
  48. data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
  49. data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
  50. data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
  51. data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
  52. data/lib/action_mcp/server/messaging_service.rb +3 -1
  53. data/lib/action_mcp/server/prompts.rb +5 -3
  54. data/lib/action_mcp/server/resources.rb +35 -2
  55. data/lib/action_mcp/server/response_collector.rb +5 -1
  56. data/lib/action_mcp/server/roots.rb +15 -2
  57. data/lib/action_mcp/server/sampling.rb +36 -1
  58. data/lib/action_mcp/server/sampling_request.rb +317 -55
  59. data/lib/action_mcp/server/tasks.rb +27 -45
  60. data/lib/action_mcp/server/tool_result.rb +27 -0
  61. data/lib/action_mcp/server/tools.rb +13 -8
  62. data/lib/action_mcp/server/transport_handler.rb +3 -0
  63. data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
  64. data/lib/action_mcp/tool.rb +133 -199
  65. data/lib/action_mcp/tool_response.rb +35 -4
  66. data/lib/action_mcp/tools_registry.rb +1 -1
  67. data/lib/action_mcp/version.rb +1 -1
  68. data/lib/action_mcp.rb +2 -5
  69. data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
  70. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
  71. data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
  72. data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
  73. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
  74. data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
  75. data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
  76. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
  77. data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
  78. data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
  79. data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
  80. data/lib/generators/action_mcp/view/view_generator.rb +57 -0
  81. data/lib/tasks/action_mcp_apps.rake +17 -0
  82. data/lib/tasks/action_mcp_tasks.rake +135 -74
  83. metadata +47 -17
@@ -21,9 +21,9 @@ module ActionMCP
21
21
  # render(text: "Hello, world!")
22
22
  #
23
23
  def render(text: nil, audio: nil, image: nil, resource: nil, mime_type: nil, blob: nil)
24
- if resource && mime_type
24
+ if !resource.nil?
25
25
  Content::Resource.new(resource, mime_type, text: text, blob: blob, annotations: nil)
26
- elsif text
26
+ elsif !text.nil?
27
27
  Content::Text.new(text, annotations: nil)
28
28
  elsif audio && mime_type
29
29
  Content::Audio.new(audio, mime_type, annotations: nil)
@@ -37,7 +37,7 @@ module ActionMCP
37
37
  # Renders a resource link for Model Context Protocol responses.
38
38
  #
39
39
  # @param uri [String] The URI of the resource
40
- # @param name [String, nil] Optional name for the resource
40
+ # @param name [String] Name for the resource
41
41
  # @param description [String, nil] Optional description
42
42
  # @param mime_type [String, nil] Optional MIME type
43
43
  # @param annotations [Hash, nil] Optional annotations
@@ -47,7 +47,7 @@ module ActionMCP
47
47
  # @example Render a resource link
48
48
  # render_resource_link(uri: "file:///path/to/file.txt", name: "Example File")
49
49
  #
50
- def render_resource_link(uri:, name: nil, description: nil, mime_type: nil, annotations: nil)
50
+ def render_resource_link(uri:, name:, description: nil, mime_type: nil, annotations: nil)
51
51
  Content::ResourceLink.new(uri, name: name, description: description,
52
52
  mime_type: mime_type, annotations: annotations)
53
53
  end
@@ -15,23 +15,26 @@ module ActionMCP
15
15
  # @param annotations [Hash, nil] Optional annotations
16
16
  # @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
17
17
  def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, meta: nil)
18
+ raise ArgumentError, "uri must be a non-empty string" unless uri.is_a?(String) && uri.present?
19
+ raise ArgumentError, "name must be a non-empty string" unless name.is_a?(String) && name.present?
20
+
21
+ { title: title, description: description, mime_type: mime_type }.each do |field, value|
22
+ raise ArgumentError, "#{field} must be a string or nil" unless value.nil? || value.is_a?(String)
23
+ end
24
+ unless size.nil? || (size.is_a?(Integer) && size >= 0)
25
+ raise ArgumentError, "size must be a non-negative integer or nil"
26
+ end
27
+
18
28
  @uri = uri
19
29
  @name = name
20
30
  @title = title
21
31
  @description = description
22
32
  @mime_type = mime_type
23
33
  @size = size
24
- @annotations = annotations
25
- @meta =
26
- if meta.nil?
27
- nil
28
- elsif meta.respond_to?(:to_hash)
29
- meta.to_hash
30
- elsif meta.respond_to?(:to_h)
31
- meta.to_h
32
- else
33
- raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
34
- end
34
+ @annotations = annotations.nil? ? nil : Content::Validation.copy_object!(annotations, "annotations")
35
+ Content::Validation.validate_annotations!(@annotations)
36
+ @meta = meta.nil? ? nil : Content::Validation.copy_object!(meta, "meta")
37
+ to_h
35
38
  freeze
36
39
  end
37
40
 
@@ -47,6 +50,7 @@ module ActionMCP
47
50
  hash[:size] = size if size
48
51
  hash[:annotations] = annotations if annotations
49
52
  hash[:_meta] = meta if meta && !meta.empty?
53
+ Content::Validation.validate_resource!(hash)
50
54
  hash
51
55
  end
52
56
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_model"
4
+ require "addressable/template"
5
+ require "addressable/uri"
4
6
 
5
7
  module ActionMCP
6
8
  class ResourceTemplate
@@ -13,7 +15,7 @@ module ActionMCP
13
15
 
14
16
  # Track all registered templates
15
17
  @registered_templates = []
16
- attr_reader :execution_context
18
+ attr_reader :execution_context, :resolved_uri
17
19
 
18
20
  # Delegate to class-level DSL values so instances see template metadata.
19
21
  delegate :description, :uri_template, :mime_type, to: :class
@@ -51,9 +53,8 @@ module ActionMCP
51
53
  @required_parameters ||= []
52
54
  end
53
55
 
54
- # Cache compiled regex patterns for URI matching to avoid recompilation
55
- def uri_regex_cache
56
- @uri_regex_cache ||= {}
56
+ def addressable_template_cache
57
+ @addressable_template_cache ||= {}
57
58
  end
58
59
 
59
60
  def parameter(name, description:, required: false, **options)
@@ -129,46 +130,62 @@ module ActionMCP
129
130
  def meta_with_ui(meta = nil)
130
131
  supplied_meta = coerce_meta(meta)
131
132
  ui_meta = @ui_meta&.any? ? { ui: @ui_meta } : {}
133
+ combined = ui_meta.deep_merge(supplied_meta)
132
134
 
133
- return nil if ui_meta.empty? && supplied_meta.empty?
134
- return supplied_meta if ui_meta.empty?
135
- return ui_meta if supplied_meta.empty?
135
+ return nil if combined.empty?
136
136
 
137
- ui_meta.deep_merge(supplied_meta)
137
+ ui_present, combined_ui = metadata_value(combined, :ui)
138
+ validate_ui_metadata!(combined_ui) if ui_present
139
+ combined
138
140
  end
139
141
 
140
142
  private
141
143
 
142
144
  def validate_ui_metadata!(data)
143
- validate_ui_csp_origins!(data[:csp] || data["csp"])
144
- validate_ui_permissions!(data[:permissions] || data["permissions"])
145
+ raise ArgumentError, "ui metadata must be a hash" unless data.is_a?(Hash)
146
+
147
+ validate_ui_keys!(data, Apps::UI_META_KEYS, "metadata")
148
+ csp_present, csp = metadata_value(data, :csp)
149
+ permissions_present, permissions = metadata_value(data, :permissions)
150
+ domain_present, domain = metadata_value(data, :domain)
151
+ border_present, border = metadata_value(data, :prefersBorder)
152
+
153
+ validate_ui_csp_origins!(csp) if csp_present
154
+ validate_ui_permissions!(permissions) if permissions_present
155
+ if domain_present && !domain.is_a?(String)
156
+ raise ArgumentError, "ui domain must be a string, got: #{domain.inspect}"
157
+ end
158
+ if border_present && border != true && border != false
159
+ raise ArgumentError, "ui prefersBorder must be true or false, got: #{border.inspect}"
160
+ end
145
161
  end
146
162
 
147
163
  def validate_ui_csp_origins!(csp)
148
- return unless csp.is_a?(Hash)
164
+ raise ArgumentError, "ui csp must be a hash" unless csp.is_a?(Hash)
149
165
 
166
+ validate_ui_keys!(csp, Apps::CSP_KEYS, "csp")
150
167
  Apps::CSP_KEYS.each do |key|
151
168
  pattern = key == :connectDomains ? Apps::CONNECT_ORIGIN_PATTERN : Apps::RESOURCE_ORIGIN_PATTERN
152
169
  scheme_message = key == :connectDomains ? "http(s):// or ws(s)://" : "http(s)://"
153
- Array(csp[key] || csp[key.to_s]).each do |origin|
154
- next if origin.is_a?(String) && pattern.match?(origin)
170
+ metadata_values(csp, key).each do |origins|
171
+ unless origins.is_a?(Array)
172
+ raise ArgumentError, "ui csp #{key} must be an array, got: #{origins.inspect}"
173
+ end
155
174
 
156
- raise ArgumentError,
157
- "ui csp #{key} entries must be #{scheme_message} origins, got: #{origin.inspect}"
175
+ origins.each do |origin|
176
+ next if origin.is_a?(String) && pattern.match?(origin)
177
+
178
+ raise ArgumentError,
179
+ "ui csp #{key} entries must be #{scheme_message} origins, got: #{origin.inspect}"
180
+ end
158
181
  end
159
182
  end
160
183
  end
161
184
 
162
185
  def validate_ui_permissions!(permissions)
163
- return unless permissions
164
186
  raise ArgumentError, "ui permissions must be a hash" unless permissions.is_a?(Hash)
165
187
 
166
- normalized_keys = permissions.keys.map(&:to_sym)
167
- invalid = normalized_keys - Apps::PERMISSION_KEYS
168
- if invalid.any?
169
- raise ArgumentError,
170
- "ui permissions keys must be #{Apps::PERMISSION_KEYS.join('/')}, got: #{invalid.inspect}"
171
- end
188
+ validate_ui_keys!(permissions, Apps::PERMISSION_KEYS, "permissions")
172
189
 
173
190
  permissions.each do |key, value|
174
191
  next if value.respond_to?(:to_hash)
@@ -194,6 +211,30 @@ module ActionMCP
194
211
  coerced
195
212
  end
196
213
 
214
+ def metadata_value(data, key)
215
+ return [ true, data[key] ] if data.key?(key)
216
+ return [ true, data[key.to_s] ] if data.key?(key.to_s)
217
+
218
+ [ false, nil ]
219
+ end
220
+
221
+ def metadata_values(data, key)
222
+ values = []
223
+ values << data[key] if data.key?(key)
224
+ values << data[key.to_s] if data.key?(key.to_s)
225
+ values
226
+ end
227
+
228
+ def validate_ui_keys!(data, allowed_keys, label)
229
+ invalid = data.keys.reject do |key|
230
+ key.respond_to?(:to_sym) && allowed_keys.include?(key.to_sym)
231
+ end
232
+ return if invalid.empty?
233
+
234
+ raise ArgumentError,
235
+ "ui #{label} keys must be #{allowed_keys.join('/')}, got: #{invalid.inspect}"
236
+ end
237
+
197
238
  public
198
239
 
199
240
  def to_h
@@ -282,84 +323,41 @@ module ActionMCP
282
323
  params = extract_params_from_uri(uri_string)
283
324
  return new if params.nil? # Return invalid template for bad URI
284
325
 
285
- # Create new instance with the extracted parameters
286
- new(params)
326
+ # Preserve the concrete URI requested by the client. The template URI
327
+ # may contain placeholders and must not leak into resource contents.
328
+ new(params).tap { |record| record.instance_variable_set(:@resolved_uri, uri_string) }
287
329
  end
288
330
 
289
331
  private
290
332
 
291
333
  # Extract parameters from a URI using the template pattern
292
334
  def extract_params_from_uri(uri_string)
293
- # Check cache for compiled regex and param names
294
- cache_entry = uri_regex_cache[@uri_template]
295
-
296
- unless cache_entry
297
- # Convert template parameters to named capture groups
298
- regex_parts = []
299
- current_pos = 0
300
- param_names = []
301
-
302
- # Find all template parameters like {param_name}
303
- @uri_template.scan(/\{([^}]+)\}/) do |param_name|
304
- param_names << param_name[0]
305
-
306
- # Get the position of this parameter in the template
307
- param_start = @uri_template.index("{#{param_name[0]}}", current_pos)
308
-
309
- # Add the text before the parameter (escaped)
310
- if param_start > current_pos
311
- prefix = Regexp.escape(@uri_template[current_pos...param_start])
312
- regex_parts << prefix
313
- end
314
-
315
- # Add the named capture group
316
- regex_parts << "(?<#{param_name[0]}>[^/]+)"
317
-
318
- # Update current position
319
- current_pos = param_start + param_name[0].length + 2 # +2 for { and }
320
- end
321
-
322
- # Add any remaining text after the last parameter
323
- if current_pos < @uri_template.length
324
- suffix = Regexp.escape(@uri_template[current_pos..])
325
- regex_parts << suffix
326
- end
327
-
328
- # Build the final regex and cache it
329
- regex_pattern = regex_parts.join
330
- regex = Regexp.new("^#{regex_pattern}$")
331
- cache_entry = { regex: regex, param_names: param_names }
332
- uri_regex_cache[@uri_template] = cache_entry
333
- end
334
-
335
- # Try to match the URI using cached regex
336
- match_data = cache_entry[:regex].match(uri_string)
337
- return nil unless match_data
338
-
339
- # Extract named captures as parameters
340
- params = {}
341
- cache_entry[:param_names].each do |name|
342
- params[name.to_sym] = match_data[name] if match_data[name]
343
- end
344
-
345
- params
335
+ extracted = compiled_uri_template(@uri_template).extract(uri_string)
336
+ extracted&.transform_keys(&:to_sym)
346
337
  end
347
338
 
348
339
  # Extract the schema and pattern from a URI template
349
340
  def parse_uri_template(template)
350
- # Parse the URI template to get schema and pattern
351
- # Format: schema://path/{param1}/{param2}...
352
- if template =~ %r{^([^:]+)://(.+)$}
353
- schema = ::Regexp.last_match(1)
354
- pattern = ::Regexp.last_match(2)
355
-
356
- # Replace parameter placeholders with generic markers to compare structure
357
- normalized_pattern = pattern.gsub(/\{[^}]+\}/, "{param}")
358
-
359
- return { schema: schema, pattern: normalized_pattern, original: template }
341
+ addressable_template = compiled_uri_template(template)
342
+ variables = addressable_template.variables.index_with.with_index do |_, index|
343
+ "action_mcp_parameter_#{index}"
360
344
  end
345
+ expanded = addressable_template.expand(variables).to_s
346
+ parsed_uri = Addressable::URI.parse(expanded)
347
+ raise ArgumentError, "Invalid URI template format: #{template}" if parsed_uri.scheme.blank?
348
+
349
+ normalized_pattern = expanded.delete_prefix("#{parsed_uri.scheme}:").delete_prefix("//")
350
+ variables.each_value { |marker| normalized_pattern.gsub!(marker, "{param}") }
351
+
352
+ { schema: parsed_uri.scheme, pattern: normalized_pattern, original: template }
353
+ rescue Addressable::URI::InvalidURIError,
354
+ Addressable::Template::InvalidTemplateValueError,
355
+ Addressable::Template::InvalidTemplateOperatorError => e
356
+ raise ArgumentError, "Invalid URI template format: #{template} (#{e.message})"
357
+ end
361
358
 
362
- raise ArgumentError, "Invalid URI template format: #{template}"
359
+ def compiled_uri_template(template)
360
+ addressable_template_cache[template] ||= Addressable::Template.new(template)
363
361
  end
364
362
 
365
363
  # Validate that the URI template is unique
@@ -411,9 +409,26 @@ module ActionMCP
411
409
  raise ArgumentError, "render_ui requires :text or :template"
412
410
  end
413
411
 
412
+ resource_uri = resolved_uri || self.class.uri_template
413
+ unless resource_uri.is_a?(String) && ActionMCP::Apps::URI_SCHEME.match?(resource_uri)
414
+ raise ArgumentError, "render_ui requires a concrete ui:// resource URI, got: #{resource_uri.inspect}"
415
+ end
416
+ if resolved_uri.nil? && resource_uri.match?(/\{[^}]+\}/)
417
+ raise ArgumentError, "render_ui cannot emit a parameterized URI template without a concrete request URI"
418
+ end
419
+
420
+ resource_mime_type = self.class.mime_type || ActionMCP::Apps::MIME_TYPE
421
+ unless resource_mime_type == ActionMCP::Apps::MIME_TYPE
422
+ raise ArgumentError,
423
+ "render_ui requires mime type #{ActionMCP::Apps::MIME_TYPE.inspect}, got: #{resource_mime_type.inspect}"
424
+ end
425
+ unless resolved.is_a?(String)
426
+ raise ArgumentError, "render_ui content must be a string, got: #{resolved.class}"
427
+ end
428
+
414
429
  ActionMCP::Content::Resource.new(
415
- self.class.uri_template,
416
- self.class.mime_type || ActionMCP::Apps::MIME_TYPE,
430
+ resource_uri,
431
+ resource_mime_type,
417
432
  text: resolved,
418
433
  meta: self.class.meta_with_ui(meta)
419
434
  )
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "addressable/template"
4
+
3
5
  module ActionMCP
4
6
  # Registry for managing resource templates.
5
7
  class ResourceTemplatesRegistry < RegistryBase
@@ -24,53 +26,22 @@ module ActionMCP
24
26
 
25
27
  # Find the most specific template for a given URI.
26
28
  # Uses registry-backed source (not ResourceTemplate.registered_templates class array).
27
- def find_template_for_uri(uri)
28
- parse_result = parse_uri(uri)
29
- return nil unless parse_result
30
-
31
- schema = parse_result[:schema]
32
- path = parse_result[:path]
33
- path_segments = path.split("/")
34
-
35
- matching_templates = resource_templates.values.select do |template|
29
+ def find_template_for_uri(uri, templates: resource_templates.values)
30
+ matching_templates = templates.select do |template|
36
31
  next unless template.uri_template
37
32
 
38
- # Parse the template
39
- template_data = parse_uri_template(template.uri_template)
40
- next unless template_data && template_data[:schema] == schema
41
-
42
- # Split template into segments and check if structure matches
43
- template_segments = template_data[:path].split("/")
44
- next unless template_segments.length == path_segments.length
45
-
46
- # Check if each segment matches (either static match or a parameter)
47
- segments_match = true
48
-
49
- template_segments.each_with_index do |template_segment, index|
50
- path_segment = path_segments[index]
51
-
52
- if template_segment.start_with?("{") && template_segment.end_with?("}")
53
- # This is a parameter segment, it matches any value
54
- next
55
- elsif template_segment != path_segment
56
- # Static segment doesn't match
57
- segments_match = false
58
- break
59
- end
60
- end
61
-
62
- segments_match
33
+ compiled_template(template.uri_template).extract(uri)
34
+ rescue Addressable::URI::InvalidURIError,
35
+ Addressable::Template::InvalidTemplateValueError,
36
+ Addressable::Template::InvalidTemplateOperatorError
37
+ false
63
38
  end
64
39
 
65
40
  # If multiple templates match, select the most specific one
66
41
  # (the one with the most static segments)
67
42
  if matching_templates.size > 1
68
43
  matching_templates.max_by do |template|
69
- template_data = parse_uri_template(template.uri_template)
70
- template_segments = template_data[:path].split("/")
71
-
72
- # Count static segments (not parameters)
73
- template_segments.count { |segment| !segment.start_with?("{") }
44
+ template_specificity(template.uri_template)
74
45
  end
75
46
  elsif matching_templates.size == 1
76
47
  matching_templates.first
@@ -79,75 +50,38 @@ module ActionMCP
79
50
 
80
51
  # Check if a URI matches a specific template
81
52
  def uri_matches_template?(uri, template)
82
- uri_data = parse_uri(uri)
83
- template_data = parse_uri_template(template.uri_template)
84
-
85
- return false unless uri_data && template_data && uri_data[:schema] == template_data[:schema]
86
-
87
- uri_segments = uri_data[:path].split("/")
88
- template_segments = template_data[:path].split("/")
89
-
90
- return false unless uri_segments.length == template_segments.length
91
-
92
- # Check each segment
93
- template_segments.each_with_index do |template_segment, index|
94
- uri_segment = uri_segments[index]
95
-
96
- # If template segment is a parameter, it matches anything
97
- next if template_segment.start_with?("{") && template_segment.end_with?("}")
98
-
99
- # Otherwise, segments must match exactly
100
- return false if template_segment != uri_segment
101
- end
102
-
103
- true
53
+ !compiled_template(template.uri_template).extract(uri).nil?
54
+ rescue Addressable::URI::InvalidURIError,
55
+ Addressable::Template::InvalidTemplateValueError,
56
+ Addressable::Template::InvalidTemplateOperatorError
57
+ false
104
58
  end
105
59
 
106
60
  # Extract parameter values from a URI based on a template
107
61
  def extract_parameters(uri, template)
108
- return {} unless uri_matches_template?(uri, template)
109
-
110
- uri_data = parse_uri(uri)
111
- template_data = parse_uri_template(template.uri_template)
112
-
113
- uri_segments = uri_data[:path].split("/")
114
- template_segments = template_data[:path].split("/")
115
-
116
- # Extract parameters
117
- params = {}
118
- template_segments.each_with_index do |template_segment, index|
119
- next unless template_segment.start_with?("{") && template_segment.end_with?("}")
120
-
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
-
126
- params
62
+ extracted = compiled_template(template.uri_template).extract(uri)
63
+ extracted ? extracted.transform_keys(&:to_sym) : {}
64
+ rescue Addressable::URI::InvalidURIError,
65
+ Addressable::Template::InvalidTemplateValueError,
66
+ Addressable::Template::InvalidTemplateOperatorError
67
+ {}
127
68
  end
128
69
 
129
70
  private
130
71
 
131
- # Parse a concrete URI
132
- def parse_uri(uri)
133
- return unless uri =~ %r{^([^:]+)://(.+)$}
134
-
135
- {
136
- schema: ::Regexp.last_match(1),
137
- path: ::Regexp.last_match(2),
138
- original: uri
139
- }
72
+ def compiled_template(pattern)
73
+ compiled_templates[pattern] ||= Addressable::Template.new(pattern)
140
74
  end
141
75
 
142
- # Parse a URI template
143
- def parse_uri_template(template)
144
- return unless template =~ %r{^([^:]+)://(.+)$}
76
+ def compiled_templates
77
+ @compiled_templates ||= {}
78
+ end
145
79
 
146
- {
147
- schema: ::Regexp.last_match(1),
148
- path: ::Regexp.last_match(2),
149
- original: template
150
- }
80
+ def template_specificity(pattern)
81
+ segments = pattern.split("/")
82
+ static_segments = segments.count { |segment| !segment.include?("{") }
83
+ literal_length = pattern.gsub(/\{[^}]*\}/, "").length
84
+ [ static_segments, literal_length ]
151
85
  end
152
86
  end
153
87
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ module ActionMCP
6
+ module SchemaValidator
7
+ DEFAULT_DIALECT = "https://json-schema.org/draft/2020-12/schema"
8
+
9
+ module_function
10
+
11
+ def compile(schema, context: "JSON Schema")
12
+ normalized_schema = normalize(schema)
13
+ schema_errors = JSONSchemer.validate_schema(normalized_schema).to_a
14
+
15
+ if schema_errors.any?
16
+ raise ArgumentError, "Invalid #{context}: #{error_messages(schema_errors).join(', ')}"
17
+ end
18
+
19
+ JSONSchemer.schema(normalized_schema)
20
+ rescue JSONSchemer::UnknownRef => e
21
+ raise ArgumentError, "Invalid #{context}: unknown schema dialect or reference #{e.message.inspect}"
22
+ end
23
+
24
+ def validate(schemer, value)
25
+ schemer.validate(normalize(value)).to_a
26
+ end
27
+
28
+ def error_messages(errors)
29
+ errors.filter_map { |error| error["error"] }.uniq
30
+ end
31
+
32
+ def normalize(value)
33
+ case value
34
+ when Hash
35
+ value.deep_stringify_keys
36
+ when Array
37
+ value.map { |item| normalize(item) }
38
+ else
39
+ value
40
+ end
41
+ end
42
+ end
43
+ end