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
@@ -15,10 +15,13 @@ module ActionMCP
15
15
  attribute :url, :string
16
16
  attribute :elicitation_id, :string
17
17
  attribute :_meta # Hash, optional
18
+ attribute :task # TaskMetadata, optional
18
19
 
19
20
  validates :message, presence: true
20
21
  validates :url, presence: true
21
22
  validate :url_must_be_valid_http, if: -> { url.present? }
23
+ validate :meta_must_be_object, if: -> { _meta.present? }
24
+ validate :task_must_match_protocol, unless: -> { task.nil? }
22
25
 
23
26
  def initialize(attributes = {})
24
27
  super
@@ -34,6 +37,7 @@ module ActionMCP
34
37
  elicitationId: elicitation_id
35
38
  }
36
39
  params[:_meta] = _meta if _meta.present?
40
+ params[:task] = task unless task.nil?
37
41
  params
38
42
  end
39
43
 
@@ -55,6 +59,15 @@ module ActionMCP
55
59
  rescue URI::InvalidURIError
56
60
  errors.add(:url, "is not a valid URI")
57
61
  end
62
+
63
+ def meta_must_be_object
64
+ errors.add(:_meta, "must be an object") unless _meta.is_a?(Hash)
65
+ end
66
+
67
+ def task_must_match_protocol
68
+ errors.add(:task, "must match MCP 2025-11-25 TaskMetadata") unless
69
+ task.is_a?(Hash) && ElicitationRequest::TASK_SCHEMER.valid?(task.deep_stringify_keys)
70
+ end
58
71
  end
59
72
  end
60
73
  end
@@ -3,6 +3,7 @@
3
3
  require "action_mcp/types/float_array_type"
4
4
  require "action_mcp/types/hash_type"
5
5
  require "action_mcp/schema_helpers"
6
+ require "action_mcp/schema_validator"
6
7
 
7
8
  module ActionMCP
8
9
  # Base class for defining tools.
@@ -28,12 +29,16 @@ module ActionMCP
28
29
  class_attribute :_meta, instance_accessor: false, default: {}
29
30
  class_attribute :_requires_consent, instance_accessor: false, default: false
30
31
  class_attribute :_output_schema_builder, instance_accessor: false, default: nil
31
- class_attribute :_additional_properties, instance_accessor: false, default: nil
32
+ class_attribute :_additional_properties, instance_accessor: false, default: false
32
33
  class_attribute :_cached_schema_property_keys, instance_accessor: false, default: nil
34
+ class_attribute :_input_schemer, instance_accessor: false, default: nil
35
+ class_attribute :_output_schemer, instance_accessor: false, default: nil
33
36
  class_attribute :_property_aliases, instance_accessor: false, default: {}
34
37
  class_attribute :_task_support, instance_accessor: false, default: :forbidden
35
38
  class_attribute :_resumable_steps_block, instance_accessor: false, default: nil
36
39
 
40
+ validate :input_arguments_match_schema
41
+
37
42
  # --------------------------------------------------------------------------
38
43
  # Tool Name and Description DSL
39
44
  # --------------------------------------------------------------------------
@@ -131,7 +136,15 @@ module ActionMCP
131
136
 
132
137
  # Class method to call the tool with arguments
133
138
  def call(arguments = {})
134
- new(arguments).call
139
+ from_wire(arguments).call
140
+ end
141
+
142
+ # Builds a tool from decoded MCP arguments. The untouched input is kept
143
+ # for JSON Schema validation before ActiveModel coercion.
144
+ def from_wire(arguments)
145
+ new(arguments).tap do |tool|
146
+ tool.instance_variable_set(:@_wire_arguments, arguments)
147
+ end
135
148
  end
136
149
 
137
150
  # Helper methods for checking annotations
@@ -160,19 +173,12 @@ module ActionMCP
160
173
 
161
174
  builder = OutputSchemaBuilder.new
162
175
  builder.instance_eval(&block)
176
+ schema = builder.to_json_schema
177
+ schemer = SchemaValidator.compile(schema, context: "output schema for #{tool_name}")
163
178
 
164
- # Store both the builder and the generated schema
165
179
  self._output_schema_builder = builder
166
- self._output_schema = builder.to_json_schema
167
-
168
- _output_schema
169
- end
170
-
171
- # Legacy output_schema method for backward compatibility
172
- def output_schema_legacy(schema = nil)
173
- if schema
174
- raise NotImplementedError, "Legacy output schema not yet implemented. Use output_schema DSL instead!"
175
- end
180
+ self._output_schema = schema
181
+ self._output_schemer = schemer
176
182
 
177
183
  _output_schema
178
184
  end
@@ -284,7 +290,12 @@ module ActionMCP
284
290
  if enabled.nil?
285
291
  _additional_properties
286
292
  else
293
+ unless enabled == true || enabled == false || enabled.is_a?(Hash)
294
+ raise ArgumentError, "additional_properties must be true, false, or a JSON Schema hash"
295
+ end
296
+
287
297
  self._additional_properties = enabled
298
+ invalidate_schema_cache
288
299
  end
289
300
  end
290
301
 
@@ -301,6 +312,46 @@ module ActionMCP
301
312
  _cached_schema_property_keys
302
313
  end
303
314
 
315
+ def input_schema
316
+ properties = _schema_properties.deep_dup
317
+ _property_aliases.each do |alias_name, property_name|
318
+ properties[alias_name] = _schema_properties.fetch(property_name).deep_dup
319
+ end
320
+
321
+ schema = {
322
+ "$schema" => SchemaValidator::DEFAULT_DIALECT,
323
+ type: "object",
324
+ properties: properties
325
+ }
326
+
327
+ required = []
328
+ alias_constraints = []
329
+ _required_properties.each do |property_name|
330
+ aliases = _property_aliases.select { |_alias_name, target| target == property_name }.keys
331
+ if aliases.empty?
332
+ required << property_name
333
+ else
334
+ alternatives = [ property_name, *aliases ].map { |name| { required: [ name ] } }
335
+ alias_constraints << { anyOf: alternatives }
336
+ end
337
+ end
338
+
339
+ schema[:required] = required if required.any?
340
+ schema[:allOf] = alias_constraints if alias_constraints.any?
341
+ add_additional_properties_to_schema(schema, _additional_properties)
342
+ schema
343
+ end
344
+
345
+ def input_schemer
346
+ self._input_schemer ||= SchemaValidator.compile(input_schema, context: "input schema for #{tool_name}")
347
+ end
348
+
349
+ def output_schemer
350
+ return unless _output_schema
351
+
352
+ self._output_schemer ||= SchemaValidator.compile(_output_schema, context: "output schema for #{tool_name}")
353
+ end
354
+
304
355
  # Creates an alternate input name for an existing property.
305
356
  #
306
357
  # @example
@@ -344,6 +395,7 @@ module ActionMCP
344
395
  # Invalidate cached schema property keys
345
396
  def invalidate_schema_cache
346
397
  self._cached_schema_property_keys = nil
398
+ self._input_schemer = nil
347
399
  end
348
400
  end
349
401
 
@@ -381,11 +433,6 @@ module ActionMCP
381
433
 
382
434
  # Map the JSON Schema type to an ActiveModel attribute type.
383
435
  attribute prop_name, map_json_type_to_active_model_type(type), default: default
384
- validates prop_name, presence: true, if: -> { required }
385
-
386
- return unless %w[number integer].include?(type)
387
-
388
- validates prop_name, numericality: true, allow_nil: !required
389
436
  end
390
437
 
391
438
  # --------------------------------------------------------------------------
@@ -424,14 +471,6 @@ module ActionMCP
424
471
  end
425
472
 
426
473
  attribute prop_name, mapped_type, default: default
427
-
428
- # For arrays, we need to check if the attribute is nil, not if it's empty
429
- return unless required
430
-
431
- validates prop_name, presence: true, unless: -> { send(prop_name).is_a?(Array) }
432
- validate do
433
- errors.add(prop_name, "can't be blank") if send(prop_name).nil?
434
- end
435
474
  end
436
475
 
437
476
  # --------------------------------------------------------------------------
@@ -441,14 +480,8 @@ module ActionMCP
441
480
  #
442
481
  # @return [Hash] The tool definition.
443
482
  def self.to_h(protocol_version: nil)
444
- schema = {
445
- type: "object",
446
- properties: _schema_properties
447
- }
448
- schema[:required] = _required_properties if _required_properties.any?
449
-
450
- # Add additionalProperties if configured
451
- add_additional_properties_to_schema(schema, _additional_properties)
483
+ schema = input_schema
484
+ input_schemer
452
485
 
453
486
  result = {
454
487
  name: tool_name,
@@ -465,45 +498,62 @@ module ActionMCP
465
498
 
466
499
  # Add execution metadata (MCP 2025-11-25)
467
500
  # Only include if not default (forbidden) to minimize payload
468
- if _task_support && _task_support != :forbidden && task_metadata_supported?(protocol_version)
501
+ if _task_support && _task_support != :forbidden
469
502
  result[:execution] = execution_metadata
470
503
  end
471
504
 
472
505
  # Add _meta if present
473
- result[:_meta] = _meta if _meta.any?
506
+ if _meta.any?
507
+ resolved_meta = _meta.deep_dup
508
+ ui_meta = resolved_meta[:ui] || resolved_meta["ui"]
509
+ if ui_meta.is_a?(Hash)
510
+ resource_uri_key = :resourceUri if ui_meta.key?(:resourceUri)
511
+ resource_uri_key ||= "resourceUri" if ui_meta.key?("resourceUri")
512
+ if resource_uri_key
513
+ ui_meta[resource_uri_key] = Apps::ViewManifest.resolve_resource_uri(ui_meta[resource_uri_key])
514
+ end
515
+ end
516
+ result[:_meta] = resolved_meta
517
+ end
474
518
 
475
519
  result
476
520
  end
477
521
 
478
- def self.task_metadata_supported?(protocol_version)
479
- protocol_version.nil? || protocol_version == "2025-11-25"
480
- end
481
- private_class_method :task_metadata_supported?
482
-
483
522
  # --------------------------------------------------------------------------
484
523
  # Instance Methods
485
524
  # --------------------------------------------------------------------------
486
525
 
487
- # Override initialize to validate parameters before ActiveModel conversion
488
526
  def initialize(attributes = {})
489
- validate_property_alias_conflicts(attributes)
490
-
491
- # Separate additional properties from defined attributes if enabled
492
- if self.class.accepts_additional_properties?
493
- defined_keys = self.class.schema_property_keys
494
- # Use partition for single-pass separation - more efficient than except/slice
495
- defined_attrs, additional_attrs = attributes.partition { |k, _|
496
- defined_keys.include?(k.to_s)
497
- }.map(&:to_h)
498
- @_additional_params = additional_attrs
499
- attributes = defined_attrs
500
- else
501
- @_additional_params = {}
527
+ @_alias_conflicts = []
528
+
529
+ defined_attributes = {}
530
+ additional_attributes = {}
531
+
532
+ if attributes.is_a?(Hash)
533
+ sources = {}
534
+ attributes.each do |key, value|
535
+ key_string = key.to_s
536
+ property_name = self.class.canonical_property_name(key_string)
537
+
538
+ if self.class._schema_properties.key?(property_name)
539
+ if defined_attributes.key?(property_name) && sources[property_name] != key_string &&
540
+ defined_attributes[property_name] != value
541
+ @_alias_conflicts <<
542
+ "conflicting values for '#{property_name}' via '#{sources[property_name]}' and '#{key_string}'"
543
+ next
544
+ end
545
+
546
+ defined_attributes[property_name] = value
547
+ sources[property_name] = key_string
548
+ else
549
+ additional_attributes[key] = value
550
+ end
551
+ end
502
552
  end
503
553
 
504
- # Validate parameters before ActiveModel processes them
505
- validate_parameter_types(attributes)
506
- super
554
+ @_provided_additional_attributes = additional_attributes
555
+ @_additional_params = self.class.accepts_additional_properties? ? additional_attributes : {}
556
+ super(defined_attributes)
507
557
  end
508
558
 
509
559
  # Returns additional parameters that were passed but not defined in the schema
@@ -530,18 +580,20 @@ module ActionMCP
530
580
  else
531
581
  e.message
532
582
  end
533
- @response.mark_as_error!(:internal_error, message: error_message)
583
+ @response.report_tool_error(error_message)
534
584
  end
535
585
  else
536
- @response.mark_as_error!(:invalid_params,
537
- message: "Invalid input",
538
- data: errors.full_messages)
586
+ @response.report_tool_error("Invalid input: #{errors.full_messages.join(', ')}")
539
587
  end
540
588
 
541
589
  # If callbacks halted execution (`performed` still false) and
542
- # nothing else marked an error, surface it as invalid_params.
590
+ # nothing else marked an error, surface it as a tool execution error.
543
591
  if !performed && !@response.error?
544
- @response.mark_as_error!(:invalid_params, message: "Tool execution was aborted")
592
+ @response.report_tool_error("Tool execution was aborted")
593
+ end
594
+
595
+ if performed && !@response.error? && self.class._output_schema && @response.structured_content.nil?
596
+ @response.report_tool_error("Tool declared an output schema but returned no structured content")
545
597
  end
546
598
 
547
599
  @response
@@ -565,11 +617,9 @@ module ActionMCP
565
617
 
566
618
  # Override render to collect Content objects and support structured content
567
619
  def render(structured: nil, **args)
568
- if structured
569
- # Validate structured content against output_schema if enabled
620
+ unless structured.nil?
570
621
  validate_structured_content!(structured) if self.class._output_schema
571
622
 
572
- # Render structured content
573
623
  set_structured_content(structured)
574
624
  structured
575
625
  else
@@ -632,45 +682,29 @@ module ActionMCP
632
682
  @response.set_structured_content(content)
633
683
  end
634
684
 
635
- # Validates structured content against the declared output_schema
636
- # Only runs if validate_structured_content is enabled in configuration
685
+ def input_arguments_match_schema
686
+ validation_input = if defined?(@_wire_arguments)
687
+ @_wire_arguments
688
+ else
689
+ attributes.compact.merge(@_provided_additional_attributes.deep_stringify_keys)
690
+ end
691
+ schema_errors = SchemaValidator.validate(self.class.input_schemer, validation_input)
692
+ SchemaValidator.error_messages(schema_errors).each { |message| errors.add(:base, message) }
693
+ @_alias_conflicts.each { |message| errors.add(:base, message) }
694
+ end
695
+
696
+ # Validates structured content against the declared output_schema.
637
697
  # @param content [Hash] The structured content to validate
638
698
  # @raise [StructuredContentValidationError] If content doesn't match schema
639
699
  def validate_structured_content!(content)
640
- return unless ActionMCP.configuration.validate_structured_content
641
-
642
- schema = self.class._output_schema
643
- return unless schema.present?
644
-
645
- # Lazy load json_schemer - only required if validation is enabled
646
- gem "json_schemer", ">= 2.4"
647
- require "json_schemer"
648
-
649
- schemer = JSONSchemer.schema(schema.deep_stringify_keys)
650
- errors = schemer.validate(deep_stringify_content(content)).to_a
651
-
652
- return if errors.empty?
700
+ schema_errors = SchemaValidator.validate(self.class.output_schemer, content)
701
+ return if schema_errors.empty?
653
702
 
654
- error_messages = errors.map { |e| e["error"] }.join(", ")
655
703
  raise ActionMCP::StructuredContentValidationError,
656
- "Structured content does not match output_schema: #{error_messages}"
704
+ "Structured content does not match output_schema: " \
705
+ "#{SchemaValidator.error_messages(schema_errors).join(', ')}"
657
706
  end
658
707
 
659
- # Deep stringify keys for validation (handles symbols and nested structures)
660
- def deep_stringify_content(content)
661
- case content
662
- when Hash
663
- content.transform_keys(&:to_s).transform_values { |v| deep_stringify_content(v) }
664
- when Array
665
- content.map { |v| deep_stringify_content(v) }
666
- else
667
- content
668
- end
669
- end
670
-
671
- private
672
-
673
-
674
708
  # Maps a JSON Schema type to an ActiveModel attribute type.
675
709
  #
676
710
  # @param type [String] The JSON Schema type.
@@ -689,105 +723,5 @@ module ActionMCP
689
723
  end
690
724
 
691
725
  private_class_method :map_json_type_to_active_model_type
692
-
693
- private
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
-
717
- # Validates parameter types before ActiveModel conversion
718
- def validate_parameter_types(attributes)
719
- return unless attributes.is_a?(Hash)
720
-
721
- attributes.each do |key, value|
722
- key_str = key.to_s
723
- property_key = self.class.canonical_property_name(key_str)
724
- property_schema = self.class._schema_properties[property_key]
725
-
726
- next unless property_schema
727
-
728
- expected_type = property_schema[:type]
729
-
730
- # Skip validation if value is nil and property is not required
731
- next if value.nil? && !self.class._required_properties.include?(property_key)
732
-
733
- # Validate based on expected JSON Schema type
734
- case expected_type
735
- when "number"
736
- validate_number_parameter(key_str, value)
737
- when "integer"
738
- validate_integer_parameter(key_str, value)
739
- when "string"
740
- validate_string_parameter(key_str, value)
741
- when "boolean"
742
- validate_boolean_parameter(key_str, value)
743
- when "array"
744
- validate_array_parameter(key_str, value, property_schema)
745
- end
746
- end
747
- end
748
-
749
- def validate_number_parameter(key, value)
750
- return if value.is_a?(Numeric)
751
-
752
- raise ArgumentError, "Parameter '#{key}' must be a number, got: #{value.class}" unless value.is_a?(String)
753
-
754
- # Check if string can be converted to a valid number
755
- begin
756
- Float(value)
757
- rescue ArgumentError, TypeError
758
- raise ArgumentError, "Parameter '#{key}' must be a valid number, got: #{value.inspect}"
759
- end
760
- end
761
-
762
- def validate_integer_parameter(key, value)
763
- return if value.is_a?(Integer)
764
-
765
- raise ArgumentError, "Parameter '#{key}' must be an integer, got: #{value.class}" unless value.is_a?(String)
766
-
767
- # Check if string can be converted to a valid integer
768
- begin
769
- Integer(value)
770
- rescue ArgumentError, TypeError
771
- raise ArgumentError, "Parameter '#{key}' must be a valid integer, got: #{value.inspect}"
772
- end
773
- end
774
-
775
- def validate_string_parameter(key, value)
776
- return if value.is_a?(String)
777
-
778
- raise ArgumentError, "Parameter '#{key}' must be a string, got: #{value.class}"
779
- end
780
-
781
- def validate_boolean_parameter(key, value)
782
- return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
783
-
784
- raise ArgumentError, "Parameter '#{key}' must be a boolean, got: #{value.class}"
785
- end
786
-
787
- def validate_array_parameter(key, value, _property_schema)
788
- return if value.is_a?(Array)
789
-
790
- raise ArgumentError, "Parameter '#{key}' must be an array, got: #{value.class}"
791
- end
792
726
  end
793
727
  end
@@ -16,41 +16,58 @@ module ActionMCP
16
16
 
17
17
  # Add content to the response
18
18
  def add(content)
19
+ serialize_content(content)
19
20
  @contents << content
20
21
  content # Return the content for chaining
21
22
  end
22
23
 
23
24
  # Set structured content for the response
24
25
  def set_structured_content(content)
25
- @structured_content = content
26
+ raise ArgumentError, "structuredContent must be a JSON object" unless content.is_a?(Hash)
27
+
28
+ @structured_content = Content::Validation.copy_object!(content, "structuredContent")
26
29
  end
27
30
 
28
31
  # Report a tool execution error (as opposed to protocol error)
29
32
  # This follows MCP spec for tool execution errors
30
33
  def report_tool_error(message)
31
34
  @tool_execution_error = true
32
- add(Content::Text.new(message))
35
+ add(Content::Text.new(message.to_s))
36
+ end
37
+
38
+ def error?
39
+ super || @tool_execution_error
40
+ end
41
+
42
+ def success?
43
+ !error?
33
44
  end
34
45
 
35
46
  def to_h(_options = nil)
36
47
  if @tool_execution_error
37
48
  result = {
38
49
  isError: true,
39
- content: @contents.map(&:to_h)
50
+ content: serialized_contents
40
51
  }
41
52
  result[:structuredContent] = @structured_content if @structured_content
53
+ Content::Validation.validate_tool_result!(result)
42
54
  result
43
55
  else
44
56
  super
45
57
  end
46
58
  end
47
59
 
60
+ def as_json(options = nil)
61
+ to_h(options)
62
+ end
63
+
48
64
  # Implementation of build_success_hash for ToolResponse
49
65
  def build_success_hash
50
66
  result = {
51
- content: @contents.map(&:to_h)
67
+ content: serialized_contents
52
68
  }
53
69
  result[:structuredContent] = @structured_content if @structured_content
70
+ Content::Validation.validate_tool_result!(result)
54
71
  result
55
72
  end
56
73
 
@@ -73,5 +90,19 @@ module ActionMCP
73
90
  parts << "isError: #{is_error}"
74
91
  "#<#{self.class.name} #{parts.join(', ')}>"
75
92
  end
93
+
94
+ private
95
+
96
+ def serialized_contents
97
+ @contents.map { |content| serialize_content(content) }
98
+ end
99
+
100
+ def serialize_content(content)
101
+ unless content.respond_to?(:to_h)
102
+ raise ArgumentError, "content must respond to #to_h"
103
+ end
104
+
105
+ content.to_h.tap { |result| Content::Validation.validate_content_block!(result) }
106
+ end
76
107
  end
77
108
  end
@@ -17,7 +17,7 @@ module ActionMCP
17
17
  # @return [Hash] A hash containing the tool's response.
18
18
  def tool_call(tool_name, arguments, _metadata = {})
19
19
  tool_class = find(tool_name)
20
- tool = tool_class.new(arguments)
20
+ tool = tool_class.from_wire(arguments)
21
21
 
22
22
  tool.call
23
23
  rescue RegistryBase::NotFound
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.111.1"
5
+ VERSION = "0.200.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -34,13 +34,10 @@ module ActionMCP
34
34
  class StructuredContentValidationError < StandardError; end
35
35
 
36
36
  # Protocol version constants
37
- SUPPORTED_VERSIONS = [
38
- "2025-11-25", # The Task Master - Tasks, icons, tool naming, polling SSE
39
- "2025-06-18" # Dr. Identity McBouncer - elicitation, structured output, resource links
40
- ].freeze
37
+ SUPPORTED_VERSIONS = [ "2025-11-25" ].freeze
41
38
 
42
39
  LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
43
- DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
40
+ DEFAULT_PROTOCOL_VERSION = LATEST_VERSION
44
41
 
45
42
  MIME_TYPE_APP_HTML = Apps::MIME_TYPE # MCP Apps UI resources (ext-apps, stable 2026-01-26)
46
43
  class << self
@@ -103,7 +103,7 @@ module ActionMCP
103
103
  def session_database_lookup
104
104
  <<~RUBY.indent(4)
105
105
  # Get #{identity} ID from session
106
- #{identity}_id = session&.[]('#{identity}_id')
106
+ #{identity}_id = session&.[]("#{identity}_id")
107
107
  raise Unauthorized, "No #{identity} session" unless #{identity}_id
108
108
 
109
109
  # Look up #{identity} in database
@@ -16,7 +16,7 @@ class <%= class_name %> < ActionMCP::GatewayIdentifier
16
16
  authenticates :<%= auth_method %>
17
17
 
18
18
  def resolve
19
- <%= resolve_implementation %>
19
+ <%= resolve_implementation.chomp %>
20
20
  end
21
21
 
22
22
  private
@@ -1,14 +1,25 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Start ActionMCP as a standalone server.
4
+ # ActionMCP binstub.
5
5
  #
6
6
  # Usage:
7
- # bin/mcp # start on default port 62770 (MCPS0 on a phone keypad)
8
- # bin/mcp 3001 # start on custom port
7
+ # bin/mcp # start the server on default port 62770 (MCPS0)
8
+ # bin/mcp 3001 # start the server on a custom port
9
+ # bin/mcp server 3001 # explicit server subcommand
10
+ # bin/mcp dev # dev loop: server + view watcher (+ --tunnel)
11
+ # bin/mcp dev --help # dev options
9
12
 
10
13
  require "bundler/setup"
11
14
 
15
+ if ARGV.first == "dev"
16
+ require "actionmcp"
17
+ require "action_mcp/dev/runner"
18
+ exit ActionMCP::Dev::Runner.new(root: Dir.pwd, argv: ARGV.drop(1)).run
19
+ end
20
+
21
+ # `server` is an optional explicit alias; a bare port keeps working.
22
+ ARGV.shift if ARGV.first == "server"
12
23
  port = ARGV[0] || 62_770
13
24
  rackup = File.expand_path("../mcp/config.ru", __dir__)
14
25
 
@@ -17,12 +28,13 @@ begin
17
28
  Gem::Specification.find_by_name("falcon")
18
29
  puts "Starting ActionMCP server with Falcon on port #{port}..."
19
30
  exec("bundle", "exec", "falcon", "serve",
20
- "--bind", "http://0.0.0.0:#{port}",
31
+ "--bind", "http://127.0.0.1:#{port}",
21
32
  "--config", rackup)
22
33
  rescue Gem::MissingSpecError
23
34
  puts "Starting ActionMCP server with Puma on port #{port}..."
24
35
  puts " (tip: add 'falcon' to your Gemfile for better concurrency)"
25
36
  exec("bundle", "exec", "rails", "server",
26
37
  "-c", rackup,
38
+ "-b", "127.0.0.1",
27
39
  "-p", port.to_s)
28
40
  end