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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json_schemer"
4
+
3
5
  module ActionMCP
4
6
  module Server
5
7
  # Value object for form-mode elicitation requests.
@@ -9,14 +11,135 @@ module ActionMCP
9
11
  include ActiveModel::Model
10
12
  include ActiveModel::Attributes
11
13
 
14
+ STRING_SCHEMA = {
15
+ "type" => "object",
16
+ "required" => [ "type" ],
17
+ "properties" => {
18
+ "type" => { "const" => "string" },
19
+ "title" => { "type" => "string" },
20
+ "description" => { "type" => "string" },
21
+ "default" => { "type" => "string" },
22
+ "format" => { "enum" => %w[date date-time email uri] },
23
+ "minLength" => { "type" => "integer" },
24
+ "maxLength" => { "type" => "integer" }
25
+ }
26
+ }.freeze
27
+
28
+ NUMBER_SCHEMA = {
29
+ "type" => "object",
30
+ "required" => [ "type" ],
31
+ "properties" => {
32
+ "type" => { "enum" => %w[integer number] },
33
+ "title" => { "type" => "string" },
34
+ "description" => { "type" => "string" },
35
+ "default" => { "type" => "integer" },
36
+ "minimum" => { "type" => "integer" },
37
+ "maximum" => { "type" => "integer" }
38
+ }
39
+ }.freeze
40
+
41
+ BOOLEAN_SCHEMA = {
42
+ "type" => "object",
43
+ "required" => [ "type" ],
44
+ "properties" => {
45
+ "type" => { "const" => "boolean" },
46
+ "title" => { "type" => "string" },
47
+ "description" => { "type" => "string" },
48
+ "default" => { "type" => "boolean" }
49
+ }
50
+ }.freeze
51
+
52
+ ENUM_OPTION_SCHEMA = {
53
+ "type" => "object",
54
+ "required" => %w[const title],
55
+ "properties" => {
56
+ "const" => { "type" => "string" },
57
+ "title" => { "type" => "string" }
58
+ }
59
+ }.freeze
60
+
61
+ UNTITLED_MULTI_SELECT_SCHEMA = {
62
+ "type" => "object",
63
+ "required" => %w[items type],
64
+ "properties" => {
65
+ "type" => { "const" => "array" },
66
+ "title" => { "type" => "string" },
67
+ "description" => { "type" => "string" },
68
+ "default" => { "type" => "array", "items" => { "type" => "string" } },
69
+ "minItems" => { "type" => "integer" },
70
+ "maxItems" => { "type" => "integer" },
71
+ "items" => {
72
+ "type" => "object",
73
+ "required" => %w[enum type],
74
+ "properties" => {
75
+ "type" => { "const" => "string" },
76
+ "enum" => { "type" => "array", "items" => { "type" => "string" } }
77
+ }
78
+ }
79
+ }
80
+ }.freeze
81
+
82
+ TITLED_MULTI_SELECT_SCHEMA = {
83
+ "type" => "object",
84
+ "required" => %w[items type],
85
+ "properties" => {
86
+ "type" => { "const" => "array" },
87
+ "title" => { "type" => "string" },
88
+ "description" => { "type" => "string" },
89
+ "default" => { "type" => "array", "items" => { "type" => "string" } },
90
+ "minItems" => { "type" => "integer" },
91
+ "maxItems" => { "type" => "integer" },
92
+ "items" => {
93
+ "type" => "object",
94
+ "required" => [ "anyOf" ],
95
+ "properties" => {
96
+ "anyOf" => { "type" => "array", "items" => ENUM_OPTION_SCHEMA }
97
+ }
98
+ }
99
+ }
100
+ }.freeze
101
+
102
+ REQUESTED_SCHEMA = {
103
+ "type" => "object",
104
+ "required" => %w[properties type],
105
+ "properties" => {
106
+ "$schema" => { "type" => "string" },
107
+ "type" => { "const" => "object" },
108
+ "required" => { "type" => "array", "items" => { "type" => "string" } },
109
+ "properties" => {
110
+ "type" => "object",
111
+ "additionalProperties" => {
112
+ "anyOf" => [
113
+ STRING_SCHEMA,
114
+ NUMBER_SCHEMA,
115
+ BOOLEAN_SCHEMA,
116
+ UNTITLED_MULTI_SELECT_SCHEMA,
117
+ TITLED_MULTI_SELECT_SCHEMA
118
+ ]
119
+ }
120
+ }
121
+ }
122
+ }.freeze
123
+ REQUESTED_SCHEMA_SCHEMER = JSONSchemer.schema(REQUESTED_SCHEMA)
124
+ TASK_SCHEMA = {
125
+ "type" => "object",
126
+ "additionalProperties" => false,
127
+ "properties" => {
128
+ "ttl" => { "type" => "integer" }
129
+ }
130
+ }.freeze
131
+ TASK_SCHEMER = JSONSchemer.schema(TASK_SCHEMA)
132
+
12
133
  attribute :message, :string
13
134
  attribute :requested_schema # Hash
14
135
  attribute :_meta # Hash, optional
136
+ attribute :task # TaskMetadata, optional
15
137
 
16
138
  validates :message, presence: true
17
139
  validates :requested_schema, presence: true
18
- validate :schema_must_be_object_with_properties, if: -> { requested_schema.present? }
19
- validate :properties_must_be_primitive, if: -> { errors[:requested_schema].empty? && requested_schema.present? }
140
+ validate :requested_schema_must_match_protocol, if: -> { requested_schema.present? }
141
+ validate :meta_must_be_object, if: -> { _meta.present? }
142
+ validate :task_must_match_protocol, unless: -> { task.nil? }
20
143
 
21
144
  # Wrap incoming schema in indifferent access so both string and symbol keys work.
22
145
  def requested_schema=(value)
@@ -27,6 +150,7 @@ module ActionMCP
27
150
  def to_params
28
151
  params = { mode: "form", message: message, requestedSchema: requested_schema.to_hash.deep_symbolize_keys }
29
152
  params[:_meta] = _meta if _meta.present?
153
+ params[:task] = task unless task.nil?
30
154
  params
31
155
  end
32
156
 
@@ -40,60 +164,21 @@ module ActionMCP
40
164
 
41
165
  private
42
166
 
43
- def schema_must_be_object_with_properties
44
- unless requested_schema.is_a?(Hash) && requested_schema[:type] == "object"
45
- errors.add(:requested_schema, "must be an object type")
46
- return
47
- end
48
-
49
- properties = requested_schema[:properties]
50
- errors.add(:requested_schema, "must have properties") unless properties.is_a?(Hash)
51
- end
52
-
53
- def properties_must_be_primitive
54
- properties = requested_schema[:properties]
55
- return unless properties.is_a?(Hash)
56
-
57
- properties.each do |key, prop_schema|
58
- validate_primitive_property(key, prop_schema)
59
- end
60
- end
167
+ def requested_schema_must_match_protocol
168
+ validation_errors = REQUESTED_SCHEMA_SCHEMER.validate(requested_schema.to_h.deep_stringify_keys).to_a
169
+ return if validation_errors.empty?
61
170
 
62
- def validate_primitive_property(key, schema)
63
- unless schema.is_a?(Hash)
64
- errors.add(:requested_schema, "property '#{key}' must have a schema definition")
65
- return
66
- end
67
-
68
- case schema[:type]
69
- when "string"
70
- validate_string_enum(key, schema)
71
- when "number", "integer", "boolean"
72
- # valid primitive types
73
- when "array"
74
- validate_enum_array(key, schema)
75
- else
76
- errors.add(:requested_schema,
77
- "property '#{key}' must be a primitive type (string, number, integer, boolean) or enum array")
78
- end
171
+ pointers = validation_errors.map { |error| error["data_pointer"].presence || "/" }.uniq
172
+ errors.add(:requested_schema, "must match MCP 2025-11-25 at #{pointers.join(', ')}")
79
173
  end
80
174
 
81
- def validate_string_enum(key, schema)
82
- if schema[:enum] && !schema[:enum].is_a?(Array)
83
- errors.add(:requested_schema, "property '#{key}' enum must be an array")
84
- end
175
+ def meta_must_be_object
176
+ errors.add(:_meta, "must be an object") unless _meta.is_a?(Hash)
85
177
  end
86
178
 
87
- def validate_enum_array(key, schema)
88
- items = schema[:items]
89
- unless items.is_a?(Hash)
90
- errors.add(:requested_schema, "property '#{key}' array must have items schema")
91
- return
92
- end
93
-
94
- unless items[:enum].is_a?(Array) || items[:anyOf].is_a?(Array)
95
- errors.add(:requested_schema, "property '#{key}' array items must be an enum (enum or anyOf)")
96
- end
179
+ def task_must_match_protocol
180
+ errors.add(:task, "must match MCP 2025-11-25 TaskMetadata") unless
181
+ task.is_a?(Hash) && TASK_SCHEMER.valid?(task.deep_stringify_keys)
97
182
  end
98
183
  end
99
184
  end
@@ -10,13 +10,16 @@ module ActionMCP
10
10
  # @param params [Hash] Request parameters containing level
11
11
  # @return [Hash] Empty hash on success
12
12
  def handle_logging_set_level(id, params)
13
- # Check if logging is enabled
14
- unless ActionMCP.configuration.logging_enabled
13
+ unless ActionMCP::Logging.supported_by?(transport.session)
15
14
  transport.send_jsonrpc_error(id, :method_not_found, "Logging not enabled")
16
15
  return
17
16
  end
18
17
 
19
- # Extract and validate level parameter
18
+ unless params.is_a?(Hash)
19
+ transport.send_jsonrpc_error(id, :invalid_params, "Logging params must be an object")
20
+ return
21
+ end
22
+
20
23
  level = params[:level] || params["level"]
21
24
  unless level
22
25
  transport.send_jsonrpc_error(id, :invalid_params, "Missing required parameter: level")
@@ -24,8 +27,7 @@ module ActionMCP
24
27
  end
25
28
 
26
29
  begin
27
- # Validate and set the new level
28
- ActionMCP::Logging.set_level(level)
30
+ ActionMCP::Logging.set_level_for(transport.session, level)
29
31
 
30
32
  # Send successful response (empty object per MCP spec)
31
33
  transport.send_jsonrpc_response(id, result: {})
@@ -10,6 +10,10 @@ module ActionMCP
10
10
  params ||= {}
11
11
 
12
12
  with_error_handling(id) do
13
+ unless params.is_a?(Hash)
14
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt params must be an object")
15
+ end
16
+
13
17
  handler = prompt_method_handlers[rpc_method]
14
18
  if handler
15
19
  send(handler, id, params)
@@ -30,8 +34,15 @@ module ActionMCP
30
34
  end
31
35
 
32
36
  def handle_prompts_get(id, params)
33
- name = extract_name(params)
37
+ name = validate_required_param(params, "name", "Prompt name is required")
34
38
  arguments = extract_arguments(params)
39
+ unless arguments.is_a?(Hash)
40
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt arguments must be an object")
41
+ end
42
+ unless arguments.values.all? { |value| value.is_a?(String) }
43
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt argument values must be strings")
44
+ end
45
+
35
46
  transport.send_prompts_get(id, name, arguments)
36
47
  end
37
48
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module ActionMCP
4
6
  module Server
5
7
  module Handlers
@@ -10,6 +12,21 @@ module ActionMCP
10
12
  params ||= {}
11
13
 
12
14
  with_error_handling(id) do
15
+ unless params.is_a?(Hash)
16
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource params must be an object")
17
+ end
18
+
19
+ capabilities = negotiated_resource_capabilities
20
+ unless capabilities
21
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
22
+ message: "Resources are not available for this session")
23
+ end
24
+
25
+ if subscription_method?(rpc_method) && capabilities[:subscribe] != true
26
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
27
+ message: "Resource subscriptions are not available for this session")
28
+ end
29
+
13
30
  handler = resource_method_handlers[rpc_method]
14
31
  if handler
15
32
  send(handler, id, params)
@@ -32,6 +49,17 @@ module ActionMCP
32
49
  }
33
50
  end
34
51
 
52
+ def negotiated_resource_capabilities
53
+ capabilities = (transport.session.server_capabilities || {}).with_indifferent_access
54
+ resource_capabilities = capabilities[:resources]
55
+ resource_capabilities.with_indifferent_access if resource_capabilities.is_a?(Hash)
56
+ end
57
+
58
+ def subscription_method?(rpc_method)
59
+ rpc_method == JsonRpcHandlerBase::Methods::RESOURCES_SUBSCRIBE ||
60
+ rpc_method == JsonRpcHandlerBase::Methods::RESOURCES_UNSUBSCRIBE
61
+ end
62
+
35
63
  def handle_resources_list(id, params)
36
64
  transport.send_resources_list(id, params)
37
65
  end
@@ -41,22 +69,28 @@ module ActionMCP
41
69
  end
42
70
 
43
71
  def handle_resources_read(id, params)
44
- validate_params_present(params, "Resource URI is required")
72
+ validate_resource_uri(params)
45
73
  transport.send_resource_read(id, params)
46
74
  end
47
75
 
48
76
  def handle_resources_subscribe(id, params)
49
- uri = validate_required_param(params, "uri", "Resource URI is required")
50
-
51
- message = transport.send_resource_subscribe(id, uri)
52
- extract_message_payload(message, id)
77
+ uri = validate_resource_uri(params)
78
+ transport.send_resource_subscribe(id, uri)
53
79
  end
54
80
 
55
81
  def handle_resources_unsubscribe(id, params)
82
+ uri = validate_resource_uri(params)
83
+ transport.send_resource_unsubscribe(id, uri)
84
+ end
85
+
86
+ def validate_resource_uri(params)
56
87
  uri = validate_required_param(params, "uri", "Resource URI is required")
88
+ parsed = URI.parse(uri) if uri.is_a?(String)
89
+ return uri if parsed&.scheme.present?
57
90
 
58
- message = transport.send_resource_unsubscribe(id, uri)
59
- extract_message_payload(message, id)
91
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource URI must be an absolute URI")
92
+ rescue URI::InvalidURIError
93
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource URI must be an absolute URI")
60
94
  end
61
95
  end
62
96
  end
@@ -12,11 +12,31 @@ module ActionMCP
12
12
  params ||= {}
13
13
 
14
14
  with_error_handling(id) do
15
+ unless params.is_a?(Hash)
16
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Task params must be an object")
17
+ end
18
+
15
19
  unless transport.session.protocol_version == "2025-11-25"
16
20
  raise JSON_RPC::JsonRpcError.new(:method_not_found,
17
21
  message: "Tasks are only available in MCP 2025-11-25")
18
22
  end
19
23
 
24
+ task_capabilities = negotiated_task_capabilities
25
+ unless task_capabilities
26
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
27
+ message: "Tasks are not available for this session")
28
+ end
29
+
30
+ if rpc_method == JsonRpcHandlerBase::Methods::TASKS_LIST && !task_capabilities.key?(:list)
31
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
32
+ message: "Task listing is not available for this session")
33
+ end
34
+
35
+ if rpc_method == JsonRpcHandlerBase::Methods::TASKS_CANCEL && !task_capabilities.key?(:cancel)
36
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
37
+ message: "Task cancellation is not available for this session")
38
+ end
39
+
20
40
  handler = task_method_handlers[rpc_method]
21
41
  if handler
22
42
  send(handler, id, params)
@@ -38,6 +58,12 @@ module ActionMCP
38
58
  }
39
59
  end
40
60
 
61
+ def negotiated_task_capabilities
62
+ capabilities = (transport.session.server_capabilities || {}).with_indifferent_access
63
+ task_capabilities = capabilities[:tasks]
64
+ task_capabilities.with_indifferent_access if task_capabilities.is_a?(Hash)
65
+ end
66
+
41
67
  def handle_tasks_get(id, params)
42
68
  task_id = validate_required_param(params, "taskId", "Task ID is required")
43
69
  task = find_task_or_error(id, task_id)
@@ -10,6 +10,10 @@ module ActionMCP
10
10
  params ||= {}
11
11
 
12
12
  with_error_handling(id) do
13
+ unless params.is_a?(Hash)
14
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Tool params must be an object")
15
+ end
16
+
13
17
  handler = tool_method_handlers[rpc_method]
14
18
  if handler
15
19
  send(handler, id, params)
@@ -36,6 +40,10 @@ module ActionMCP
36
40
  def handle_tools_call(id, params)
37
41
  name = validate_required_param(params, "name", "Tool name is required")
38
42
  arguments = extract_arguments(params)
43
+ unless arguments.is_a?(Hash)
44
+ raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Tool arguments must be an object")
45
+ end
46
+
39
47
  _meta = params["_meta"] || params[:_meta] || {}
40
48
  task_params = params.key?("task") ? params["task"] : params[:task]
41
49
  transport.send_tools_call(id, name, arguments, _meta, task_params)
@@ -81,20 +81,19 @@ module ActionMCP
81
81
 
82
82
  def handle_response(response)
83
83
  Rails.logger.debug("Received response: #{response.inspect}")
84
- response
84
+ nil
85
85
  end
86
86
 
87
87
  def process_completion_complete(id, params)
88
- # Extract context if provided
89
- context = params["context"] if params.is_a?(Hash)
90
-
91
- transport.send_jsonrpc_response(id, result: build_completion_result(params, context))
88
+ transport.send_completion_complete(id, params)
92
89
  end
93
90
 
94
91
  def process_notifications(rpc_method, params)
95
92
  case rpc_method
96
93
  when Methods::NOTIFICATIONS_INITIALIZED
97
- transport.initialize!
94
+ raise JSON_RPC::JsonRpcError.new(:invalid_request, message: "Session is not awaiting initialization") unless transport.initialize!
95
+ when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED
96
+ transport.refresh_roots_list
98
97
  else
99
98
  super
100
99
  end
@@ -107,21 +106,6 @@ module ActionMCP
107
106
  result: response.result
108
107
  }
109
108
  end
110
-
111
- def build_completion_result(_params = {}, _context = nil)
112
- # In a real implementation, this would use the params and context
113
- # to generate appropriate completion suggestions
114
- # For now, we just return an empty result
115
- #
116
- # params contains:
117
- # - ref: reference to prompt or resource template
118
- # - argument: { name: string, value: string }
119
- # - context: { arguments: { [key]: string } } (previously resolved variables)
120
-
121
- {
122
- completion: { values: [], total: 0, hasMore: false }
123
- }
124
- end
125
109
  end
126
110
  end
127
111
  end
@@ -33,6 +33,8 @@ module ActionMCP
33
33
  end
34
34
 
35
35
  def send_resource_updated_notification(uri)
36
+ return unless session.resource_subscribed?(uri)
37
+
36
38
  send_jsonrpc_notification("notifications/resources/updated", { uri: uri })
37
39
  end
38
40
 
@@ -47,7 +49,7 @@ module ActionMCP
47
49
  def send_logging_message_notification(level:, data:, logger: nil)
48
50
  params = { level: level, data: data }
49
51
  params[:logger] = logger if logger.present?
50
- send_jsonrpc_notification("notifications/logging/message", params)
52
+ send_jsonrpc_notification("notifications/message", params)
51
53
  end
52
54
 
53
55
  def send_progress_notification(progressToken:, progress:, total: nil, message: nil, **options)
@@ -33,13 +33,15 @@ module ActionMCP
33
33
  end
34
34
 
35
35
  if result.is_error
36
- send_jsonrpc_response(request_id, error: result)
36
+ send_jsonrpc_response(request_id, error: result.to_h)
37
37
  else
38
- send_jsonrpc_response(request_id, result: result)
38
+ send_jsonrpc_response(request_id, result: result.to_h)
39
39
  end
40
40
  else
41
- send_jsonrpc_error(request_id, :method_not_found, "Prompt '#{prompt_name}' not available in this session")
41
+ send_jsonrpc_error(request_id, :invalid_params, "Prompt '#{prompt_name}' not available in this session")
42
42
  end
43
+ rescue ArgumentError, ActiveModel::UnknownAttributeError => e
44
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
43
45
  end
44
46
  end
45
47
  end
@@ -46,7 +46,7 @@ module ActionMCP
46
46
  # @param params [Hash] Parameters specifying which resource to read
47
47
  def send_resource_read(id, params)
48
48
  uri = params["uri"]
49
- template = ResourceTemplatesRegistry.find_template_for_uri(uri)
49
+ template = find_registered_template_for_uri(uri)
50
50
 
51
51
  unless template
52
52
  error = {
@@ -108,8 +108,40 @@ module ActionMCP
108
108
  end
109
109
  end
110
110
 
111
+ # Subscribe the current session to updates for an available resource.
112
+ def send_resource_subscribe(id, uri)
113
+ unless find_registered_template_for_uri(uri)
114
+ send_jsonrpc_response(id, error: resource_not_found_error(uri))
115
+ return
116
+ end
117
+
118
+ session.resource_subscribe(uri)
119
+ send_jsonrpc_response(id, result: {})
120
+ end
121
+
122
+ # Remove a resource update subscription. Unsubscribing is idempotent.
123
+ def send_resource_unsubscribe(id, uri)
124
+ session.resource_unsubscribe(uri)
125
+ send_jsonrpc_response(id, result: {})
126
+ end
127
+
111
128
  private
112
129
 
130
+ def find_registered_template_for_uri(uri)
131
+ ResourceTemplatesRegistry.find_template_for_uri(
132
+ uri,
133
+ templates: session.registered_resource_templates
134
+ )
135
+ end
136
+
137
+ def resource_not_found_error(uri)
138
+ {
139
+ code: -32_002,
140
+ message: "Resource not found",
141
+ data: { uri: uri }
142
+ }
143
+ end
144
+
113
145
  # Collect all concrete resources from templates that implement list.
114
146
  # Returns nil if a URI collision error was sent.
115
147
  # @return [Array<ActionMCP::Resource>, nil]
@@ -167,7 +199,8 @@ module ActionMCP
167
199
  def normalize_read_content(content, _uri)
168
200
  case content
169
201
  when ActionMCP::Content::Resource
170
- inner = { uri: content.uri, mimeType: content.mime_type }
202
+ inner = { uri: content.uri }
203
+ inner[:mimeType] = content.mime_type if content.mime_type
171
204
  inner[:text] = content.text if content.text
172
205
  inner[:blob] = content.blob if content.blob
173
206
  inner[:_meta] = content.meta if content.meta && !content.meta.empty?
@@ -10,11 +10,15 @@ module ActionMCP
10
10
  @collected_responses = []
11
11
  end
12
12
 
13
- # Override write_message to collect responses instead of writing them
13
+ # Collect messages for the HTTP response while retaining the session's
14
+ # complete wire history. Server-initiated requests cannot be placed in
15
+ # the empty 202 response required for an incoming notification, so the
16
+ # generated request must not be silently discarded.
14
17
  def write_message(message)
15
18
  if messaging_mode == :return
16
19
  @collected_responses ||= []
17
20
  @collected_responses << message
21
+ super
18
22
  message
19
23
  else
20
24
  super
@@ -3,9 +3,22 @@
3
3
  module ActionMCP
4
4
  module Server
5
5
  module Roots
6
- def send_roots_list(id)
7
- send_jsonrpc_response(id, result: { roots: [] })
6
+ def send_roots_list(id = SecureRandom.uuid_v7)
7
+ roots = session.client_capabilities&.then { |capabilities| capabilities["roots"] || capabilities[:roots] }
8
+ raise UnsupportedRootsError, "Client does not support roots" unless roots.is_a?(Hash)
9
+
10
+ send_jsonrpc_request("roots/list", id: id)
11
+ end
12
+
13
+ def refresh_roots_list
14
+ roots = session.client_capabilities&.then { |capabilities| capabilities["roots"] || capabilities[:roots] }
15
+ return unless roots.is_a?(Hash)
16
+
17
+ list_changed = roots["listChanged"] || roots[:listChanged]
18
+ send_roots_list if list_changed == true
8
19
  end
9
20
  end
21
+
22
+ class UnsupportedRootsError < StandardError; end
10
23
  end
11
24
  end
@@ -7,8 +7,43 @@ module ActionMCP
7
7
  # @param [SamplingRequest] request
8
8
  def send_sampling_create_message(id, request)
9
9
  params = request.is_a?(SamplingRequest) ? request.to_h : request
10
- send_jsonrpc_request(id, "sampling/createMessage", params)
10
+ SamplingRequest.validate_params!(params)
11
+ require_client_sampling_support!(params)
12
+
13
+ send_jsonrpc_request("sampling/createMessage", params: params, id: id)
14
+ end
15
+
16
+ private
17
+
18
+ def require_client_sampling_support!(params)
19
+ client_capabilities = session.client_capabilities || {}
20
+ sampling = client_capabilities["sampling"] || client_capabilities[:sampling]
21
+ raise UnsupportedSamplingError, "Client does not support sampling" unless sampling.is_a?(Hash)
22
+
23
+ if params.key?(:tools) || params.key?("tools") || params.key?(:toolChoice) || params.key?("toolChoice")
24
+ tools = sampling["tools"] || sampling[:tools]
25
+ raise UnsupportedSamplingError, "Client does not support sampling with tools" unless tools.is_a?(Hash)
26
+ end
27
+
28
+ include_context = params[:includeContext] || params["includeContext"]
29
+ if %w[thisServer allServers].include?(include_context)
30
+ context = sampling["context"] || sampling[:context]
31
+ raise UnsupportedSamplingError, "Client does not support sampling context inclusion" unless context.is_a?(Hash)
32
+ end
33
+
34
+ return unless params.key?(:task) || params.key?("task")
35
+
36
+ tasks = client_capabilities["tasks"] || client_capabilities[:tasks]
37
+ task_requests = tasks.is_a?(Hash) && (tasks["requests"] || tasks[:requests])
38
+ task_sampling = task_requests.is_a?(Hash) && (task_requests["sampling"] || task_requests[:sampling])
39
+ create_message = task_sampling.is_a?(Hash) &&
40
+ (task_sampling["createMessage"] || task_sampling[:createMessage])
41
+ return if create_message.is_a?(Hash)
42
+
43
+ raise UnsupportedSamplingError, "Client does not support task-augmented sampling"
11
44
  end
12
45
  end
46
+
47
+ class UnsupportedSamplingError < StandardError; end
13
48
  end
14
49
  end