actionmcp 0.108.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +52 -3
  3. data/app/controllers/action_mcp/application_controller.rb +7 -3
  4. data/app/jobs/action_mcp/tool_execution_job.rb +13 -3
  5. data/app/models/action_mcp/session/task.rb +70 -15
  6. data/app/models/action_mcp/session.rb +18 -1
  7. data/lib/action_mcp/apps.rb +20 -0
  8. data/lib/action_mcp/capability.rb +5 -0
  9. data/lib/action_mcp/configuration.rb +69 -4
  10. data/lib/action_mcp/content/resource.rb +17 -2
  11. data/lib/action_mcp/engine.rb +3 -0
  12. data/lib/action_mcp/json_rpc_handler_base.rb +0 -1
  13. data/lib/action_mcp/middleware/origin_validation.rb +94 -0
  14. data/lib/action_mcp/mime_types.rb +30 -0
  15. data/lib/action_mcp/resource.rb +16 -4
  16. data/lib/action_mcp/resource_template.rb +65 -4
  17. data/lib/action_mcp/server/base_session.rb +17 -1
  18. data/lib/action_mcp/server/elicitation.rb +105 -37
  19. data/lib/action_mcp/server/elicitation_request.rb +100 -0
  20. data/lib/action_mcp/server/handlers/prompt_handler.rb +2 -2
  21. data/lib/action_mcp/server/handlers/task_handler.rb +6 -11
  22. data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
  23. data/lib/action_mcp/server/pagination.rb +106 -0
  24. data/lib/action_mcp/server/prompts.rb +9 -4
  25. data/lib/action_mcp/server/resources.rb +67 -125
  26. data/lib/action_mcp/server/tasks.rb +97 -50
  27. data/lib/action_mcp/server/tools.rb +58 -29
  28. data/lib/action_mcp/server/transport_handler.rb +2 -0
  29. data/lib/action_mcp/server/url_elicitation_request.rb +60 -0
  30. data/lib/action_mcp/tool.rb +31 -1
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/action_mcp.rb +2 -0
  33. metadata +7 -2
  34. data/db/test.sqlite3 +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 917d5120acc0a5cb82cb4a9516a65e59370f922dfe2828670e1117a5d1d26934
4
- data.tar.gz: a77bd78234661ee4ef52cafff59f5d6c856e94728bca98832c926928635e8e94
3
+ metadata.gz: b49e1bd20853c60ec17b35b3ba822a275b8258800ce615707dcf85ecaa606dc8
4
+ data.tar.gz: c1abbbe666dbea818abde461aaae5d65f867700fa96e6d5c8011b12b10be1fcd
5
5
  SHA512:
6
- metadata.gz: 6110ec397f08c3e8cbc2fe54a20f22bae3f600ad005e82f254bf6eeea7ba07c3bee1fc28a337a6263725fff213fdcbf092b08000285bdff0958b16fa5a958ccf
7
- data.tar.gz: f57f192f165bf2a5d44d18189e6107eb0ab951eeaf4f88ddc92fa2e3208bcc4bfe7a7d6aed9cd0e01b2951d07e6e8b2826f75e6033054130b304707b0bec03b1
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.
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
 
@@ -491,6 +506,25 @@ end
491
506
 
492
507
  For dynamic versioning, consider adding the `rails_app_version` gem.
493
508
 
509
+ ### Pagination
510
+
511
+ All list endpoints (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`) support cursor-based pagination per the MCP specification. Pagination is **off by default** — set `pagination_page_size` to enable:
512
+
513
+ ```ruby
514
+ config.action_mcp.pagination_page_size = 10
515
+ ```
516
+
517
+ Or in `config/mcp.yml`:
518
+
519
+ ```yaml
520
+ shared:
521
+ pagination_page_size: 10
522
+ ```
523
+
524
+ When set, responses include an opaque `nextCursor` when more items are available. When `nil` (default), all items are returned in a single response. Enable only when your clients support cursor-based pagination.
525
+
526
+ `tasks/list` always paginates regardless of this setting (defaults to 50 per page, or `pagination_page_size` if configured).
527
+
494
528
  ### Server Instructions
495
529
 
496
530
  Server instructions help LLMs understand **what your server is for** and **when to use it**. They describe the server's purpose and goal, not technical details like rate limits or authentication (tools are self-documented via their own descriptions).
@@ -1263,6 +1297,21 @@ For comprehensive client documentation, including examples, session management,
1263
1297
  - **Implement proper authorization** in your tools and prompts
1264
1298
  - **Validate all inputs** using property definitions and Rails validations
1265
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
+ ```
1266
1315
 
1267
1316
  ### Performance
1268
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.
@@ -34,6 +37,7 @@ module ActionMCP
34
37
  def show
35
38
  # MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
36
39
  # ActionMCP uses Tasks for async operations instead of SSE streaming.
40
+ response.headers["Allow"] = "POST, DELETE"
37
41
  head :method_not_allowed
38
42
  end
39
43
 
@@ -95,7 +99,7 @@ module ActionMCP
95
99
  result = json_rpc_handler.call(jsonrpc_params)
96
100
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
97
101
  rescue StandardError => e
98
- Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
102
+ Rails.error.report(e, handled: true, severity: :error)
99
103
  id = begin
100
104
  jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
101
105
  rescue StandardError
@@ -128,7 +132,7 @@ module ActionMCP
128
132
  Rails.logger.info "Unified DELETE: Terminated session: #{session.id}" if ActionMCP.configuration.verbose_logging
129
133
  head :no_content
130
134
  rescue StandardError => e
131
- Rails.logger.error "Unified DELETE: Error terminating session #{session.id}: #{e.class} - #{e.message}"
135
+ Rails.error.report(e, handled: true, severity: :error)
132
136
  render_internal_server_error("Failed to terminate session.")
133
137
  end
134
138
  end
@@ -329,7 +333,7 @@ module ActionMCP
329
333
  rescue ActionMCP::UnauthorizedError => e
330
334
  render_unauthorized(e.message)
331
335
  rescue StandardError => e
332
- Rails.logger.error "Gateway authentication error: #{e.class} - #{e.message}"
336
+ Rails.error.report(e, handled: true, severity: :error)
333
337
  render_unauthorized("Authentication system error")
334
338
  end
335
339
  end
@@ -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 }
@@ -68,7 +70,15 @@ module ActionMCP
68
70
  # Scopes - state_machines >= 0.100.0 auto-generates .with_status(:state) scopes
69
71
  scope :terminal, -> { with_status(:completed, :failed, :cancelled) }
70
72
  scope :non_terminal, -> { with_status(:working, :input_required) }
71
- scope :recent, -> { order(created_at: :desc) }
73
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
74
+ scope :before_recent, lambda { |task|
75
+ table = arel_table
76
+
77
+ where(
78
+ table[:created_at].lt(task.created_at)
79
+ .or(table[:created_at].eq(task.created_at).and(table[:id].lt(task.id)))
80
+ )
81
+ }
72
82
 
73
83
  # State machine definition per MCP spec
74
84
  state_machine :status, initial: :working do
@@ -131,6 +141,10 @@ module ActionMCP
131
141
  status.in?(%w[completed failed cancelled])
132
142
  end
133
143
 
144
+ def result_ready?
145
+ terminal? || input_required?
146
+ end
147
+
134
148
  # Check if task is in a non-terminal state
135
149
  def non_terminal?
136
150
  !terminal?
@@ -140,32 +154,73 @@ module ActionMCP
140
154
  # @return [Hash] Task data for JSON-RPC responses
141
155
  def to_task_data
142
156
  data = {
143
- id: id,
157
+ taskId: id,
144
158
  status: status,
145
- lastUpdatedAt: last_updated_at.iso8601(3)
159
+ createdAt: created_at.iso8601(3),
160
+ lastUpdatedAt: last_updated_at.iso8601(3),
161
+ ttl: ttl
146
162
  }
147
163
  data[:statusMessage] = status_message if status_message.present?
148
-
149
- # Add progress if available (ActiveJob::Continuable support)
150
- if progress_percent.present? || progress_message.present?
151
- data[:progress] = {}.tap do |progress|
152
- progress[:percent] = progress_percent if progress_percent.present?
153
- progress[:message] = progress_message if progress_message.present?
154
- end
155
- end
164
+ data[:pollInterval] = poll_interval if poll_interval.present?
156
165
 
157
166
  data
158
167
  end
159
168
 
160
- # Convert to full task result format
161
- # @return [Hash] Complete task with result for tasks/result response
162
- def to_task_result
169
+ def to_create_task_result
163
170
  {
164
171
  task: to_task_data,
165
- result: result_payload
172
+ _meta: related_task_meta
166
173
  }
167
174
  end
168
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
+
169
224
  # Broadcast status change notification to the session
170
225
  # @param transition [StateMachines::Transition] The state transition that occurred
171
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
@@ -25,7 +25,6 @@ module ActionMCP
25
25
  :logging_level,
26
26
  :active_profile,
27
27
  :profiles,
28
- :elicitation_enabled,
29
28
  :verbose_logging,
30
29
  # --- Authentication Options ---
31
30
  :authentication_methods,
@@ -46,19 +45,23 @@ module ActionMCP
46
45
  :tasks_enabled,
47
46
  :tasks_list_enabled,
48
47
  :tasks_cancel_enabled,
48
+ :tasks_result_strategy,
49
+ :tasks_result_timeout,
50
+ :tasks_result_poll_interval,
49
51
  # --- Schema Validation Options ---
50
52
  :validate_structured_content,
51
53
  # --- Allowed identity keys for gateway ---
52
54
  :allowed_identity_keys,
53
55
  # --- JSON-RPC Path ---
54
- :base_path
56
+ :base_path,
57
+ # --- Origin validation (DNS rebinding protection) ---
58
+ :allowed_origins
55
59
 
56
60
  def initialize
57
61
  @logging_enabled = false
58
62
  @list_changed = true
59
63
  @logging_level = :warning
60
64
  @resources_subscribe = false
61
- @elicitation_enabled = false
62
65
  @verbose_logging = false
63
66
  @active_profile = :primary
64
67
  @profiles = default_profiles
@@ -72,6 +75,13 @@ module ActionMCP
72
75
  @tasks_enabled = false
73
76
  @tasks_list_enabled = true
74
77
  @tasks_cancel_enabled = true
78
+ @tasks_result_strategy = :blocking_http
79
+ @tasks_result_timeout = 30.seconds
80
+ @tasks_result_poll_interval = 0.25
81
+
82
+ # Pagination - nil means off. Set a number to enable with that page size.
83
+ # Most MCP clients (including Claude Code) don't follow nextCursor yet.
84
+ @pagination_page_size = nil
75
85
 
76
86
  # Schema validation - disabled by default for backward compatibility
77
87
  @validate_structured_content = false
@@ -94,6 +104,9 @@ module ActionMCP
94
104
 
95
105
  # Path for JSON-RPC endpoint
96
106
  @base_path = "/"
107
+
108
+ # Allowed origins for DNS rebinding protection (nil = derive from request.host)
109
+ @allowed_origins = nil
97
110
  end
98
111
 
99
112
  def name
@@ -132,6 +145,36 @@ module ActionMCP
132
145
  @allowed_identity_keys = Array(value).map(&:to_s).freeze
133
146
  end
134
147
 
148
+ # Pagination page size. nil = pagination disabled, positive integer = enabled.
149
+ attr_reader :pagination_page_size
150
+
151
+ def pagination_page_size=(value)
152
+ if value.nil?
153
+ @pagination_page_size = nil
154
+ else
155
+ size = value.to_i
156
+ raise ArgumentError, "pagination_page_size must be a positive integer, got: #{value.inspect}" unless size > 0
157
+ @pagination_page_size = size
158
+ end
159
+ end
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
+
135
178
  def gateway_class
136
179
  # Resolve gateway class lazily to account for Zeitwerk autoloading
137
180
  # This allows ApplicationGateway to be loaded from app/mcp even if the
@@ -263,7 +306,6 @@ module ActionMCP
263
306
  capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
264
307
  end
265
308
 
266
- capabilities[:elicitation] = {} if @elicitation_enabled
267
309
 
268
310
  # Tasks capability (MCP 2025-11-25)
269
311
  if @tasks_enabled
@@ -383,6 +425,22 @@ module ActionMCP
383
425
  if config["server_instructions"]
384
426
  @server_instructions = parse_instructions(config["server_instructions"])
385
427
  end
428
+
429
+ # Extract pagination page size (nil = off, positive integer = on)
430
+ if config.key?("pagination_page_size")
431
+ self.pagination_page_size = config["pagination_page_size"]
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
386
444
  end
387
445
 
388
446
  def should_include_all?(type)
@@ -405,6 +463,13 @@ module ActionMCP
405
463
  Array(instructions).map(&:to_s)
406
464
  end
407
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
+
408
473
  def ensure_mcp_components_loaded
409
474
  # Only load if we haven't loaded yet - but in development, always reload
410
475
  return if @mcp_components_loaded && !Rails.env.development?
@@ -9,7 +9,8 @@ module ActionMCP
9
9
  # @return [String] The MIME type of the resource.
10
10
  # @return [String, nil] The text content of the resource (optional).
11
11
  # @return [String, nil] The base64-encoded blob of the resource (optional).
12
- attr_reader :uri, :mime_type, :text, :blob, :annotations
12
+ # @return [Hash, nil] Optional extension metadata (serialized on the wire as `_meta`).
13
+ attr_reader :uri, :mime_type, :text, :blob, :annotations, :meta
13
14
 
14
15
  # Initializes a new Resource content.
15
16
  #
@@ -18,17 +19,30 @@ module ActionMCP
18
19
  # @param text [String, nil] The text content of the resource (optional).
19
20
  # @param blob [String, nil] The base64-encoded blob of the resource (optional).
20
21
  # @param annotations [Hash, nil] Optional annotations for the resource.
21
- def initialize(uri, mime_type = "text/plain", text: nil, blob: nil, annotations: nil)
22
+ # @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
23
+ def initialize(uri, mime_type = "text/plain", text: nil, blob: nil, annotations: nil, meta: nil)
22
24
  super("resource", annotations: annotations)
23
25
  @uri = uri
24
26
  @mime_type = mime_type
25
27
  @text = text
26
28
  @blob = blob
27
29
  @annotations = annotations
30
+ @meta =
31
+ if meta.nil?
32
+ nil
33
+ elsif meta.respond_to?(:to_hash)
34
+ meta.to_hash
35
+ elsif meta.respond_to?(:to_h)
36
+ meta.to_h
37
+ else
38
+ raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
39
+ end
28
40
  end
29
41
 
30
42
  # Returns a hash representation of the resource content.
31
43
  # Per MCP spec, embedded resources have type "resource" with a nested resource object.
44
+ # `meta` is emitted as `_meta` on the inner resource hash (TextResourceContents /
45
+ # BlobResourceContents), not on the outer content envelope.
32
46
  #
33
47
  # @return [Hash] The hash representation of the resource content.
34
48
  def to_h
@@ -36,6 +50,7 @@ module ActionMCP
36
50
  inner[:text] = @text if @text
37
51
  inner[:blob] = @blob if @blob
38
52
  inner[:annotations] = @annotations if @annotations
53
+ inner[:_meta] = @meta if @meta && !@meta.empty?
39
54
 
40
55
  { type: @type, resource: inner }
41
56
  end
@@ -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"