actionmcp 0.111.1 → 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.
- checksums.yaml +4 -4
- data/README.md +26 -18
- data/Rakefile +3 -0
- data/app/controllers/action_mcp/application_controller.rb +166 -109
- data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
- data/app/helpers/action_mcp/apps_helper.rb +48 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
- data/app/models/action_mcp/session/message.rb +8 -3
- data/app/models/action_mcp/session/task.rb +15 -5
- data/app/models/action_mcp/session.rb +93 -19
- data/config/routes.rb +1 -1
- data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
- data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
- data/lib/action_mcp/apps/view_manifest.rb +170 -0
- data/lib/action_mcp/apps.rb +17 -2
- data/lib/action_mcp/configuration.rb +14 -30
- data/lib/action_mcp/content/audio.rb +4 -1
- data/lib/action_mcp/content/base.rb +3 -1
- data/lib/action_mcp/content/image.rb +4 -1
- data/lib/action_mcp/content/resource.rb +15 -14
- data/lib/action_mcp/content/resource_link.rb +15 -5
- data/lib/action_mcp/content/text.rb +2 -1
- data/lib/action_mcp/content/validation.rb +253 -0
- data/lib/action_mcp/content.rb +3 -1
- data/lib/action_mcp/dev/runner.rb +203 -0
- data/lib/action_mcp/engine.rb +24 -2
- data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
- data/lib/action_mcp/logging/logger.rb +16 -11
- data/lib/action_mcp/logging.rb +34 -1
- data/lib/action_mcp/middleware/origin_validation.rb +9 -13
- data/lib/action_mcp/output_schema_builder.rb +2 -0
- data/lib/action_mcp/prompt_response.rb +12 -2
- data/lib/action_mcp/protocol_validator.rb +513 -0
- data/lib/action_mcp/renderable.rb +4 -4
- data/lib/action_mcp/resource.rb +15 -11
- data/lib/action_mcp/resource_template.rb +105 -90
- data/lib/action_mcp/resource_templates_registry.rb +31 -97
- data/lib/action_mcp/schema_validator.rb +43 -0
- data/lib/action_mcp/server/base_session.rb +134 -28
- data/lib/action_mcp/server/base_session_store.rb +1 -1
- data/lib/action_mcp/server/capabilities.rb +10 -30
- data/lib/action_mcp/server/client_notifications.rb +63 -0
- data/lib/action_mcp/server/completions.rb +68 -0
- data/lib/action_mcp/server/elicitation.rb +47 -25
- data/lib/action_mcp/server/elicitation_request.rb +136 -51
- data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
- data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
- data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
- data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
- data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
- data/lib/action_mcp/server/messaging_service.rb +3 -1
- data/lib/action_mcp/server/prompts.rb +5 -3
- data/lib/action_mcp/server/resources.rb +35 -2
- data/lib/action_mcp/server/response_collector.rb +5 -1
- data/lib/action_mcp/server/roots.rb +15 -2
- data/lib/action_mcp/server/sampling.rb +36 -1
- data/lib/action_mcp/server/sampling_request.rb +317 -55
- data/lib/action_mcp/server/tasks.rb +27 -45
- data/lib/action_mcp/server/tool_result.rb +27 -0
- data/lib/action_mcp/server/tools.rb +12 -10
- data/lib/action_mcp/server/transport_handler.rb +3 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
- data/lib/action_mcp/tool.rb +133 -199
- data/lib/action_mcp/tool_response.rb +35 -4
- data/lib/action_mcp/tools_registry.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -5
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
- data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
- data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
- data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
- data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
- data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
- data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
- data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
- data/lib/generators/action_mcp/view/view_generator.rb +57 -0
- data/lib/tasks/action_mcp_apps.rake +17 -0
- data/lib/tasks/action_mcp_tasks.rake +135 -74
- 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
|
|
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
|
|
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
|
|
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
|
data/lib/action_mcp/resource.rb
CHANGED
|
@@ -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
|
-
@
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
286
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
76
|
+
def compiled_templates
|
|
77
|
+
@compiled_templates ||= {}
|
|
78
|
+
end
|
|
145
79
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|