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.
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 +12 -10
  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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Apps
5
+ # Namespace for ResourceTemplate classes generated from a compiled-views
6
+ # manifest.
7
+ module Views; end
8
+
9
+ # Loads `.action_mcp/views/manifest.json` (emitted by
10
+ # @action-mcp/vite-plugin) and registers one ResourceTemplate per compiled
11
+ # view, serving the bundle inlined into an HTML shell as a `ui://` resource.
12
+ class ViewManifest
13
+ SCHEMA_VERSION = 1
14
+ MANIFEST_FILENAME = "manifest.json"
15
+
16
+ class Error < StandardError; end
17
+
18
+ class << self
19
+ # Registers templates for every view in the manifest. Idempotent:
20
+ # previously generated view classes are evicted first, so rebuilt
21
+ # manifests (new hashes) load cleanly on app reload.
22
+ #
23
+ # @return [Array<Class>] the generated ResourceTemplate subclasses
24
+ def load!(dir = nil)
25
+ dir = resolve_dir(dir)
26
+ manifest_path = File.join(dir, MANIFEST_FILENAME)
27
+ unless File.exist?(manifest_path)
28
+ evict_generated_templates!
29
+ return []
30
+ end
31
+
32
+ data = parse_manifest(manifest_path)
33
+ evict_generated_templates!
34
+
35
+ aliases = {}
36
+ classes = data.fetch("views", {}).map do |name, entry|
37
+ klass, logical_uri, resource_uri = register_view(dir, name, entry)
38
+ if aliases.key?(logical_uri)
39
+ raise Error, "Duplicate logicalUri #{logical_uri.inspect} in views manifest"
40
+ end
41
+
42
+ aliases[logical_uri] = resource_uri
43
+ klass
44
+ end
45
+ @resource_uri_aliases = aliases.freeze
46
+ classes
47
+ rescue StandardError
48
+ evict_generated_templates!
49
+ raise
50
+ end
51
+
52
+ # Resolves the stable URI declared by a tool to the content-addressed
53
+ # resource URI currently registered from the compiled views manifest.
54
+ def resolve_resource_uri(uri)
55
+ (@resource_uri_aliases || {}).fetch(uri, uri)
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_dir(dir)
61
+ path = (dir || ActionMCP.configuration.mcp_apps_views_path).to_s
62
+ return path if File.absolute_path?(path)
63
+
64
+ defined?(Rails) && Rails.root ? Rails.root.join(path).to_s : File.expand_path(path)
65
+ end
66
+
67
+ def parse_manifest(path)
68
+ data = JSON.parse(File.read(path))
69
+ version = data["schemaVersion"]
70
+ unless version == SCHEMA_VERSION
71
+ raise Error, "Unsupported views manifest schemaVersion #{version.inspect} at #{path} " \
72
+ "(expected #{SCHEMA_VERSION}; rebuild with a matching @action-mcp/vite-plugin)"
73
+ end
74
+ data
75
+ rescue JSON::ParserError => e
76
+ raise Error, "Invalid views manifest at #{path}: #{e.message}"
77
+ end
78
+
79
+ def evict_generated_templates!
80
+ generated = Views.constants(false).filter_map do |const|
81
+ Views.const_get(const, false)
82
+ end
83
+
84
+ generated.concat(ResourceTemplate.registered_templates.select { |klass| generated_template?(klass) })
85
+ generated.concat(ResourceTemplatesRegistry.items.values.select { |klass| generated_template?(klass) })
86
+ generated.uniq!
87
+
88
+ ResourceTemplate.registered_templates.delete_if do |klass|
89
+ generated.include?(klass) || generated_template?(klass)
90
+ end
91
+ ResourceTemplatesRegistry.items.delete_if do |_name, klass|
92
+ generated.include?(klass) || generated_template?(klass)
93
+ end
94
+
95
+ Views.constants(false).each do |const|
96
+ Views.send(:remove_const, const)
97
+ end
98
+
99
+ @resource_uri_aliases = {}.freeze
100
+ end
101
+
102
+ def generated_template?(klass)
103
+ klass.is_a?(Class) && klass.name&.start_with?("#{Views.name}::")
104
+ end
105
+
106
+ def register_view(dir, name, entry)
107
+ unless entry.is_a?(Hash)
108
+ raise Error, "Views manifest entry for #{name.inspect} must be an object"
109
+ end
110
+
111
+ bundle_path = File.join(dir, entry.fetch("file"))
112
+ unless File.exist?(bundle_path)
113
+ raise Error, "Views manifest references missing bundle #{entry['file'].inspect} for view #{name.inspect}. " \
114
+ "Rebuild with action-mcp-build-views."
115
+ end
116
+
117
+ uri = entry.fetch("uri")
118
+ logical_uri = entry.fetch("logicalUri", "ui://views/#{name}")
119
+ validate_view_uri!(uri, field: "uri", view_name: name)
120
+ validate_view_uri!(logical_uri, field: "logicalUri", view_name: name)
121
+ ui_meta = entry["ui"]
122
+
123
+ klass = Class.new(ResourceTemplate)
124
+ const_name = "#{name.underscore.camelize}View"
125
+ Views.const_set(const_name, klass)
126
+ # The inheritance hook sees the class before it has a constant name
127
+ # and may cache/register the empty capability name. Reset that state
128
+ # now that the generated class has its final identity.
129
+ klass.remove_instance_variable(:@capability_name) if klass.instance_variable_defined?(:@capability_name)
130
+
131
+ klass.class_eval do
132
+ description "Compiled MCP Apps view: #{name}"
133
+ uri_template uri
134
+ mime_type :mcp_app
135
+ ui(**ui_meta.deep_symbolize_keys) if ui_meta.is_a?(Hash) && ui_meta.any?
136
+
137
+ define_method(:resolve) do
138
+ render_ui(text: ViewManifest.send(:html_shell, name, bundle_path))
139
+ end
140
+ end
141
+
142
+ ResourceTemplatesRegistry.items.delete_if { |_key, registered| registered.equal?(klass) }
143
+ ResourceTemplatesRegistry.register(klass)
144
+
145
+ [ klass, logical_uri, uri ]
146
+ end
147
+
148
+ def validate_view_uri!(uri, field:, view_name:)
149
+ return if uri.is_a?(String) && Apps::URI_SCHEME.match?(uri)
150
+
151
+ raise Error, "Views manifest #{field} for #{view_name.inspect} must be a ui:// URI, got: #{uri.inspect}"
152
+ end
153
+
154
+ def html_shell(name, bundle_path)
155
+ js = File.read(bundle_path).gsub("</script", "<\\/script")
156
+ <<~HTML
157
+ <!doctype html>
158
+ <html>
159
+ <head><meta charset="utf-8"><title>#{ERB::Util.html_escape(name)}</title></head>
160
+ <body>
161
+ <div id="root"></div>
162
+ <script type="module">#{js}</script>
163
+ </body>
164
+ </html>
165
+ HTML
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -11,7 +11,8 @@ module ActionMCP
11
11
  MIME_TYPE = MimeTypes::APP_HTML
12
12
  EXTENSION_SETTINGS = { mimeTypes: [ MIME_TYPE ] }.freeze
13
13
 
14
- # `_meta.ui.csp` directive keys per ext-apps spec.
14
+ # `_meta.ui` fields and nested CSP directive keys per ext-apps spec.
15
+ UI_META_KEYS = %i[csp permissions domain prefersBorder].freeze
15
16
  CSP_KEYS = %i[connectDomains resourceDomains frameDomains baseUriDomains].freeze
16
17
  PERMISSION_KEYS = %i[camera microphone geolocation clipboardWrite].freeze
17
18
 
@@ -19,8 +20,22 @@ module ActionMCP
19
20
  CONNECT_ORIGIN_PATTERN = %r{\A(?:https?|wss?)://[^\s"'<>]+\z}
20
21
  RESOURCE_ORIGIN_PATTERN = %r{\Ahttps?://[^\s"'<>]+\z}
21
22
 
23
+ # Vendored browser bridge: the official self-contained ESM bundle from
24
+ # @modelcontextprotocol/ext-apps (app-with-deps export). Refresh with
25
+ # bin/update-apps-bridge.
26
+ BRIDGE_PACKAGE = "@modelcontextprotocol/ext-apps"
27
+ BRIDGE_VERSION = "1.7.4"
28
+ BRIDGE_PATH = File.expand_path("apps/javascript/ext_apps.js", __dir__)
29
+
22
30
  module_function
23
31
 
32
+ # Raw ESM source of the vendored ext-apps browser bundle, with `</script>`
33
+ # sequences neutralized so it can be inlined into a <script> element.
34
+ # (`<\/` and `/` are identical inside JS strings and regexes.)
35
+ def bridge_source
36
+ @bridge_source ||= File.read(BRIDGE_PATH).gsub("</script", "<\\/script").freeze
37
+ end
38
+
24
39
  def extension_settings
25
40
  EXTENSION_SETTINGS.deep_dup
26
41
  end
@@ -31,7 +46,7 @@ module ActionMCP
31
46
  return false unless settings.is_a?(Hash)
32
47
 
33
48
  mime_types = settings["mimeTypes"] || settings[:mimeTypes]
34
- Array(mime_types).include?(MIME_TYPE)
49
+ mime_types.is_a?(Array) && mime_types.include?(MIME_TYPE)
35
50
  end
36
51
  end
37
52
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "gateway"
4
- require "active_support/core_ext/integer/time"
5
4
 
6
5
  module ActionMCP
7
6
  # Configuration class to hold settings for the ActionMCP server.
@@ -45,13 +44,10 @@ module ActionMCP
45
44
  :tasks_enabled,
46
45
  :tasks_list_enabled,
47
46
  :tasks_cancel_enabled,
48
- :tasks_result_strategy,
49
- :tasks_result_timeout,
50
47
  :tasks_result_poll_interval,
51
48
  # --- MCP Apps Extension Options ---
52
49
  :mcp_apps_enabled,
53
- # --- Schema Validation Options ---
54
- :validate_structured_content,
50
+ :mcp_apps_views_path,
55
51
  # --- Allowed identity keys for gateway ---
56
52
  :allowed_identity_keys,
57
53
  # --- JSON-RPC Path ---
@@ -71,26 +67,24 @@ module ActionMCP
71
67
  # Authentication defaults - empty means all configured identifiers will be tried
72
68
  @authentication_methods = []
73
69
 
74
- @protocol_version = "2025-06-18" # Default to stable version for backwards compatibility
70
+ @protocol_version = "2025-11-25"
75
71
 
76
72
  # Tasks defaults (MCP 2025-11-25)
77
73
  @tasks_enabled = false
78
74
  @tasks_list_enabled = true
79
75
  @tasks_cancel_enabled = true
80
- @tasks_result_strategy = :blocking_http
81
- @tasks_result_timeout = 30.seconds
82
76
  @tasks_result_poll_interval = 0.25
83
77
 
84
78
  # MCP Apps extension defaults to explicit opt-in.
85
79
  @mcp_apps_enabled = false
80
+ # Directory holding compiled view bundles + manifest.json, relative to
81
+ # Rails.root unless absolute.
82
+ @mcp_apps_views_path = ".action_mcp/views"
86
83
 
87
84
  # Pagination - nil means off. Set a number to enable with that page size.
88
85
  # Most MCP clients (including Claude Code) don't follow nextCursor yet.
89
86
  @pagination_page_size = nil
90
87
 
91
- # Schema validation - disabled by default for backward compatibility
92
- @validate_structured_content = false
93
-
94
88
  # Server instructions - empty by default
95
89
  @server_instructions = []
96
90
 
@@ -110,8 +104,9 @@ module ActionMCP
110
104
  # Path for JSON-RPC endpoint
111
105
  @base_path = "/"
112
106
 
113
- # Allowed origins for DNS rebinding protection (nil = derive from request.host)
114
- @allowed_origins = nil
107
+ # Browser origins allowed to reach a local server. Remote deployments must
108
+ # explicitly add their trusted origin hosts.
109
+ @allowed_origins = %w[localhost 127.0.0.1 ::1].freeze
115
110
  end
116
111
 
117
112
  def name
@@ -163,19 +158,6 @@ module ActionMCP
163
158
  end
164
159
  end
165
160
 
166
- def tasks_result_strategy=(value)
167
- strategy = value.to_sym
168
- unless %i[blocking_http polling_only].include?(strategy)
169
- raise ArgumentError, "tasks_result_strategy must be :blocking_http or :polling_only, got: #{value.inspect}"
170
- end
171
-
172
- @tasks_result_strategy = strategy
173
- end
174
-
175
- def tasks_result_timeout=(value)
176
- @tasks_result_timeout = normalize_positive_duration(value, "tasks_result_timeout")
177
- end
178
-
179
161
  def tasks_result_poll_interval=(value)
180
162
  @tasks_result_poll_interval = normalize_positive_duration(value, "tasks_result_poll_interval")
181
163
  end
@@ -184,12 +166,14 @@ module ActionMCP
184
166
  # Resolve gateway class lazily to account for Zeitwerk autoloading
185
167
  # This allows ApplicationGateway to be loaded from app/mcp even if the
186
168
  # configuration is initialized before Zeitwerk runs
187
- if @gateway_class_name
169
+ if instance_variable_defined?(:@gateway_class)
170
+ @gateway_class
171
+ elsif @gateway_class_name
188
172
  @gateway_class_name.constantize
189
173
  elsif defined?(::ApplicationGateway)
190
174
  ::ApplicationGateway
191
175
  else
192
- ActionMCP::Gateway
176
+ nil
193
177
  end
194
178
  end
195
179
 
@@ -305,6 +289,7 @@ module ActionMCP
305
289
  capabilities[:prompts] = { listChanged: @list_changed } if profile && profile[:prompts]&.any?
306
290
 
307
291
  capabilities[:logging] = {} if @logging_enabled
292
+ capabilities[:completions] = {}
308
293
 
309
294
  # If profile includes resources, advertise resources capability
310
295
  if profile && profile[:resources]&.any?
@@ -442,13 +427,12 @@ module ActionMCP
442
427
  self.pagination_page_size = config["pagination_page_size"]
443
428
  end
444
429
 
445
- self.tasks_result_strategy = config["tasks_result_strategy"] if config.key?("tasks_result_strategy")
446
- self.tasks_result_timeout = config["tasks_result_timeout"] if config.key?("tasks_result_timeout")
447
430
  if config.key?("tasks_result_poll_interval")
448
431
  self.tasks_result_poll_interval = config["tasks_result_poll_interval"]
449
432
  end
450
433
 
451
434
  @mcp_apps_enabled = boolean_config_value(config["mcp_apps_enabled"]) if config.key?("mcp_apps_enabled")
435
+ @mcp_apps_views_path = config["mcp_apps_views_path"].to_s if config.key?("mcp_apps_views_path")
452
436
 
453
437
  # Extract allowed origins for DNS rebinding protection
454
438
  if config["allowed_origins"]
@@ -17,13 +17,16 @@ module ActionMCP
17
17
  super("audio", annotations: annotations)
18
18
  @data = data
19
19
  @mime_type = mime_type
20
+ to_h
20
21
  end
21
22
 
22
23
  # Returns a hash representation of the audio content.
23
24
  #
24
25
  # @return [Hash] The hash representation of the audio content.
25
26
  def to_h
26
- super.merge(data: @data, mimeType: @mime_type)
27
+ super.merge(data: @data, mimeType: @mime_type).tap do |result|
28
+ Validation.validate_content_block!(result)
29
+ end
27
30
  end
28
31
  end
29
32
  end
@@ -12,7 +12,8 @@ module ActionMCP
12
12
  # @param annotations [Hash, nil] Optional annotations for the content.
13
13
  def initialize(type, annotations: nil)
14
14
  @type = type
15
- @annotations = annotations
15
+ @annotations = annotations.nil? ? nil : Validation.copy_object!(annotations, "annotations")
16
+ Validation.validate_annotations!(@annotations)
16
17
  end
17
18
 
18
19
  # Returns a hash representation of the base content.
@@ -21,6 +22,7 @@ module ActionMCP
21
22
  def to_h
22
23
  h = { type: @type }
23
24
  h[:annotations] = @annotations if @annotations
25
+ Validation.validate_annotations!(@annotations)
24
26
  h
25
27
  end
26
28
  end
@@ -17,13 +17,16 @@ module ActionMCP
17
17
  super("image", annotations: annotations)
18
18
  @data = data
19
19
  @mime_type = mime_type
20
+ to_h
20
21
  end
21
22
 
22
23
  # Returns a hash representation of the image content.
23
24
  #
24
25
  # @return [Hash] The hash representation of the image content.
25
26
  def to_h
26
- super.merge(data: @data, mimeType: @mime_type)
27
+ super.merge(data: @data, mimeType: @mime_type).tap do |result|
28
+ Validation.validate_content_block!(result)
29
+ end
27
30
  end
28
31
  end
29
32
  end
@@ -21,22 +21,23 @@ module ActionMCP
21
21
  # @param annotations [Hash, nil] Optional annotations for the resource.
22
22
  # @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
23
23
  def initialize(uri, mime_type = "text/plain", text: nil, blob: nil, annotations: nil, meta: nil)
24
+ raise ArgumentError, "uri must be a non-empty string" unless uri.is_a?(String) && uri.present?
25
+ unless mime_type.nil? || (mime_type.is_a?(String) && mime_type.present?)
26
+ raise ArgumentError, "mime_type must be a non-empty string or nil"
27
+ end
28
+ unless [ !text.nil?, !blob.nil? ].one?
29
+ raise ArgumentError, "embedded resources require exactly one of text or blob"
30
+ end
31
+ raise ArgumentError, "text must be a string" if !text.nil? && !text.is_a?(String)
32
+ raise ArgumentError, "blob must be a base64 string" if !blob.nil? && !blob.is_a?(String)
33
+
24
34
  super("resource", annotations: annotations)
25
35
  @uri = uri
26
36
  @mime_type = mime_type
27
37
  @text = text
28
38
  @blob = blob
29
- @annotations = annotations
30
- @meta =
31
- if meta.nil?
32
- nil
33
- elsif meta.respond_to?(:to_hash)
34
- meta.to_hash
35
- elsif meta.respond_to?(:to_h)
36
- meta.to_h
37
- else
38
- raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
39
- end
39
+ @meta = meta.nil? ? nil : Validation.copy_object!(meta, "meta")
40
+ to_h
40
41
  end
41
42
 
42
43
  # Returns a hash representation of the resource content.
@@ -46,13 +47,13 @@ module ActionMCP
46
47
  #
47
48
  # @return [Hash] The hash representation of the resource content.
48
49
  def to_h
49
- inner = { uri: @uri, mimeType: @mime_type }
50
+ inner = { uri: @uri }
51
+ inner[:mimeType] = @mime_type if @mime_type
50
52
  inner[:text] = @text if @text
51
53
  inner[:blob] = @blob if @blob
52
- inner[:annotations] = @annotations if @annotations
53
54
  inner[:_meta] = @meta if @meta && !@meta.empty?
54
55
 
55
- { type: @type, resource: inner }
56
+ super.merge(resource: inner).tap { |result| Validation.validate_content_block!(result) }
56
57
  end
57
58
  end
58
59
  end
@@ -7,7 +7,7 @@ module ActionMCP
7
7
  # Note: resource links returned by tools are not guaranteed to appear in resources/list requests.
8
8
  class ResourceLink < Base
9
9
  # @return [String] The URI of the resource.
10
- # @return [String, nil] The name of the resource (optional).
10
+ # @return [String] The name of the resource.
11
11
  # @return [String, nil] The description of the resource (optional).
12
12
  # @return [String, nil] The MIME type of the resource (optional).
13
13
  attr_reader :uri, :name, :description, :mime_type
@@ -15,26 +15,36 @@ module ActionMCP
15
15
  # Initializes a new ResourceLink content.
16
16
  #
17
17
  # @param uri [String] The URI of the resource.
18
- # @param name [String, nil] The name of the resource (optional).
18
+ # @param name [String] The name of the resource.
19
19
  # @param description [String, nil] The description of the resource (optional).
20
20
  # @param mime_type [String, nil] The MIME type of the resource (optional).
21
21
  # @param annotations [Hash, nil] Optional annotations for the resource link.
22
- def initialize(uri, name: nil, description: nil, mime_type: nil, annotations: nil)
22
+ def initialize(uri, name:, description: nil, mime_type: nil, annotations: nil)
23
+ raise ArgumentError, "uri must be a non-empty string" unless uri.is_a?(String) && uri.present?
24
+ raise ArgumentError, "name must be a non-empty string" unless name.is_a?(String) && name.present?
25
+ unless description.nil? || description.is_a?(String)
26
+ raise ArgumentError, "description must be a string or nil"
27
+ end
28
+ unless mime_type.nil? || mime_type.is_a?(String)
29
+ raise ArgumentError, "mime_type must be a string or nil"
30
+ end
31
+
23
32
  super("resource_link", annotations: annotations)
24
33
  @uri = uri
25
34
  @name = name
26
35
  @description = description
27
36
  @mime_type = mime_type
37
+ to_h
28
38
  end
29
39
 
30
40
  # Returns a hash representation of the resource link content.
31
41
  #
32
42
  # @return [Hash] The hash representation of the resource link content.
33
43
  def to_h
34
- result = super.merge(uri: @uri)
35
- result[:name] = @name if @name
44
+ result = super.merge(uri: @uri, name: @name)
36
45
  result[:description] = @description if @description
37
46
  result[:mimeType] = @mime_type if @mime_type
47
+ Validation.validate_content_block!(result)
38
48
  result
39
49
  end
40
50
  end
@@ -14,13 +14,14 @@ module ActionMCP
14
14
  def initialize(text, annotations: nil)
15
15
  super("text", annotations: annotations)
16
16
  @text = text.to_s
17
+ to_h
17
18
  end
18
19
 
19
20
  # Returns a hash representation of the text content.
20
21
  #
21
22
  # @return [Hash] The hash representation of the text content.
22
23
  def to_h
23
- super.merge(text: @text)
24
+ super.merge(text: @text).tap { |result| Validation.validate_content_block!(result) }
24
25
  end
25
26
  end
26
27
  end