actionmcp 0.109.0 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbc03b1ba75f2efaf3c94ff2e664d3ca4e21013e8c55651f1fccff0b8c4e889b
4
- data.tar.gz: 1576d864115954653182d05080352a28b7be2a11c8fb376f0e426503a320eb7f
3
+ metadata.gz: b49e1bd20853c60ec17b35b3ba822a275b8258800ce615707dcf85ecaa606dc8
4
+ data.tar.gz: c1abbbe666dbea818abde461aaae5d65f867700fa96e6d5c8011b12b10be1fcd
5
5
  SHA512:
6
- metadata.gz: da5bcfd15ecc9e76b4ac6c557a84a95cf91090520ec7d11fb6c3652168c80cc1820660a8311b8b173db8bd4a1773a26cd53e83b2bae4fb6874f0b79aff489482
7
- data.tar.gz: 28235fee76f77b9a7a87cca20a149231d31649a980e2d19480fde5f38543d0aa7ca61d16e7d97d89a83de9d849dda6c9e323e03cbc73dacca68572a7ba2dbaf5
6
+ metadata.gz: 984a0a14b69ed4684ee7054db880320711c18613d4f5242922ac6eeb6946fb4c21954831c4ac935796f234f3abf8e691bc24c8ab219910fce8f9dd7a77600828
7
+ data.tar.gz: 8356febeb0097488d56a50e42160cf09c5e9be1e9952bf170fefb6f56e991079efc183bbca2e2080d2d3d9406c21a57e9211888eee405171bc4730903904559e
data/README.md CHANGED
@@ -315,7 +315,7 @@ class BatchIndexTool < ApplicationMCPTool
315
315
  end
316
316
  ```
317
317
 
318
- Call it as a task from a client by adding `_meta.task` (creates a Task record and runs the tool via `ToolExecutionJob`):
318
+ Call it as a task from a client by adding top-level `task` params (creates a Task record and runs the tool via `ToolExecutionJob`):
319
319
 
320
320
  ```json
321
321
  {
@@ -325,12 +325,27 @@ Call it as a task from a client by adding `_meta.task` (creates a Task record an
325
325
  "params": {
326
326
  "name": "batch_index",
327
327
  "arguments": { "items": ["a", "b", "c"] },
328
- "_meta": { "task": { "ttl": 120000, "pollInterval": 2000 } }
328
+ "task": { "ttl": 120000 }
329
329
  }
330
330
  }
331
331
  ```
332
332
 
333
- Poll task status with `tasks/get` or fetch the result when finished with `tasks/result`. Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available — treat cursors as opaque tokens.
333
+ Poll task status with `tasks/get` or fetch the result with `tasks/result`.
334
+ By default, `tasks/result` uses spec-aligned blocking HTTP: if the task is still working, the request waits until the task reaches `completed`, `failed`, `cancelled`, or `input_required`, then returns one JSON response. Configure the wait bounds for your Rails app:
335
+
336
+ ```ruby
337
+ config.action_mcp.tasks_result_strategy = :blocking_http
338
+ config.action_mcp.tasks_result_timeout = 30.seconds
339
+ config.action_mcp.tasks_result_poll_interval = 0.25.seconds
340
+ ```
341
+
342
+ Rails apps that cannot hold request workers open can opt into `:polling_only`, where clients must poll `tasks/get` until terminal or `input_required` before calling `tasks/result`. This is a deliberate MCP spec deviation:
343
+
344
+ ```ruby
345
+ config.action_mcp.tasks_result_strategy = :polling_only
346
+ ```
347
+
348
+ Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available; treat cursors as opaque tokens.
334
349
 
335
350
  ### ActionMCP::ResourceTemplate
336
351
 
@@ -1282,6 +1297,21 @@ For comprehensive client documentation, including examples, session management,
1282
1297
  - **Implement proper authorization** in your tools and prompts
1283
1298
  - **Validate all inputs** using property definitions and Rails validations
1284
1299
  - **Use consent management** for sensitive operations
1300
+ - **Protect against DNS rebinding**: ActionMCP validates the `Origin` header on every request. If `Origin` is present and its host doesn't match the server's own host, the server returns HTTP 403 with a JSON-RPC error body (no `id`), as required by the MCP spec. Non-browser clients (Claude Desktop, CLI tools) don't send `Origin` and are unaffected. To allow additional trusted origins, configure `allowed_origins`:
1301
+
1302
+ ```ruby
1303
+ # config/initializers/action_mcp.rb
1304
+ ActionMCP.configure do |config|
1305
+ config.allowed_origins = ["app.example.com", "api.example.com"]
1306
+ end
1307
+ ```
1308
+
1309
+ For defence-in-depth, also configure Rails' `ActionDispatch::HostAuthorization` to restrict which `Host` headers are accepted (a separate check against host-header injection):
1310
+
1311
+ ```ruby
1312
+ # config/environments/production.rb
1313
+ config.hosts = ["api.example.com"]
1314
+ ```
1285
1315
 
1286
1316
  ### Performance
1287
1317
 
@@ -11,6 +11,9 @@ module ActionMCP
11
11
  include JSONRPC_Rails::ControllerHelpers
12
12
  include ActionController::Instrumentation
13
13
 
14
+ # Origin validation is enforced by ActionMCP::Middleware::OriginValidation
15
+ # (see engine.rb) so invalid requests are rejected before routing.
16
+
14
17
  # Provides the ActionMCP::Session for the current request.
15
18
  # Handles finding existing sessions via header/param or initializing a new one.
16
19
  # Specific controllers/handlers might need to enforce session ID presence based on context.
@@ -53,6 +53,7 @@ module ActionMCP
53
53
  session = task.session
54
54
  unless session
55
55
  task.update(status_message: "Session not found")
56
+ task.result_payload = { code: -32_603, message: "Session not found" }
56
57
  task.mark_failed!
57
58
  return nil
58
59
  end
@@ -64,6 +65,10 @@ module ActionMCP
64
65
  tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
65
66
  unless tool_class
66
67
  @task.update(status_message: "Tool '#{tool_name}' not found")
68
+ @task.result_payload = {
69
+ code: -32_601,
70
+ message: "Tool '#{tool_name}' not found"
71
+ }
67
72
  @task.mark_failed!
68
73
  return nil
69
74
  end
@@ -101,12 +106,13 @@ module ActionMCP
101
106
  def update_task_result(task, result)
102
107
  return if task.terminal? # Guard against double-complete
103
108
 
104
- if result.is_error
105
- task.result_payload = result.to_h
109
+ payload = result.to_h
110
+ if result.is_error || payload[:isError] || payload["isError"]
111
+ task.result_payload = payload
106
112
  task.status_message = result.respond_to?(:error_message) ? result.error_message : "Tool returned error"
107
113
  task.mark_failed!
108
114
  else
109
- task.result_payload = result.to_h
115
+ task.result_payload = payload
110
116
  task.record_step!(:completed)
111
117
  task.complete!
112
118
  end
@@ -123,6 +129,10 @@ module ActionMCP
123
129
 
124
130
  task.update(
125
131
  status_message: "Job failed: #{error.message}",
132
+ result_payload: {
133
+ code: -32_603,
134
+ message: "Job failed: #{error.message}"
135
+ },
126
136
  continuation_state: {
127
137
  step: :failed,
128
138
  error: { class: error.class.name, message: error.message },
@@ -52,6 +52,8 @@ module ActionMCP
52
52
  # input_required -> completed | failed | cancelled
53
53
  #
54
54
  class Task < ApplicationRecord
55
+ RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"
56
+
55
57
  self.table_name = "action_mcp_session_tasks"
56
58
 
57
59
  attribute :id, :string, default: -> { SecureRandom.uuid_v7 }
@@ -139,6 +141,10 @@ module ActionMCP
139
141
  status.in?(%w[completed failed cancelled])
140
142
  end
141
143
 
144
+ def result_ready?
145
+ terminal? || input_required?
146
+ end
147
+
142
148
  # Check if task is in a non-terminal state
143
149
  def non_terminal?
144
150
  !terminal?
@@ -148,32 +154,73 @@ module ActionMCP
148
154
  # @return [Hash] Task data for JSON-RPC responses
149
155
  def to_task_data
150
156
  data = {
151
- id: id,
157
+ taskId: id,
152
158
  status: status,
153
- lastUpdatedAt: last_updated_at.iso8601(3)
159
+ createdAt: created_at.iso8601(3),
160
+ lastUpdatedAt: last_updated_at.iso8601(3),
161
+ ttl: ttl
154
162
  }
155
163
  data[:statusMessage] = status_message if status_message.present?
156
-
157
- # Add progress if available (ActiveJob::Continuable support)
158
- if progress_percent.present? || progress_message.present?
159
- data[:progress] = {}.tap do |progress|
160
- progress[:percent] = progress_percent if progress_percent.present?
161
- progress[:message] = progress_message if progress_message.present?
162
- end
163
- end
164
+ data[:pollInterval] = poll_interval if poll_interval.present?
164
165
 
165
166
  data
166
167
  end
167
168
 
168
- # Convert to full task result format
169
- # @return [Hash] Complete task with result for tasks/result response
170
- def to_task_result
169
+ def to_create_task_result
171
170
  {
172
171
  task: to_task_data,
173
- result: result_payload
172
+ _meta: related_task_meta
174
173
  }
175
174
  end
176
175
 
176
+ # Convert to the original request's result payload for tasks/result.
177
+ # The result carries related-task metadata because its structure does not
178
+ # otherwise include the task identifier.
179
+ # @return [Hash] Result payload for tasks/result response
180
+ def to_task_result
181
+ payload = result_payload.is_a?(Hash) ? result_payload.deep_dup : {}
182
+ meta = payload.delete("_meta") || payload.delete(:_meta) || {}
183
+ meta = meta.to_h if meta.respond_to?(:to_h)
184
+ meta = {} unless meta.is_a?(Hash)
185
+
186
+ payload[:_meta] = meta.deep_merge(related_task_meta)
187
+ payload
188
+ end
189
+
190
+ def to_task_error
191
+ return unless result_payload.is_a?(Hash)
192
+
193
+ code = result_payload["code"] || result_payload[:code]
194
+ message = result_payload["message"] || result_payload[:message]
195
+ return unless code && message
196
+ return if result_payload.key?("content") || result_payload.key?(:content)
197
+ return if result_payload.key?("isError") || result_payload.key?(:isError)
198
+
199
+ error = { code: code, message: message }
200
+ data = result_payload["data"] || result_payload[:data]
201
+ error[:data] = data unless data.nil?
202
+ error
203
+ end
204
+
205
+ def related_task_meta
206
+ {
207
+ RELATED_TASK_META_KEY => {
208
+ "taskId" => id
209
+ }
210
+ }
211
+ end
212
+
213
+ def request_meta_with_related_task(meta = nil)
214
+ existing_meta =
215
+ if meta.respond_to?(:to_h)
216
+ meta.to_h.deep_dup
217
+ else
218
+ {}
219
+ end
220
+
221
+ existing_meta.deep_merge(related_task_meta)
222
+ end
223
+
177
224
  # Broadcast status change notification to the session
178
225
  # @param transition [StateMachines::Transition] The state transition that occurred
179
226
  def broadcast_status_change(transition = nil)
@@ -114,7 +114,7 @@ module ActionMCP
114
114
  payload = {
115
115
  protocolVersion: protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION,
116
116
  serverInfo: server_info,
117
- capabilities: server_capabilities
117
+ capabilities: capabilities_for_protocol(server_capabilities)
118
118
  }
119
119
  # Add instructions at top level if configured
120
120
  instructions = ActionMCP.configuration.instructions
@@ -130,6 +130,23 @@ module ActionMCP
130
130
  super(parsed_json_attribute(value))
131
131
  end
132
132
 
133
+ def capabilities_for_protocol(capabilities)
134
+ parsed = parsed_json_attribute(capabilities)
135
+ filtered =
136
+ if parsed.respond_to?(:deep_dup)
137
+ parsed.deep_dup
138
+ elsif parsed
139
+ parsed.dup
140
+ else
141
+ {}
142
+ end
143
+ return filtered if protocol_version == "2025-11-25"
144
+
145
+ filtered.delete("tasks")
146
+ filtered.delete(:tasks)
147
+ filtered
148
+ end
149
+
133
150
  def initialize!
134
151
  # update the session initialized to true
135
152
  return false if initialized?
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Constants for the MCP Apps extension (ext-apps, SEP-1865, draft 2026-01-26).
5
+ # See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
6
+ module Apps
7
+ EXTENSION_KEY = "io.modelcontextprotocol/ui"
8
+ VISIBILITY_VALUES = %w[model app].freeze
9
+ URI_SCHEME = %r{\Aui://\S+\z}
10
+ MIME_TYPE = MimeTypes::APP_HTML
11
+
12
+ # `_meta.ui.csp` directive keys per ext-apps spec.
13
+ CSP_KEYS = %i[connectDomains resourceDomains frameDomains baseUriDomains].freeze
14
+
15
+ # Accepts http/https origins only. Wildcard subdomain (`https://*.example.com`),
16
+ # ports, and paths are allowed. WebSocket origins (ws://, wss://) are not
17
+ # accepted by ActionMCP — declare them via `ws://` over fetch if you must.
18
+ ORIGIN_PATTERN = %r{\Ahttps?://[^\s"'<>]+\z}
19
+ end
20
+ end
@@ -32,6 +32,11 @@ module ActionMCP
32
32
 
33
33
  delegate :session_data, to: :session, allow_nil: true
34
34
 
35
+ # Returns true when the connected client advertises the MCP Apps UI extension.
36
+ def client_supports_ui?
37
+ !session&.client_capabilities&.dig("extensions", Apps::EXTENSION_KEY).nil?
38
+ end
39
+
35
40
  # use _capability_name or default_capability_name
36
41
  def self.capability_name
37
42
  _capability_name || default_capability_name
@@ -45,12 +45,17 @@ module ActionMCP
45
45
  :tasks_enabled,
46
46
  :tasks_list_enabled,
47
47
  :tasks_cancel_enabled,
48
+ :tasks_result_strategy,
49
+ :tasks_result_timeout,
50
+ :tasks_result_poll_interval,
48
51
  # --- Schema Validation Options ---
49
52
  :validate_structured_content,
50
53
  # --- Allowed identity keys for gateway ---
51
54
  :allowed_identity_keys,
52
55
  # --- JSON-RPC Path ---
53
- :base_path
56
+ :base_path,
57
+ # --- Origin validation (DNS rebinding protection) ---
58
+ :allowed_origins
54
59
 
55
60
  def initialize
56
61
  @logging_enabled = false
@@ -70,6 +75,9 @@ module ActionMCP
70
75
  @tasks_enabled = false
71
76
  @tasks_list_enabled = true
72
77
  @tasks_cancel_enabled = true
78
+ @tasks_result_strategy = :blocking_http
79
+ @tasks_result_timeout = 30.seconds
80
+ @tasks_result_poll_interval = 0.25
73
81
 
74
82
  # Pagination - nil means off. Set a number to enable with that page size.
75
83
  # Most MCP clients (including Claude Code) don't follow nextCursor yet.
@@ -96,6 +104,9 @@ module ActionMCP
96
104
 
97
105
  # Path for JSON-RPC endpoint
98
106
  @base_path = "/"
107
+
108
+ # Allowed origins for DNS rebinding protection (nil = derive from request.host)
109
+ @allowed_origins = nil
99
110
  end
100
111
 
101
112
  def name
@@ -147,6 +158,23 @@ module ActionMCP
147
158
  end
148
159
  end
149
160
 
161
+ def tasks_result_strategy=(value)
162
+ strategy = value.to_sym
163
+ unless %i[blocking_http polling_only].include?(strategy)
164
+ raise ArgumentError, "tasks_result_strategy must be :blocking_http or :polling_only, got: #{value.inspect}"
165
+ end
166
+
167
+ @tasks_result_strategy = strategy
168
+ end
169
+
170
+ def tasks_result_timeout=(value)
171
+ @tasks_result_timeout = normalize_positive_duration(value, "tasks_result_timeout")
172
+ end
173
+
174
+ def tasks_result_poll_interval=(value)
175
+ @tasks_result_poll_interval = normalize_positive_duration(value, "tasks_result_poll_interval")
176
+ end
177
+
150
178
  def gateway_class
151
179
  # Resolve gateway class lazily to account for Zeitwerk autoloading
152
180
  # This allows ApplicationGateway to be loaded from app/mcp even if the
@@ -402,6 +430,17 @@ module ActionMCP
402
430
  if config.key?("pagination_page_size")
403
431
  self.pagination_page_size = config["pagination_page_size"]
404
432
  end
433
+
434
+ self.tasks_result_strategy = config["tasks_result_strategy"] if config.key?("tasks_result_strategy")
435
+ self.tasks_result_timeout = config["tasks_result_timeout"] if config.key?("tasks_result_timeout")
436
+ if config.key?("tasks_result_poll_interval")
437
+ self.tasks_result_poll_interval = config["tasks_result_poll_interval"]
438
+ end
439
+
440
+ # Extract allowed origins for DNS rebinding protection
441
+ if config["allowed_origins"]
442
+ @allowed_origins = Array(config["allowed_origins"])
443
+ end
405
444
  end
406
445
 
407
446
  def should_include_all?(type)
@@ -424,6 +463,13 @@ module ActionMCP
424
463
  Array(instructions).map(&:to_s)
425
464
  end
426
465
 
466
+ def normalize_positive_duration(value, setting_name)
467
+ duration = value.respond_to?(:to_f) ? value.to_f : value.to_s.to_f
468
+ raise ArgumentError, "#{setting_name} must be positive, got: #{value.inspect}" unless duration.positive?
469
+
470
+ duration
471
+ end
472
+
427
473
  def ensure_mcp_components_loaded
428
474
  # Only load if we haven't loaded yet - but in development, always reload
429
475
  return if @mcp_components_loaded && !Rails.env.development?
@@ -46,6 +46,9 @@ module ActionMCP
46
46
  end
47
47
 
48
48
  initializer "action_mcp.insert_middleware" do |app|
49
+ config.middleware.use ActionDispatch::HostAuthorization, app.config.hosts if app.config.hosts.present?
50
+ config.middleware.use ActionMCP::Middleware::OriginValidation,
51
+ [ ActionMCP.configuration.base_path ].compact.freeze
49
52
  config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.base_path ].compact.freeze
50
53
  end
51
54
 
@@ -40,7 +40,6 @@ module ActionMCP
40
40
  TASKS_RESULT = "tasks/result"
41
41
  TASKS_LIST = "tasks/list"
42
42
  TASKS_CANCEL = "tasks/cancel"
43
- TASKS_RESUME = "tasks/resume"
44
43
 
45
44
  # Task notifications
46
45
  NOTIFICATIONS_TASKS_STATUS = "notifications/tasks/status"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ActionMCP
6
+ module Middleware
7
+ # Rack middleware that validates the Origin header on MCP requests to
8
+ # prevent DNS rebinding attacks per the MCP Streamable HTTP security
9
+ # section. Non-browser clients (Claude Desktop, curl) never send Origin
10
+ # and are always allowed. Present Origins must match either
11
+ # `ActionMCP.configuration.allowed_origins` or the server's own host.
12
+ #
13
+ # Runs as middleware — same layer as `ActionDispatch::HostAuthorization` —
14
+ # so invalid requests are rejected before they reach routing.
15
+ class OriginValidation
16
+ INVALID_REQUEST_CODE = -32_600
17
+
18
+ # @param app [#call]
19
+ # @param paths [Array<String, Regexp, Proc>, nil] paths to guard.
20
+ # Nil or empty means every request is guarded.
21
+ def initialize(app, paths = nil)
22
+ @app = app
23
+ @paths = Array(paths)
24
+ end
25
+
26
+ def call(env)
27
+ return @app.call(env) unless guard_path?(env["PATH_INFO"])
28
+
29
+ request = ActionDispatch::Request.new(env)
30
+ origin = request.origin
31
+ return @app.call(env) if origin.nil? || origin.empty?
32
+ return @app.call(env) if origin_allowed?(origin, request)
33
+
34
+ forbidden_response
35
+ end
36
+
37
+ private
38
+
39
+ def guard_path?(path)
40
+ return true if @paths.empty?
41
+
42
+ @paths.any? do |matcher|
43
+ case matcher
44
+ when String then path == matcher
45
+ when Regexp then matcher.match?(path)
46
+ when Proc then matcher.call(path)
47
+ else false
48
+ end
49
+ end
50
+ end
51
+
52
+ def origin_allowed?(origin, request)
53
+ return false if origin == "null"
54
+
55
+ uri = URI.parse(origin)
56
+ return false if uri.host.nil? || uri.host.empty?
57
+
58
+ origin_host = strip_brackets(uri.host)
59
+ allowed = ActionMCP.configuration.allowed_origins
60
+
61
+ if allowed && !allowed.empty?
62
+ allowed.any? { |pattern| match?(pattern, origin_host) }
63
+ else
64
+ # ActionDispatch::Request#host strips the port and handles IPv6 brackets
65
+ origin_host.casecmp?(strip_brackets(request.host))
66
+ end
67
+ rescue URI::InvalidURIError
68
+ false
69
+ end
70
+
71
+ def match?(pattern, host)
72
+ case pattern
73
+ when Regexp then pattern.match?(host)
74
+ when String then host.casecmp?(strip_brackets(pattern))
75
+ else false
76
+ end
77
+ end
78
+
79
+ def strip_brackets(host)
80
+ host.to_s.delete_prefix("[").delete_suffix("]")
81
+ end
82
+
83
+ def forbidden_response
84
+ body = {
85
+ jsonrpc: "2.0",
86
+ id: nil,
87
+ error: { code: INVALID_REQUEST_CODE, message: "Forbidden: invalid Origin header" }
88
+ }.to_json
89
+
90
+ [ 403, { "Content-Type" => "application/json" }, [ body ] ]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Engine-owned MIME type registry. Keeps protocol-level MIME values out of
5
+ # Rails' global `Mime::Type` registry while still letting the DSL accept
6
+ # short symbols. For symbols not in our table, falls back to Rails' global
7
+ # registry so apps can still use their own registered formats.
8
+ module MimeTypes
9
+ APP_HTML = "text/html;profile=mcp-app" # MCP Apps (ext-apps, SEP-1865)
10
+
11
+ TYPES = {
12
+ mcp_app: APP_HTML
13
+ }.freeze
14
+
15
+ # Resolve a user-supplied MIME value to a wire string.
16
+ #
17
+ # @param value [Symbol, String, Mime::Type]
18
+ # @return [String]
19
+ def self.resolve(value)
20
+ case value
21
+ when Symbol
22
+ TYPES[value] || Mime[value]&.to_s || raise(KeyError, "unknown MIME type: #{value.inspect}")
23
+ when Mime::Type
24
+ value.to_s
25
+ else
26
+ value.to_s
27
+ end
28
+ end
29
+ end
30
+ end
@@ -20,7 +20,7 @@ module ActionMCP
20
20
 
21
21
  class << self
22
22
  attr_reader :registered_templates, :description, :uri_template,
23
- :mime_type, :template_name, :parameters, :_meta
23
+ :mime_type, :template_name, :parameters, :_meta, :ui_meta
24
24
 
25
25
  def abstract?
26
26
  @abstract ||= false
@@ -94,7 +94,9 @@ module ActionMCP
94
94
  end
95
95
 
96
96
  def mime_type(value = nil)
97
- value ? @mime_type = value : @mime_type
97
+ return @mime_type unless value
98
+
99
+ @mime_type = ActionMCP::MimeTypes.resolve(value)
98
100
  end
99
101
 
100
102
  # Sets or retrieves the _meta field
@@ -109,6 +111,38 @@ module ActionMCP
109
111
  end
110
112
  end
111
113
 
114
+ # Declares MCP Apps UI metadata for this resource template. Stored verbatim
115
+ # (camelCase keys per the ext-apps spec). Used both for the `resources/list`
116
+ # entry's `_meta.ui` and the default content `_meta.ui` produced by
117
+ # `render_ui`.
118
+ #
119
+ # @example
120
+ # ui csp: { connectDomains: %w[api.openweathermap.org] }, prefersBorder: true
121
+ def ui(**data)
122
+ raise ArgumentError, "ui metadata must not be empty" if data.empty?
123
+
124
+ validate_ui_csp_origins!(data[:csp])
125
+ @ui_meta ||= {}
126
+ @ui_meta = @ui_meta.deep_merge(data)
127
+ end
128
+
129
+ private
130
+
131
+ def validate_ui_csp_origins!(csp)
132
+ return unless csp.is_a?(Hash)
133
+
134
+ Apps::CSP_KEYS.each do |key|
135
+ Array(csp[key]).each do |origin|
136
+ next if origin.is_a?(String) && Apps::ORIGIN_PATTERN.match?(origin)
137
+
138
+ raise ArgumentError,
139
+ "ui csp #{key} entries must be http(s):// origins, got: #{origin.inspect}"
140
+ end
141
+ end
142
+ end
143
+
144
+ public
145
+
112
146
  def to_h
113
147
  name_value = defined?(@template_name) ? @template_name : name.demodulize.underscore.gsub(/_template$/, "")
114
148
 
@@ -298,6 +332,31 @@ module ActionMCP
298
332
  end
299
333
  end
300
334
 
335
+ # Build a `Content::Resource` for an MCP Apps UI view. Accepts either a raw
336
+ # `:text` string or a Rails `:template` path; the class-level `ui` macro
337
+ # supplies the content-level `_meta.ui` automatically.
338
+ #
339
+ # @example
340
+ # render_ui(template: "mcp/ui/weather_dashboard")
341
+ def render_ui(text: nil, template: nil, layout: false, locals: {})
342
+ resolved =
343
+ if text
344
+ text
345
+ elsif template
346
+ ApplicationController.render(template: template, layout: layout, locals: locals)
347
+ else
348
+ raise ArgumentError, "render_ui requires :text or :template"
349
+ end
350
+
351
+ ui = self.class.ui_meta
352
+ ActionMCP::Content::Resource.new(
353
+ self.class.uri_template,
354
+ self.class.mime_type || ActionMCP::Apps::MIME_TYPE,
355
+ text: resolved,
356
+ meta: (ui&.any? ? { ui: ui } : nil)
357
+ )
358
+ end
359
+
301
360
  # Initialize with attribute values
302
361
  def initialize(attributes = {})
303
362
  super(attributes)
@@ -118,7 +118,7 @@ module ActionMCP
118
118
  payload = {
119
119
  protocolVersion: ActionMCP::LATEST_VERSION,
120
120
  serverInfo: server_info,
121
- capabilities: server_capabilities
121
+ capabilities: capabilities_for_protocol(server_capabilities)
122
122
  }
123
123
  # Add instructions at top level if configured
124
124
  instructions = ActionMCP.configuration.instructions
@@ -280,6 +280,22 @@ module ActionMCP
280
280
 
281
281
  private
282
282
 
283
+ def capabilities_for_protocol(capabilities)
284
+ filtered =
285
+ if capabilities.respond_to?(:deep_dup)
286
+ capabilities.deep_dup
287
+ elsif capabilities
288
+ capabilities.dup
289
+ else
290
+ {}
291
+ end
292
+ return filtered if protocol_version == "2025-11-25"
293
+
294
+ filtered.delete("tasks")
295
+ filtered.delete(:tasks)
296
+ filtered
297
+ end
298
+
283
299
  def normalize_name(class_or_name, type)
284
300
  case class_or_name
285
301
  when String
@@ -12,6 +12,11 @@ module ActionMCP
12
12
  params ||= {}
13
13
 
14
14
  with_error_handling(id) do
15
+ unless transport.session.protocol_version == "2025-11-25"
16
+ raise JSON_RPC::JsonRpcError.new(:method_not_found,
17
+ message: "Tasks are only available in MCP 2025-11-25")
18
+ end
19
+
15
20
  handler = task_method_handlers[rpc_method]
16
21
  if handler
17
22
  send(handler, id, params)
@@ -29,8 +34,7 @@ module ActionMCP
29
34
  JsonRpcHandlerBase::Methods::TASKS_GET => :handle_tasks_get,
30
35
  JsonRpcHandlerBase::Methods::TASKS_RESULT => :handle_tasks_result,
31
36
  JsonRpcHandlerBase::Methods::TASKS_LIST => :handle_tasks_list,
32
- JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel,
33
- JsonRpcHandlerBase::Methods::TASKS_RESUME => :handle_tasks_resume
37
+ JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel
34
38
  }
35
39
  end
36
40
 
@@ -63,15 +67,6 @@ module ActionMCP
63
67
  transport.send_tasks_cancel(id, task_id)
64
68
  end
65
69
 
66
- def handle_tasks_resume(id, params)
67
- task_id = validate_required_param(params, "taskId", "Task ID is required")
68
- input = params["input"]
69
- task = find_task_or_error(id, task_id)
70
- return unless task
71
-
72
- transport.send_tasks_resume(id, task_id, input)
73
- end
74
-
75
70
  def find_task_or_error(id, task_id)
76
71
  task = transport.session.tasks.find_by(id: task_id)
77
72
  unless task
@@ -37,7 +37,8 @@ module ActionMCP
37
37
  name = validate_required_param(params, "name", "Tool name is required")
38
38
  arguments = extract_arguments(params)
39
39
  _meta = params["_meta"] || params[:_meta] || {}
40
- transport.send_tools_call(id, name, arguments, _meta)
40
+ task_params = params.key?("task") ? params["task"] : params[:task]
41
+ transport.send_tools_call(id, name, arguments, _meta, task_params)
41
42
  end
42
43
 
43
44
  def extract_arguments(params)
@@ -5,7 +5,7 @@ module ActionMCP
5
5
  # Tasks module for MCP 2025-11-25 specification
6
6
  # Provides methods for handling task-related requests:
7
7
  # - tasks/get: Get task status and data
8
- # - tasks/result: Get task result (blocking until terminal state)
8
+ # - tasks/result: Get task result (blocking until terminal or input_required state)
9
9
  # - tasks/list: List tasks for the session
10
10
  # - tasks/cancel: Cancel a task
11
11
  module Tasks
@@ -16,25 +16,47 @@ module ActionMCP
16
16
  task = find_task(task_id)
17
17
  return unless task
18
18
 
19
- send_jsonrpc_response(request_id, result: { task: task.to_task_data })
19
+ send_jsonrpc_response(request_id, result: task.to_task_data)
20
20
  end
21
21
 
22
- # Get task result, blocking until task reaches terminal state
22
+ # Get task result, blocking until task reaches terminal or input_required state
23
23
  # @param request_id [String, Integer] JSON-RPC request ID
24
24
  # @param task_id [String] Task ID to get result for
25
25
  def send_tasks_result(request_id, task_id)
26
26
  task = find_task(task_id)
27
27
  return unless task
28
28
 
29
- # If task is not in terminal state, wait for it
30
- # In async execution, client should poll or use SSE for notifications
31
- unless task.terminal?
32
- send_jsonrpc_error(request_id, :invalid_request,
33
- "Task is not yet complete. Current status: #{task.status}")
34
- return
29
+ unless task.result_ready?
30
+ case ActionMCP.configuration.tasks_result_strategy
31
+ when :polling_only
32
+ send_jsonrpc_error(
33
+ request_id,
34
+ :invalid_request,
35
+ "Task is not ready. Poll tasks/get over HTTP until the task reaches a terminal or input_required status, then retry tasks/result."
36
+ )
37
+ return
38
+ else
39
+ task = wait_for_result_ready_task(task_id)
40
+ unless task&.result_ready?
41
+ send_jsonrpc_response(
42
+ request_id,
43
+ error: {
44
+ code: -32_000,
45
+ message: "Timed out waiting for task '#{task_id}' to reach a terminal or input_required status"
46
+ }
47
+ )
48
+ return
49
+ end
50
+ end
35
51
  end
36
52
 
37
- send_jsonrpc_response(request_id, result: task.to_task_result)
53
+ if (error = task.to_task_error)
54
+ send_jsonrpc_response(request_id, error: error)
55
+ else
56
+ send_jsonrpc_response(request_id, result: task.to_task_result)
57
+ end
58
+ rescue ActiveRecord::RecordNotFound
59
+ send_jsonrpc_error(request_id, :invalid_params, "Task '#{task_id}' not found")
38
60
  end
39
61
 
40
62
  # List tasks for the session with keyset pagination.
@@ -67,33 +89,14 @@ module ActionMCP
67
89
  return
68
90
  end
69
91
 
92
+ task.status_message = "The task was cancelled by request." if task.status_message.blank?
93
+ task.result_payload ||= {
94
+ code: -32_000,
95
+ message: "Task was cancelled"
96
+ }
97
+ task.save! if task.changed?
70
98
  task.cancel!
71
- send_jsonrpc_response(request_id, result: { task: task.to_task_data })
72
- end
73
-
74
- # Resume a task from input_required state
75
- # @param request_id [String, Integer] JSON-RPC request ID
76
- # @param task_id [String] Task ID to resume
77
- # @param input [Object] Input data for the task
78
- def send_tasks_resume(request_id, task_id, input)
79
- task = find_task(task_id)
80
- return unless task
81
-
82
- unless task.input_required?
83
- send_jsonrpc_error(request_id, :invalid_params,
84
- "Task is not awaiting input. Current status: #{task.status}")
85
- return
86
- end
87
-
88
- # Store input in continuation state
89
- continuation = task.continuation_state || {}
90
- continuation[:input] = input
91
- task.update!(continuation_state: continuation)
92
-
93
- # Resume task and re-enqueue job
94
- task.resume_from_continuation!
95
-
96
- send_jsonrpc_response(request_id, result: { task: task.to_task_data })
99
+ send_jsonrpc_response(request_id, result: task.to_task_data)
97
100
  end
98
101
 
99
102
  # Send task status notification
@@ -101,7 +104,7 @@ module ActionMCP
101
104
  def send_task_status_notification(task)
102
105
  send_jsonrpc_notification(
103
106
  JsonRpcHandlerBase::Methods::NOTIFICATIONS_TASKS_STATUS,
104
- { task: task.to_task_data }
107
+ task.to_task_data
105
108
  )
106
109
  end
107
110
 
@@ -140,6 +143,30 @@ module ActionMCP
140
143
 
141
144
  task
142
145
  end
146
+
147
+ def wait_for_result_ready_task(task_id)
148
+ deadline = monotonic_time + ActionMCP.configuration.tasks_result_timeout.to_f
149
+
150
+ loop do
151
+ task = load_task_for_result(task_id)
152
+ return task if task.nil? || task.result_ready?
153
+
154
+ remaining = deadline - monotonic_time
155
+ return task unless remaining.positive?
156
+
157
+ sleep [ ActionMCP.configuration.tasks_result_poll_interval.to_f, remaining ].min
158
+ end
159
+ end
160
+
161
+ def load_task_for_result(task_id)
162
+ ActiveRecord::Base.connection_pool.with_connection do
163
+ session.tasks.find_by(id: task_id)
164
+ end
165
+ end
166
+
167
+ def monotonic_time
168
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
169
+ end
143
170
  end
144
171
  end
145
172
  end
@@ -25,7 +25,7 @@ module ActionMCP
25
25
  send_jsonrpc_error(request_id, :invalid_params, e.message)
26
26
  end
27
27
 
28
- def send_tools_call(request_id, tool_name, arguments, _meta = {})
28
+ def send_tools_call(request_id, tool_name, arguments, _meta = {}, task_params = nil)
29
29
  # Find tool in session's registry
30
30
  tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
31
31
 
@@ -47,13 +47,39 @@ module ActionMCP
47
47
  return
48
48
  end
49
49
 
50
- # Check for task-augmented execution (MCP 2025-11-25)
51
- task_params = _meta["task"] || _meta[:task]
52
- if task_params && tasks_enabled?
50
+ task_support = tool_task_support(tool_class)
51
+ task_requested = !task_params.nil?
52
+
53
+ if task_requested && !tasks_enabled?
54
+ send_jsonrpc_error(request_id, :method_not_found,
55
+ "Task-augmented execution is not available for this session")
56
+ return
57
+ end
58
+
59
+ if task_requested
60
+ unless task_params.respond_to?(:to_h)
61
+ send_jsonrpc_error(request_id, :invalid_params, "Task parameters must be an object")
62
+ return
63
+ end
64
+
65
+ task_params = task_params.to_h
66
+
67
+ if task_support == :forbidden
68
+ send_jsonrpc_error(request_id, :method_not_found,
69
+ "Tool '#{tool_name}' does not support task-augmented execution")
70
+ return
71
+ end
72
+
53
73
  handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
54
74
  return
55
75
  end
56
76
 
77
+ if !task_requested && task_support == :required
78
+ send_jsonrpc_error(request_id, :method_not_found,
79
+ "Tool '#{tool_name}' requires task-augmented execution")
80
+ return
81
+ end
82
+
57
83
  # Standard synchronous execution
58
84
  execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
59
85
  end
@@ -114,7 +140,6 @@ module ActionMCP
114
140
  def handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
115
141
  # Extract task configuration
116
142
  ttl = task_params["ttl"] || task_params[:ttl] || 60_000
117
- poll_interval = task_params["pollInterval"] || task_params[:pollInterval] || 5_000
118
143
 
119
144
  # Create task record
120
145
  task = session.tasks.create!(
@@ -123,24 +148,39 @@ module ActionMCP
123
148
  request_params: {
124
149
  name: tool_name,
125
150
  arguments: arguments,
151
+ task: task_params,
126
152
  _meta: _meta
127
153
  },
128
- ttl: ttl,
129
- poll_interval: poll_interval
154
+ ttl: ttl
155
+ )
156
+ request_meta = task.request_meta_with_related_task(_meta)
157
+ task.update!(
158
+ request_params: {
159
+ name: tool_name,
160
+ arguments: arguments,
161
+ task: task_params,
162
+ _meta: request_meta
163
+ }
130
164
  )
131
165
 
132
166
  # Return CreateTaskResult immediately
133
- send_jsonrpc_response(request_id, result: { task: task.to_task_data })
167
+ send_jsonrpc_response(request_id, result: task.to_create_task_result)
134
168
 
135
169
  # Execute tool asynchronously via ActiveJob
136
- ToolExecutionJob.perform_later(task.id, tool_name, arguments, _meta)
170
+ ToolExecutionJob.perform_later(task.id, tool_name, arguments, request_meta)
137
171
  rescue StandardError => e
138
172
  Rails.logger.error "Failed to create task: #{e.class} - #{e.message}"
139
173
  send_jsonrpc_error(request_id, :internal_error, "Failed to create task")
140
174
  end
141
175
 
142
176
  def tasks_enabled?
143
- ActionMCP.configuration.tasks_enabled
177
+ ActionMCP.configuration.tasks_enabled && session.protocol_version == "2025-11-25"
178
+ end
179
+
180
+ def tool_task_support(tool_class)
181
+ return :forbidden unless tool_class.respond_to?(:task_support)
182
+
183
+ (tool_class.task_support || :forbidden).to_sym
144
184
  end
145
185
 
146
186
  def format_registry_items(registry, protocol_version = nil)
@@ -187,6 +187,31 @@ module ActionMCP
187
187
  end
188
188
  end
189
189
 
190
+ # Declares the UI resource this tool renders. Merges a `ui:` entry into
191
+ # `_meta` so the tool listing advertises the dashboard.
192
+ #
193
+ # @param resource_uri [String] a `ui://` URI
194
+ # @param visibility [Array<Symbol, String>, nil] subset of `[:model, :app]`
195
+ def renders_ui(resource_uri, visibility: nil)
196
+ unless resource_uri.is_a?(String) && Apps::URI_SCHEME.match?(resource_uri)
197
+ raise ArgumentError, "renders_ui requires a ui:// URI, got: #{resource_uri.inspect}"
198
+ end
199
+
200
+ normalized_visibility =
201
+ if visibility
202
+ normalized = Array(visibility).map(&:to_s)
203
+ invalid = normalized - Apps::VISIBILITY_VALUES
204
+ if invalid.any?
205
+ raise ArgumentError,
206
+ "renders_ui visibility must be #{Apps::VISIBILITY_VALUES.join('/')}, got: #{visibility.inspect}"
207
+ end
208
+ normalized
209
+ end
210
+
211
+ ui_meta = { resourceUri: resource_uri, visibility: normalized_visibility }.compact
212
+ self._meta = _meta.deep_merge(ui: ui_meta)
213
+ end
214
+
190
215
  # Marks this tool as requiring consent before execution
191
216
  def requires_consent!
192
217
  self._requires_consent = true
@@ -397,7 +422,7 @@ module ActionMCP
397
422
 
398
423
  # Add execution metadata (MCP 2025-11-25)
399
424
  # Only include if not default (forbidden) to minimize payload
400
- if _task_support && _task_support != :forbidden
425
+ if _task_support && _task_support != :forbidden && task_metadata_supported?(protocol_version)
401
426
  result[:execution] = execution_metadata
402
427
  end
403
428
 
@@ -407,6 +432,11 @@ module ActionMCP
407
432
  result
408
433
  end
409
434
 
435
+ def self.task_metadata_supported?(protocol_version)
436
+ protocol_version.nil? || protocol_version == "2025-11-25"
437
+ end
438
+ private_class_method :task_metadata_supported?
439
+
410
440
  # --------------------------------------------------------------------------
411
441
  # Instance Methods
412
442
  # --------------------------------------------------------------------------
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.109.0"
5
+ VERSION = "0.110.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -41,6 +41,8 @@ module ActionMCP
41
41
 
42
42
  LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
43
43
  DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
44
+
45
+ MIME_TYPE_APP_HTML = Apps::MIME_TYPE # MCP Apps UI resources (ext-apps, draft 2026-01-26)
44
46
  class << self
45
47
  # Returns a Rack-compatible application for serving MCP requests
46
48
  # @return [#call] A Rack application that can be used with `run ActionMCP.server`
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.109.0
4
+ version: 0.110.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -160,9 +160,9 @@ files:
160
160
  - app/models/concerns/action_mcp/mcp_message_inspect.rb
161
161
  - config/routes.rb
162
162
  - db/migrate/20250512154359_consolidated_migration.rb
163
- - db/test.sqlite3
164
163
  - exe/actionmcp_cli
165
164
  - lib/action_mcp.rb
165
+ - lib/action_mcp/apps.rb
166
166
  - lib/action_mcp/base_response.rb
167
167
  - lib/action_mcp/callbacks.rb
168
168
  - lib/action_mcp/capability.rb
@@ -199,6 +199,8 @@ files:
199
199
  - lib/action_mcp/logging/mixin.rb
200
200
  - lib/action_mcp/logging/null_logger.rb
201
201
  - lib/action_mcp/logging/state.rb
202
+ - lib/action_mcp/middleware/origin_validation.rb
203
+ - lib/action_mcp/mime_types.rb
202
204
  - lib/action_mcp/output_schema_builder.rb
203
205
  - lib/action_mcp/prompt.rb
204
206
  - lib/action_mcp/prompt_response.rb
data/db/test.sqlite3 DELETED
File without changes