actionmcp 0.110.2 → 0.111.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: f18a19a33b307301a158d77b9fb5bfede2a9dfa9f3d316be439db3f58b313b6b
4
- data.tar.gz: 5623ce4489efa81032fac7c1a9baad1a6ebe27b74593d6a25b022143f2d40d0c
3
+ metadata.gz: c0047ad57f7f03ccd09f3ebfc05f865412bd4731c5e918b0e7886a37c9a708e1
4
+ data.tar.gz: c85e5ffc964b2bc2587a12b669cd5e754feff1e8266fc0abf50fa0e3a40e2b15
5
5
  SHA512:
6
- metadata.gz: fe04793ecfdad4d4c90041d3834b52fdf0956b152c51a4c5a866380bba60d327c560c427962eaac2259c6087a22daa7d97244adc75a5f7e7099ae0a37ec44fbf
7
- data.tar.gz: 75ece96abe7f14658da802e3b295cb085bd7c736e900a7339f42c91b65c1ddd2422e10c650bac99121201ccd1b3abaaa0a39c2ad4665bafc5f7748493ce45a5c
6
+ metadata.gz: a8d4f3f7f47d9ac6031ebb925aa7ef59bb4e42ce7b2273ca768e431b13860d32887db55a1599ca6fe5991d39f5339347827fef12ef78e4a96ea0dfa1671ec864
7
+ data.tar.gz: 45ce27835f49e3f1068ec69e76c1c9f0d54fe5c13be1cabce94993490647d282b098fed63410953e10835227d980b6ae4450b12fd65915dcf176f422c5b97c33
@@ -1,20 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
- # Constants for the MCP Apps extension (ext-apps, SEP-1865, draft 2026-01-26).
5
- # See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
4
+ # Constants and helpers for the MCP Apps extension (ext-apps, SEP-1865,
5
+ # stable 2026-01-26).
6
+ # See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
6
7
  module Apps
7
8
  EXTENSION_KEY = "io.modelcontextprotocol/ui"
8
9
  VISIBILITY_VALUES = %w[model app].freeze
9
10
  URI_SCHEME = %r{\Aui://\S+\z}
10
11
  MIME_TYPE = MimeTypes::APP_HTML
12
+ EXTENSION_SETTINGS = { mimeTypes: [ MIME_TYPE ] }.freeze
11
13
 
12
14
  # `_meta.ui.csp` directive keys per ext-apps spec.
13
15
  CSP_KEYS = %i[connectDomains resourceDomains frameDomains baseUriDomains].freeze
16
+ PERMISSION_KEYS = %i[camera microphone geolocation clipboardWrite].freeze
14
17
 
15
- # Accepts http/https origins only. Wildcard subdomain (`https://*.example.com`),
16
- # ports, and paths are allowed. WebSocket origins (ws://, wss://) are not
17
- # accepted by ActionMCP — declare them via `ws://` over fetch if you must.
18
- ORIGIN_PATTERN = %r{\Ahttps?://[^\s"'<>]+\z}
18
+ # The stable MCP Apps spec allows WebSocket endpoints for connect-src only.
19
+ CONNECT_ORIGIN_PATTERN = %r{\A(?:https?|wss?)://[^\s"'<>]+\z}
20
+ RESOURCE_ORIGIN_PATTERN = %r{\Ahttps?://[^\s"'<>]+\z}
21
+
22
+ module_function
23
+
24
+ def extension_settings
25
+ EXTENSION_SETTINGS.deep_dup
26
+ end
27
+
28
+ def client_supports?(client_capabilities)
29
+ settings = client_capabilities&.dig("extensions", EXTENSION_KEY) ||
30
+ client_capabilities&.dig(:extensions, EXTENSION_KEY)
31
+ return false unless settings.is_a?(Hash)
32
+
33
+ mime_types = settings["mimeTypes"] || settings[:mimeTypes]
34
+ Array(mime_types).include?(MIME_TYPE)
35
+ end
19
36
  end
20
37
  end
@@ -32,10 +32,12 @@ module ActionMCP
32
32
 
33
33
  delegate :session_data, to: :session, allow_nil: true
34
34
 
35
- # Returns true when the connected client advertises the MCP Apps UI extension.
35
+ # Returns true when the connected client advertises the MCP Apps UI extension
36
+ # and supports ActionMCP's HTML app resource MIME type.
36
37
  def client_supports_ui?
37
- !session&.client_capabilities&.dig("extensions", Apps::EXTENSION_KEY).nil?
38
+ Apps.client_supports?(session&.client_capabilities)
38
39
  end
40
+ alias client_supports_mcp_app? client_supports_ui?
39
41
 
40
42
  # use _capability_name or default_capability_name
41
43
  def self.capability_name
@@ -48,6 +48,8 @@ module ActionMCP
48
48
  :tasks_result_strategy,
49
49
  :tasks_result_timeout,
50
50
  :tasks_result_poll_interval,
51
+ # --- MCP Apps Extension Options ---
52
+ :mcp_apps_enabled,
51
53
  # --- Schema Validation Options ---
52
54
  :validate_structured_content,
53
55
  # --- Allowed identity keys for gateway ---
@@ -79,6 +81,9 @@ module ActionMCP
79
81
  @tasks_result_timeout = 30.seconds
80
82
  @tasks_result_poll_interval = 0.25
81
83
 
84
+ # MCP Apps extension defaults to explicit opt-in.
85
+ @mcp_apps_enabled = false
86
+
82
87
  # Pagination - nil means off. Set a number to enable with that page size.
83
88
  # Most MCP clients (including Claude Code) don't follow nextCursor yet.
84
89
  @pagination_page_size = nil
@@ -319,6 +324,11 @@ module ActionMCP
319
324
  capabilities[:tasks] = tasks_cap
320
325
  end
321
326
 
327
+ if @mcp_apps_enabled
328
+ capabilities[:extensions] ||= {}
329
+ capabilities[:extensions][Apps::EXTENSION_KEY] = Apps.extension_settings
330
+ end
331
+
322
332
  capabilities
323
333
  end
324
334
 
@@ -341,6 +351,7 @@ module ActionMCP
341
351
  @logging_enabled = options[:logging_enabled] unless options[:logging_enabled].nil?
342
352
  @logging_level = options[:logging_level] unless options[:logging_level].nil?
343
353
  @resources_subscribe = options[:resources_subscribe] unless options[:resources_subscribe].nil?
354
+ @mcp_apps_enabled = boolean_config_value(options[:mcp_apps_enabled]) unless options[:mcp_apps_enabled].nil?
344
355
  end
345
356
 
346
357
  def eager_load_if_needed
@@ -437,6 +448,8 @@ module ActionMCP
437
448
  self.tasks_result_poll_interval = config["tasks_result_poll_interval"]
438
449
  end
439
450
 
451
+ @mcp_apps_enabled = boolean_config_value(config["mcp_apps_enabled"]) if config.key?("mcp_apps_enabled")
452
+
440
453
  # Extract allowed origins for DNS rebinding protection
441
454
  if config["allowed_origins"]
442
455
  @allowed_origins = Array(config["allowed_origins"])
@@ -470,6 +483,17 @@ module ActionMCP
470
483
  duration
471
484
  end
472
485
 
486
+ def boolean_config_value(value)
487
+ case value
488
+ when true, false
489
+ value
490
+ when String
491
+ !%w[false 0 no off].include?(value.strip.downcase)
492
+ else
493
+ !!value
494
+ end
495
+ end
496
+
473
497
  def ensure_mcp_components_loaded
474
498
  # Only load if we haven't loaded yet - but in development, always reload
475
499
  return if @mcp_components_loaded && !Rails.env.development?
@@ -121,26 +121,79 @@ module ActionMCP
121
121
  def ui(**data)
122
122
  raise ArgumentError, "ui metadata must not be empty" if data.empty?
123
123
 
124
- validate_ui_csp_origins!(data[:csp])
124
+ validate_ui_metadata!(data)
125
125
  @ui_meta ||= {}
126
126
  @ui_meta = @ui_meta.deep_merge(data)
127
127
  end
128
128
 
129
+ def meta_with_ui(meta = nil)
130
+ supplied_meta = coerce_meta(meta)
131
+ ui_meta = @ui_meta&.any? ? { ui: @ui_meta } : {}
132
+
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?
136
+
137
+ ui_meta.deep_merge(supplied_meta)
138
+ end
139
+
129
140
  private
130
141
 
142
+ def validate_ui_metadata!(data)
143
+ validate_ui_csp_origins!(data[:csp] || data["csp"])
144
+ validate_ui_permissions!(data[:permissions] || data["permissions"])
145
+ end
146
+
131
147
  def validate_ui_csp_origins!(csp)
132
148
  return unless csp.is_a?(Hash)
133
149
 
134
150
  Apps::CSP_KEYS.each do |key|
135
- Array(csp[key]).each do |origin|
136
- next if origin.is_a?(String) && Apps::ORIGIN_PATTERN.match?(origin)
151
+ pattern = key == :connectDomains ? Apps::CONNECT_ORIGIN_PATTERN : Apps::RESOURCE_ORIGIN_PATTERN
152
+ 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)
137
155
 
138
156
  raise ArgumentError,
139
- "ui csp #{key} entries must be http(s):// origins, got: #{origin.inspect}"
157
+ "ui csp #{key} entries must be #{scheme_message} origins, got: #{origin.inspect}"
140
158
  end
141
159
  end
142
160
  end
143
161
 
162
+ def validate_ui_permissions!(permissions)
163
+ return unless permissions
164
+ raise ArgumentError, "ui permissions must be a hash" unless permissions.is_a?(Hash)
165
+
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
172
+
173
+ permissions.each do |key, value|
174
+ next if value.respond_to?(:to_hash)
175
+
176
+ raise ArgumentError, "ui permissions #{key} value must be a hash, got: #{value.inspect}"
177
+ end
178
+ end
179
+
180
+ def coerce_meta(meta)
181
+ coerced =
182
+ if meta.nil?
183
+ {}
184
+ elsif meta.respond_to?(:to_hash)
185
+ meta.to_hash
186
+ elsif meta.respond_to?(:to_h)
187
+ meta.to_h
188
+ else
189
+ raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
190
+ end
191
+
192
+ coerced = coerced.deep_dup
193
+ coerced[:ui] = coerced.delete("ui") if coerced.key?("ui") && !coerced.key?(:ui)
194
+ coerced
195
+ end
196
+
144
197
  public
145
198
 
146
199
  def to_h
@@ -153,8 +206,8 @@ module ActionMCP
153
206
  mimeType: @mime_type
154
207
  }.compact
155
208
 
156
- # Add _meta if present
157
- result[:_meta] = @_meta if @_meta&.any?
209
+ meta = meta_with_ui(@_meta)
210
+ result[:_meta] = meta if meta&.any?
158
211
 
159
212
  result
160
213
  end
@@ -201,7 +254,7 @@ module ActionMCP
201
254
  mime_type: mime_type || @mime_type,
202
255
  size: size,
203
256
  annotations: annotations,
204
- meta: meta
257
+ meta: meta_with_ui(meta)
205
258
  )
206
259
  end
207
260
 
@@ -338,11 +391,13 @@ module ActionMCP
338
391
  #
339
392
  # @example
340
393
  # render_ui(template: "mcp/ui/weather_dashboard")
341
- def render_ui(text: nil, template: nil, layout: false, locals: {})
394
+ def render_ui(text: nil, template: nil, layout: false, locals: {}, meta: nil)
395
+ raise ArgumentError, "render_ui accepts either :text or :template, not both" if !text.nil? && !template.nil?
396
+
342
397
  resolved =
343
- if text
398
+ if !text.nil?
344
399
  text
345
- elsif template
400
+ elsif !template.nil?
346
401
  rendered = ActionMCP::MCPAppRenderer.render(template: template, layout: layout, locals: locals)
347
402
  if rendered.to_s.strip.empty?
348
403
  ActionMCP.logger.warn(
@@ -356,12 +411,11 @@ module ActionMCP
356
411
  raise ArgumentError, "render_ui requires :text or :template"
357
412
  end
358
413
 
359
- ui = self.class.ui_meta
360
414
  ActionMCP::Content::Resource.new(
361
415
  self.class.uri_template,
362
416
  self.class.mime_type || ActionMCP::Apps::MIME_TYPE,
363
417
  text: resolved,
364
- meta: (ui&.any? ? { ui: ui } : nil)
418
+ meta: self.class.meta_with_ui(meta)
365
419
  )
366
420
  end
367
421
 
@@ -30,6 +30,7 @@ module ActionMCP
30
30
  class_attribute :_output_schema_builder, instance_accessor: false, default: nil
31
31
  class_attribute :_additional_properties, instance_accessor: false, default: nil
32
32
  class_attribute :_cached_schema_property_keys, instance_accessor: false, default: nil
33
+ class_attribute :_property_aliases, instance_accessor: false, default: {}
33
34
  class_attribute :_task_support, instance_accessor: false, default: :forbidden
34
35
  class_attribute :_resumable_steps_block, instance_accessor: false, default: nil
35
36
 
@@ -296,16 +297,46 @@ module ActionMCP
296
297
  def schema_property_keys
297
298
  return _cached_schema_property_keys if _cached_schema_property_keys
298
299
 
299
- self._cached_schema_property_keys = _schema_properties.keys.map(&:to_s)
300
+ self._cached_schema_property_keys = (_schema_properties.keys + _property_aliases.keys).map(&:to_s)
300
301
  _cached_schema_property_keys
301
302
  end
302
303
 
303
- # Clear cached keys when properties change - use metaprogramming to avoid duplication
304
- [ :property, :collection ].each do |method_name|
305
- define_method(method_name) do |prop_name, **opts|
306
- invalidate_schema_cache
307
- super(prop_name, **opts)
304
+ # Creates an alternate input name for an existing property.
305
+ #
306
+ # @example
307
+ # property :thread_id, type: "string", required: true
308
+ # alias_property :root_id, :thread_id
309
+ #
310
+ # Both `root_id` and `thread_id` are accepted when initializing the tool,
311
+ # and both readers resolve to the same ActiveModel attribute.
312
+ def alias_property(alias_name, property_name)
313
+ alias_key = alias_name.to_s
314
+ property_key = canonical_property_name(property_name)
315
+
316
+ unless _schema_properties.key?(property_key)
317
+ raise ArgumentError, "Cannot alias unknown property '#{property_name}'"
318
+ end
319
+
320
+ if alias_key == property_key
321
+ raise ArgumentError, "Cannot alias property '#{property_key}' to itself"
322
+ end
323
+
324
+ if _schema_properties.key?(alias_key)
325
+ raise ArgumentError, "Cannot alias '#{alias_key}' because it is already defined as a property"
326
+ end
327
+
328
+ existing_alias = _property_aliases[alias_key]
329
+ if existing_alias && existing_alias != property_key
330
+ raise ArgumentError, "Alias '#{alias_key}' already points to property '#{existing_alias}'"
308
331
  end
332
+
333
+ self._property_aliases = _property_aliases.merge(alias_key => property_key)
334
+ alias_attribute alias_key, property_key
335
+ invalidate_schema_cache
336
+ end
337
+
338
+ def canonical_property_name(name)
339
+ _property_aliases[name.to_s] || name.to_s
309
340
  end
310
341
 
311
342
  private
@@ -332,6 +363,12 @@ module ActionMCP
332
363
  # @param opts [Hash] Additional options for the JSON Schema.
333
364
  # @return [void]
334
365
  def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
366
+ if _property_aliases.key?(prop_name.to_s)
367
+ raise ArgumentError, "Cannot define property '#{prop_name}' because it is already defined as an alias"
368
+ end
369
+
370
+ invalidate_schema_cache
371
+
335
372
  # Build the JSON Schema definition.
336
373
  prop_definition = { type: type }
337
374
  prop_definition[:description] = description if description && !description.empty?
@@ -365,6 +402,12 @@ module ActionMCP
365
402
  def self.collection(prop_name, type:, description: nil, required: false, default: [])
366
403
  raise ArgumentError, "Type is required for a collection" if type.nil?
367
404
 
405
+ if _property_aliases.key?(prop_name.to_s)
406
+ raise ArgumentError, "Cannot define collection '#{prop_name}' because it is already defined as an alias"
407
+ end
408
+
409
+ invalidate_schema_cache
410
+
368
411
  collection_definition = { type: "array", items: { type: type } }
369
412
  collection_definition[:description] = description if description && !description.empty?
370
413
 
@@ -443,6 +486,8 @@ module ActionMCP
443
486
 
444
487
  # Override initialize to validate parameters before ActiveModel conversion
445
488
  def initialize(attributes = {})
489
+ validate_property_alias_conflicts(attributes)
490
+
446
491
  # Separate additional properties from defined attributes if enabled
447
492
  if self.class.accepts_additional_properties?
448
493
  defined_keys = self.class.schema_property_keys
@@ -647,20 +692,43 @@ module ActionMCP
647
692
 
648
693
  private
649
694
 
695
+ def validate_property_alias_conflicts(attributes)
696
+ return unless attributes.is_a?(Hash)
697
+
698
+ seen_values = {}
699
+ seen_keys = {}
700
+
701
+ attributes.each do |key, value|
702
+ key_str = key.to_s
703
+ property_key = self.class.canonical_property_name(key_str)
704
+ next unless self.class._schema_properties.key?(property_key)
705
+
706
+ if seen_values.key?(property_key) && seen_keys[property_key] != key_str && seen_values[property_key] != value
707
+ raise ArgumentError,
708
+ "Conflicting values provided for aliased property '#{property_key}' " \
709
+ "via '#{seen_keys[property_key]}' and '#{key_str}'"
710
+ end
711
+
712
+ seen_values[property_key] = value
713
+ seen_keys[property_key] = key_str
714
+ end
715
+ end
716
+
650
717
  # Validates parameter types before ActiveModel conversion
651
718
  def validate_parameter_types(attributes)
652
719
  return unless attributes.is_a?(Hash)
653
720
 
654
721
  attributes.each do |key, value|
655
722
  key_str = key.to_s
656
- property_schema = self.class._schema_properties[key_str]
723
+ property_key = self.class.canonical_property_name(key_str)
724
+ property_schema = self.class._schema_properties[property_key]
657
725
 
658
726
  next unless property_schema
659
727
 
660
728
  expected_type = property_schema[:type]
661
729
 
662
730
  # Skip validation if value is nil and property is not required
663
- next if value.nil? && !self.class._required_properties.include?(key_str)
731
+ next if value.nil? && !self.class._required_properties.include?(property_key)
664
732
 
665
733
  # Validate based on expected JSON Schema type
666
734
  case expected_type
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.110.2"
5
+ VERSION = "0.111.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -42,7 +42,7 @@ module ActionMCP
42
42
  LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
43
43
  DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
44
44
 
45
- MIME_TYPE_APP_HTML = Apps::MIME_TYPE # MCP Apps UI resources (ext-apps, draft 2026-01-26)
45
+ MIME_TYPE_APP_HTML = Apps::MIME_TYPE # MCP Apps UI resources (ext-apps, stable 2026-01-26)
46
46
  class << self
47
47
  # Returns a Rack-compatible application for serving MCP requests
48
48
  # @return [#call] A Rack application that can be used with `run ActionMCP.server`
@@ -20,6 +20,10 @@ shared:
20
20
  # - "Use this server to manage project tickets and workflows"
21
21
  # - "Helpful for tracking bugs, features, and sprint planning"
22
22
 
23
+ # MCP Apps are an optional extension. Enable this to advertise
24
+ # io.modelcontextprotocol/ui with text/html;profile=mcp-app support.
25
+ # mcp_apps_enabled: true
26
+
23
27
  # MCP capability profiles
24
28
  profiles:
25
29
  primary:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.110.2
4
+ version: 0.111.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih