actionmcp 0.111.0 → 0.200.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +13 -8
- 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
|
@@ -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
|
data/lib/action_mcp/tool.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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.
|
|
583
|
+
@response.report_tool_error(error_message)
|
|
534
584
|
end
|
|
535
585
|
else
|
|
536
|
-
@response.
|
|
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
|
|
590
|
+
# nothing else marked an error, surface it as a tool execution error.
|
|
543
591
|
if !performed && !@response.error?
|
|
544
|
-
@response.
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
20
|
+
tool = tool_class.from_wire(arguments)
|
|
21
21
|
|
|
22
22
|
tool.call
|
|
23
23
|
rescue RegistryBase::NotFound
|
data/lib/action_mcp/version.rb
CHANGED
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 =
|
|
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&.[](
|
|
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
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# ActionMCP binstub.
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
|
-
# bin/mcp
|
|
8
|
-
# bin/mcp 3001
|
|
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://
|
|
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
|