actionmcp 0.107.1 → 0.109.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -15
  3. data/app/controllers/action_mcp/application_controller.rb +9 -3
  4. data/app/models/action_mcp/session/task.rb +9 -1
  5. data/app/models/action_mcp/session.rb +35 -3
  6. data/db/migrate/20250512154359_consolidated_migration.rb +90 -101
  7. data/lib/action_mcp/configuration.rb +22 -3
  8. data/lib/action_mcp/content/resource.rb +17 -2
  9. data/lib/action_mcp/resource.rb +16 -4
  10. data/lib/action_mcp/resource_template.rb +4 -2
  11. data/lib/action_mcp/server/elicitation.rb +105 -37
  12. data/lib/action_mcp/server/elicitation_request.rb +100 -0
  13. data/lib/action_mcp/server/handlers/prompt_handler.rb +2 -2
  14. data/lib/action_mcp/server/pagination.rb +106 -0
  15. data/lib/action_mcp/server/prompts.rb +9 -4
  16. data/lib/action_mcp/server/resources.rb +67 -125
  17. data/lib/action_mcp/server/tasks.rb +33 -13
  18. data/lib/action_mcp/server/tools.rb +8 -19
  19. data/lib/action_mcp/server/transport_handler.rb +2 -0
  20. data/lib/action_mcp/server/url_elicitation_request.rb +60 -0
  21. data/lib/action_mcp/test_helper.rb +35 -0
  22. data/lib/action_mcp/version.rb +1 -1
  23. metadata +5 -10
  24. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +0 -9
  25. data/db/migrate/20250727000001_remove_oauth_support.rb +0 -59
  26. data/db/migrate/20251125000001_create_action_mcp_session_tasks.rb +0 -29
  27. data/db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb +0 -10
  28. data/db/migrate/20251203000001_remove_sse_support.rb +0 -31
  29. data/db/migrate/20251204000001_add_progress_to_session_tasks.rb +0 -12
  30. data/db/migrate/20260303000001_add_session_data_to_action_mcp_sessions.rb +0 -9
  31. data/db/migrate/20260304000001_remove_session_resources.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be05e5d56452910d6c725b15041b68b41b12830f38bd5661ba5c363403ef8df7
4
- data.tar.gz: 4ead1672cb76e8c1ff4057062f6396cc68d290abf0ab7c0e0562f2d438ddd8a7
3
+ metadata.gz: dbc03b1ba75f2efaf3c94ff2e664d3ca4e21013e8c55651f1fccff0b8c4e889b
4
+ data.tar.gz: 1576d864115954653182d05080352a28b7be2a11c8fb376f0e426503a320eb7f
5
5
  SHA512:
6
- metadata.gz: f0d3ed2611e1347f24a1b6422c9d1628d85cbce23e9b9db13996aca9d9133d03e50bf6b45b26a0f4afa5795314b5285e70e545ecfd2e40ef71294bfc13241b03
7
- data.tar.gz: fbdf20f97d3324d4adc6d9905ff45dc662ea9a039e6682e884a614fb02a14726b32c17c84785856188d9dea2babbe7aefd9d8d2d0c1c4171facaf69d1dcd02ce
6
+ metadata.gz: da5bcfd15ecc9e76b4ac6c557a84a95cf91090520ec7d11fb6c3652168c80cc1820660a8311b8b173db8bd4a1773a26cd53e83b2bae4fb6874f0b79aff489482
7
+ data.tar.gz: 28235fee76f77b9a7a87cca20a149231d31649a980e2d19480fde5f38543d0aa7ca61d16e7d97d89a83de9d849dda6c9e323e03cbc73dacca68572a7ba2dbaf5
data/README.md CHANGED
@@ -330,7 +330,7 @@ Call it as a task from a client by adding `_meta.task` (creates a Task record an
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 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.
334
334
 
335
335
  ### ActionMCP::ResourceTemplate
336
336
 
@@ -491,6 +491,25 @@ end
491
491
 
492
492
  For dynamic versioning, consider adding the `rails_app_version` gem.
493
493
 
494
+ ### Pagination
495
+
496
+ 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:
497
+
498
+ ```ruby
499
+ config.action_mcp.pagination_page_size = 10
500
+ ```
501
+
502
+ Or in `config/mcp.yml`:
503
+
504
+ ```yaml
505
+ shared:
506
+ pagination_page_size: 10
507
+ ```
508
+
509
+ 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.
510
+
511
+ `tasks/list` always paginates regardless of this setting (defaults to 50 per page, or `pagination_page_size` if configured).
512
+
494
513
  ### Server Instructions
495
514
 
496
515
  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).
@@ -712,10 +731,12 @@ This will create:
712
731
 
713
732
  ## Authentication with Gateway
714
733
 
715
- ActionMCP provides a Gateway system for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
734
+ ActionMCP provides a Gateway system for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components. For the full gateway reference including identifier classes, session persistence, profile switching, and production hardening tips, see **[GATEWAY.md](GATEWAY.md)**.
716
735
 
717
736
  ActionMCP uses a Gateway pattern with pluggable identifiers for authentication. You can implement custom authentication strategies using session-based auth, API keys, bearer tokens, or integrate with existing authentication systems like Warden, Devise, or external OAuth providers.
718
737
 
738
+ > **Note:** Auth errors return HTTP 200 with a JSON-RPC error payload (not HTTP 401). This is correct per the MCP specification — all MCP communication uses JSON-RPC over HTTP, and protocol-level errors are expressed within the JSON-RPC envelope. The `initialize` request bypasses authentication per MCP spec.
739
+
719
740
  ### Creating an ApplicationGateway
720
741
 
721
742
  When you run the install generator, it creates an `ApplicationGateway` class:
@@ -986,26 +1007,68 @@ class ToolTest < ActiveSupport::TestCase
986
1007
  include ActionMCP::TestHelper
987
1008
 
988
1009
  test "CalculateSumTool returns the correct sum" do
989
- assert_tool_findable("calculate_sum")
990
- result = execute_tool("calculate_sum", a: 5, b: 10)
991
- assert_tool_output(result, "15.0")
1010
+ assert_mcp_tool_findable("calculate_sum")
1011
+ result = execute_mcp_tool("calculate_sum", a: 5, b: 10)
1012
+ assert_mcp_tool_output("15.0", result)
992
1013
  end
993
1014
 
994
1015
  test "AnalyzeCodePrompt returns the correct analysis" do
995
- assert_prompt_findable("analyze_code")
996
- result = execute_prompt("analyze_code", language: "Ruby", code: "def hello; puts 'Hello, world!'; end")
997
- assert_equal "Analyzing Ruby code: def hello; puts 'Hello, world!'; end", assert_prompt_output(result)
1016
+ assert_mcp_prompt_findable("analyze_code")
1017
+ result = execute_mcp_prompt("analyze_code", language: "Ruby", code: "def hello; puts 'Hello, world!'; end")
1018
+ assert_mcp_prompt_output("Analyzing Ruby code: def hello; puts 'Hello, world!'; end", result)
998
1019
  end
999
1020
  end
1000
1021
  ```
1001
1022
 
1002
- The TestHelper provides several assertion methods:
1003
- - `assert_tool_findable(name)` - Verifies a tool exists and is registered
1004
- - `assert_prompt_findable(name)` - Verifies a prompt exists and is registered
1005
- - `execute_tool(name, **args)` - Executes a tool with arguments
1006
- - `execute_prompt(name, **args)` - Executes a prompt with arguments
1007
- - `assert_tool_output(result, expected)` - Asserts tool output matches expected text
1008
- - `assert_prompt_output(result)` - Extracts and returns prompt output for assertions
1023
+ The TestHelper provides several assertion and execution methods:
1024
+
1025
+ **Tools:**
1026
+ - `assert_mcp_tool_findable(name)` - Verifies a tool exists and is registered
1027
+ - `execute_mcp_tool(name, **args)` - Executes a tool with arguments and asserts success
1028
+ - `execute_mcp_tool_with_error(name, **args)` - Executes a tool without asserting success (for testing error cases)
1029
+ - `assert_mcp_tool_output(expected, response)` - Asserts tool output matches expected content
1030
+
1031
+ **Prompts:**
1032
+ - `assert_mcp_prompt_findable(name)` - Verifies a prompt exists and is registered
1033
+ - `execute_mcp_prompt(name, **args)` - Executes a prompt with arguments
1034
+ - `assert_mcp_prompt_output(expected, response)` - Asserts prompt output matches expected content
1035
+
1036
+ **Resource Templates:**
1037
+ - `assert_mcp_resource_template_findable(name)` - Verifies a resource template exists and is registered
1038
+ - `resolve_mcp_resource(uri)` - Resolves a resource URI and asserts success
1039
+ - `resolve_mcp_resource_with_error(uri)` - Resolves a resource URI without asserting success (for testing error cases)
1040
+
1041
+ **General:**
1042
+ - `assert_mcp_error_code(code, response)` - Asserts a specific JSON-RPC error code
1043
+
1044
+ ### Testing Resource Templates
1045
+
1046
+ ```ruby
1047
+ require "test_helper"
1048
+ require "action_mcp/test_helper"
1049
+
1050
+ class ProductResourceTest < ActiveSupport::TestCase
1051
+ include ActionMCP::TestHelper
1052
+
1053
+ test "product template is registered" do
1054
+ assert_mcp_resource_template_findable("products")
1055
+ end
1056
+
1057
+ test "resolves a product resource by URI" do
1058
+ resp = resolve_mcp_resource("ecommerce://products/1")
1059
+
1060
+ assert resp.success?
1061
+ assert_not_empty resp.contents
1062
+ assert_equal "application/json", resp.contents.first.mime_type
1063
+ end
1064
+
1065
+ test "returns error for nonexistent product" do
1066
+ resp = resolve_mcp_resource_with_error("ecommerce://products/0")
1067
+
1068
+ assert resp.is_error
1069
+ end
1070
+ end
1071
+ ```
1009
1072
 
1010
1073
  ## Inspecting Your MCP Server
1011
1074
 
@@ -34,6 +34,7 @@ module ActionMCP
34
34
  def show
35
35
  # MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
36
36
  # ActionMCP uses Tasks for async operations instead of SSE streaming.
37
+ response.headers["Allow"] = "POST, DELETE"
37
38
  head :method_not_allowed
38
39
  end
39
40
 
@@ -70,6 +71,11 @@ module ActionMCP
70
71
  end
71
72
  end
72
73
 
74
+ if session.nil?
75
+ id = jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
76
+ return render_not_found("Session not found.", id)
77
+ end
78
+
73
79
  if session.new_record?
74
80
  session.save!
75
81
  response.headers[MCP_SESSION_ID_HEADER] = session.id
@@ -90,7 +96,7 @@ module ActionMCP
90
96
  result = json_rpc_handler.call(jsonrpc_params)
91
97
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
92
98
  rescue StandardError => e
93
- Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
99
+ Rails.error.report(e, handled: true, severity: :error)
94
100
  id = begin
95
101
  jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
96
102
  rescue StandardError
@@ -123,7 +129,7 @@ module ActionMCP
123
129
  Rails.logger.info "Unified DELETE: Terminated session: #{session.id}" if ActionMCP.configuration.verbose_logging
124
130
  head :no_content
125
131
  rescue StandardError => e
126
- Rails.logger.error "Unified DELETE: Error terminating session #{session.id}: #{e.class} - #{e.message}"
132
+ Rails.error.report(e, handled: true, severity: :error)
127
133
  render_internal_server_error("Failed to terminate session.")
128
134
  end
129
135
  end
@@ -324,7 +330,7 @@ module ActionMCP
324
330
  rescue ActionMCP::UnauthorizedError => e
325
331
  render_unauthorized(e.message)
326
332
  rescue StandardError => e
327
- Rails.logger.error "Gateway authentication error: #{e.class} - #{e.message}"
333
+ Rails.error.report(e, handled: true, severity: :error)
328
334
  render_unauthorized("Authentication system error")
329
335
  end
330
336
  end
@@ -68,7 +68,15 @@ module ActionMCP
68
68
  # Scopes - state_machines >= 0.100.0 auto-generates .with_status(:state) scopes
69
69
  scope :terminal, -> { with_status(:completed, :failed, :cancelled) }
70
70
  scope :non_terminal, -> { with_status(:working, :input_required) }
71
- scope :recent, -> { order(created_at: :desc) }
71
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
72
+ scope :before_recent, lambda { |task|
73
+ table = arel_table
74
+
75
+ where(
76
+ table[:created_at].lt(task.created_at)
77
+ .or(table[:created_at].eq(task.created_at).and(table[:id].lt(task.id)))
78
+ )
79
+ }
72
80
 
73
81
  # State machine definition per MCP spec
74
82
  state_machine :status, initial: :working do
@@ -172,6 +172,7 @@ module ActionMCP
172
172
  def register_tool(tool_class_or_name)
173
173
  tool_name = normalize_name(tool_class_or_name, :tool)
174
174
  return false unless tool_exists?(tool_name)
175
+ return true if uses_all_tools?
175
176
 
176
177
  self.tool_registry ||= []
177
178
  unless self.tool_registry.include?(tool_name)
@@ -184,8 +185,13 @@ module ActionMCP
184
185
 
185
186
  def unregister_tool(tool_class_or_name)
186
187
  tool_name = normalize_name(tool_class_or_name, :tool)
187
- self.tool_registry ||= []
188
188
 
189
+ if uses_all_tools?
190
+ return unless tool_exists?(tool_name)
191
+ expand_tool_registry!
192
+ end
193
+
194
+ self.tool_registry ||= []
189
195
  return unless self.tool_registry.delete(tool_name)
190
196
 
191
197
  save!
@@ -195,6 +201,7 @@ module ActionMCP
195
201
  def register_prompt(prompt_class_or_name)
196
202
  prompt_name = normalize_name(prompt_class_or_name, :prompt)
197
203
  return false unless prompt_exists?(prompt_name)
204
+ return true if uses_all_prompts?
198
205
 
199
206
  self.prompt_registry ||= []
200
207
  unless self.prompt_registry.include?(prompt_name)
@@ -207,8 +214,13 @@ module ActionMCP
207
214
 
208
215
  def unregister_prompt(prompt_class_or_name)
209
216
  prompt_name = normalize_name(prompt_class_or_name, :prompt)
210
- self.prompt_registry ||= []
211
217
 
218
+ if uses_all_prompts?
219
+ return unless prompt_exists?(prompt_name)
220
+ expand_prompt_registry!
221
+ end
222
+
223
+ self.prompt_registry ||= []
212
224
  return unless self.prompt_registry.delete(prompt_name)
213
225
 
214
226
  save!
@@ -218,6 +230,7 @@ module ActionMCP
218
230
  def register_resource_template(template_class_or_name)
219
231
  template_name = normalize_name(template_class_or_name, :resource_template)
220
232
  return false unless resource_template_exists?(template_name)
233
+ return true if uses_all_resources?
221
234
 
222
235
  self.resource_registry ||= []
223
236
  unless self.resource_registry.include?(template_name)
@@ -230,8 +243,13 @@ module ActionMCP
230
243
 
231
244
  def unregister_resource_template(template_class_or_name)
232
245
  template_name = normalize_name(template_class_or_name, :resource_template)
233
- self.resource_registry ||= []
234
246
 
247
+ if uses_all_resources?
248
+ return unless resource_template_exists?(template_name)
249
+ expand_resource_registry!
250
+ end
251
+
252
+ self.resource_registry ||= []
235
253
  return unless self.resource_registry.delete(template_name)
236
254
 
237
255
  save!
@@ -348,6 +366,20 @@ module ActionMCP
348
366
  self.resource_registry = [ "*" ]
349
367
  end
350
368
 
369
+ # Expand wildcard registries to explicit name lists so individual
370
+ # entries can be removed without breaking the wildcard check.
371
+ def expand_tool_registry!
372
+ self.tool_registry = ActionMCP.configuration.filtered_tools.map(&:name)
373
+ end
374
+
375
+ def expand_prompt_registry!
376
+ self.prompt_registry = ActionMCP.configuration.filtered_prompts.map(&:name)
377
+ end
378
+
379
+ def expand_resource_registry!
380
+ self.resource_registry = ActionMCP.configuration.filtered_resources.map(&:name)
381
+ end
382
+
351
383
  def normalize_name(class_or_name, type)
352
384
  case class_or_name
353
385
  when String
@@ -1,113 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ConsolidatedMigration < ActiveRecord::Migration[8.0]
4
- def change
5
- # Only create tables if they don't exist to avoid deleting existing data
6
-
7
- # Create sessions table
8
- unless table_exists?(:action_mcp_sessions)
9
- create_table :action_mcp_sessions, id: :string do |t|
10
- t.string :role, null: false, default: 'server', comment: 'The role of the session'
11
- t.string :status, null: false, default: 'pre_initialize'
12
- t.datetime :ended_at, comment: 'The time the session ended'
13
- t.string :protocol_version
14
- t.json :server_capabilities, comment: 'The capabilities of the server'
15
- t.json :client_capabilities, comment: 'The capabilities of the client'
16
- t.json :server_info, comment: 'The information about the server'
17
- t.json :client_info, comment: 'The information about the client'
18
- t.boolean :initialized, null: false, default: false
19
- t.integer :messages_count, null: false, default: 0
20
- t.json :tool_registry, default: []
21
- t.json :prompt_registry, default: []
22
- t.json :resource_registry, default: []
23
- t.timestamps
24
- end
25
- end
26
-
27
- # Create session messages table
28
- unless table_exists?(:action_mcp_session_messages)
29
- create_table :action_mcp_session_messages do |t|
30
- t.references :session, null: false,
31
- foreign_key: { to_table: :action_mcp_sessions,
32
- on_delete: :cascade,
33
- on_update: :cascade,
34
- name: 'fk_action_mcp_session_messages_session_id' }, type: :string
35
- t.string :direction, null: false, comment: 'The message recipient', default: 'client'
36
- t.string :message_type, null: false, comment: 'The type of the message'
37
- t.string :jsonrpc_id
38
- t.json :message_json
39
- t.boolean :is_ping, default: false, null: false, comment: 'Whether the message is a ping'
40
- t.boolean :request_acknowledged, default: false, null: false
41
- t.boolean :request_cancelled, null: false, default: false
42
- t.timestamps
43
- end
44
- end
45
-
46
- # Create session subscriptions table
47
- unless table_exists?(:action_mcp_session_subscriptions)
48
- create_table :action_mcp_session_subscriptions do |t|
49
- t.references :session,
50
- null: false,
51
- foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
52
- type: :string
53
- t.string :uri, null: false
54
- t.datetime :last_notification_at
55
- t.timestamps
56
- end
57
- end
58
-
59
- # Add missing columns to existing tables if they exist
60
-
61
- # For action_mcp_sessions
62
- if table_exists?(:action_mcp_sessions)
63
- unless column_exists?(:action_mcp_sessions, :tool_registry)
64
- add_column :action_mcp_sessions, :tool_registry, :json, default: []
65
- end
66
-
67
- unless column_exists?(:action_mcp_sessions, :prompt_registry)
68
- add_column :action_mcp_sessions, :prompt_registry, :json, default: []
69
- end
70
-
71
- unless column_exists?(:action_mcp_sessions, :resource_registry)
72
- add_column :action_mcp_sessions, :resource_registry, :json, default: []
73
- end
3
+ class ConsolidatedMigration < ActiveRecord::Migration[8.1]
4
+ def up
5
+ create_table :action_mcp_sessions, id: :string, if_not_exists: true do |t|
6
+ t.string :role, null: false, default: "server", comment: "The role of the session"
7
+ t.string :status, null: false, default: "pre_initialize"
8
+ t.datetime :ended_at, comment: "The time the session ended"
9
+ t.string :protocol_version
10
+ t.json :server_capabilities, comment: "The capabilities of the server"
11
+ t.json :client_capabilities, comment: "The capabilities of the client"
12
+ t.json :server_info, comment: "The information about the server"
13
+ t.json :client_info, comment: "The information about the client"
14
+ t.boolean :initialized, null: false, default: false
15
+ t.integer :messages_count, null: false, default: 0
16
+ t.json :tool_registry, default: []
17
+ t.json :prompt_registry, default: []
18
+ t.json :resource_registry, default: []
19
+ t.json :consents, default: {}, null: false
20
+ t.json :session_data, default: {}, null: false
21
+ t.timestamps
74
22
  end
75
23
 
76
- # For action_mcp_session_messages
77
- return unless table_exists?(:action_mcp_session_messages)
78
-
79
- unless column_exists?(:action_mcp_session_messages, :is_ping)
80
- add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false,
81
- comment: 'Whether the message is a ping'
82
- end
83
-
84
- unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
85
- add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false
24
+ add_column :action_mcp_sessions, :consents, :json, default: {}, null: false unless column_exists?(:action_mcp_sessions, :consents)
25
+ add_column :action_mcp_sessions, :session_data, :json, default: {}, null: false unless column_exists?(:action_mcp_sessions, :session_data)
26
+
27
+ create_table :action_mcp_session_messages, if_not_exists: true do |t|
28
+ t.references :session, null: false,
29
+ foreign_key: { to_table: :action_mcp_sessions,
30
+ on_delete: :cascade,
31
+ on_update: :cascade,
32
+ name: "fk_action_mcp_session_messages_session_id" },
33
+ type: :string
34
+ t.string :direction, null: false, comment: "The message recipient", default: "client"
35
+ t.string :message_type, null: false, comment: "The type of the message"
36
+ t.string :jsonrpc_id
37
+ t.json :message_json
38
+ t.boolean :is_ping, default: false, null: false, comment: "Whether the message is a ping"
39
+ t.boolean :request_acknowledged, default: false, null: false
40
+ t.boolean :request_cancelled, null: false, default: false
41
+ t.timestamps
86
42
  end
87
43
 
88
- unless column_exists?(:action_mcp_session_messages, :request_cancelled)
89
- add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
44
+ add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false unless column_exists?(:action_mcp_session_messages, :is_ping)
45
+ add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
46
+ add_column :action_mcp_session_messages, :request_cancelled, :boolean, default: false, null: false unless column_exists?(:action_mcp_session_messages, :request_cancelled)
47
+
48
+ create_table :action_mcp_session_subscriptions, if_not_exists: true do |t|
49
+ t.references :session, null: false,
50
+ foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
51
+ type: :string
52
+ t.string :uri, null: false
53
+ t.datetime :last_notification_at
54
+ t.timestamps
90
55
  end
91
56
 
92
- if column_exists?(:action_mcp_session_messages, :message_text)
93
- remove_column :action_mcp_session_messages, :message_text
57
+ create_table :action_mcp_session_tasks, id: :string, if_not_exists: true do |t|
58
+ t.references :session, null: false,
59
+ foreign_key: { to_table: :action_mcp_sessions,
60
+ on_delete: :cascade,
61
+ on_update: :cascade,
62
+ name: "fk_action_mcp_session_tasks_session_id" },
63
+ type: :string
64
+ t.string :status, null: false, default: "working"
65
+ t.string :status_message
66
+ t.string :request_method, comment: "e.g., tools/call, prompts/get"
67
+ t.string :request_name, comment: "e.g., tool name, prompt name"
68
+ t.json :request_params, comment: "Original request params"
69
+ t.json :result_payload, comment: "Final result data"
70
+ t.integer :ttl, comment: "Time to live in milliseconds"
71
+ t.integer :poll_interval, comment: "Suggested polling interval in milliseconds"
72
+ t.datetime :last_updated_at, null: false
73
+ t.json :continuation_state, default: {}
74
+ t.integer :progress_percent, comment: "Task progress as percentage 0-100"
75
+ t.string :progress_message, comment: "Human-readable progress message"
76
+ t.datetime :last_step_at
77
+ t.timestamps
78
+
79
+ t.index :status
80
+ t.index %i[session_id status]
81
+ t.index :created_at
94
82
  end
95
83
 
96
- return unless column_exists?(:action_mcp_session_messages, :direction)
97
-
98
- # SQLite3 doesn't support changing column comments
99
- return unless connection.adapter_name.downcase != 'sqlite'
100
-
101
- change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
102
- end
103
-
104
- private
105
-
106
- def table_exists?(table_name)
107
- ActionMCP::ApplicationRecord.connection.table_exists?(table_name)
108
- end
109
-
110
- def column_exists?(table_name, column_name)
111
- ActionMCP::ApplicationRecord.connection.column_exists?(table_name, column_name)
84
+ add_column :action_mcp_session_tasks, :continuation_state, :json, default: {} unless column_exists?(:action_mcp_session_tasks, :continuation_state)
85
+ add_column :action_mcp_session_tasks, :progress_percent, :integer unless column_exists?(:action_mcp_session_tasks, :progress_percent)
86
+ add_column :action_mcp_session_tasks, :progress_message, :string unless column_exists?(:action_mcp_session_tasks, :progress_message)
87
+ add_column :action_mcp_session_tasks, :last_step_at, :datetime unless column_exists?(:action_mcp_session_tasks, :last_step_at)
88
+
89
+ # Remove deprecated tables if they exist
90
+ drop_table :action_mcp_oauth_clients, if_exists: true
91
+ drop_table :action_mcp_oauth_tokens, if_exists: true
92
+ drop_table :action_mcp_sse_events, if_exists: true
93
+ drop_table :action_mcp_session_resources, if_exists: true
94
+
95
+ # Remove deprecated columns if they exist
96
+ remove_column :action_mcp_sessions, :oauth_access_token if column_exists?(:action_mcp_sessions, :oauth_access_token)
97
+ remove_column :action_mcp_sessions, :oauth_refresh_token if column_exists?(:action_mcp_sessions, :oauth_refresh_token)
98
+ remove_column :action_mcp_sessions, :oauth_token_expires_at if column_exists?(:action_mcp_sessions, :oauth_token_expires_at)
99
+ remove_column :action_mcp_sessions, :oauth_user_context if column_exists?(:action_mcp_sessions, :oauth_user_context)
100
+ remove_column :action_mcp_sessions, :sse_event_counter if column_exists?(:action_mcp_sessions, :sse_event_counter)
112
101
  end
113
102
  end
@@ -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,
@@ -58,7 +57,6 @@ module ActionMCP
58
57
  @list_changed = true
59
58
  @logging_level = :warning
60
59
  @resources_subscribe = false
61
- @elicitation_enabled = false
62
60
  @verbose_logging = false
63
61
  @active_profile = :primary
64
62
  @profiles = default_profiles
@@ -73,6 +71,10 @@ module ActionMCP
73
71
  @tasks_list_enabled = true
74
72
  @tasks_cancel_enabled = true
75
73
 
74
+ # Pagination - nil means off. Set a number to enable with that page size.
75
+ # Most MCP clients (including Claude Code) don't follow nextCursor yet.
76
+ @pagination_page_size = nil
77
+
76
78
  # Schema validation - disabled by default for backward compatibility
77
79
  @validate_structured_content = false
78
80
 
@@ -132,6 +134,19 @@ module ActionMCP
132
134
  @allowed_identity_keys = Array(value).map(&:to_s).freeze
133
135
  end
134
136
 
137
+ # Pagination page size. nil = pagination disabled, positive integer = enabled.
138
+ attr_reader :pagination_page_size
139
+
140
+ def pagination_page_size=(value)
141
+ if value.nil?
142
+ @pagination_page_size = nil
143
+ else
144
+ size = value.to_i
145
+ raise ArgumentError, "pagination_page_size must be a positive integer, got: #{value.inspect}" unless size > 0
146
+ @pagination_page_size = size
147
+ end
148
+ end
149
+
135
150
  def gateway_class
136
151
  # Resolve gateway class lazily to account for Zeitwerk autoloading
137
152
  # This allows ApplicationGateway to be loaded from app/mcp even if the
@@ -263,7 +278,6 @@ module ActionMCP
263
278
  capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
264
279
  end
265
280
 
266
- capabilities[:elicitation] = {} if @elicitation_enabled
267
281
 
268
282
  # Tasks capability (MCP 2025-11-25)
269
283
  if @tasks_enabled
@@ -383,6 +397,11 @@ module ActionMCP
383
397
  if config["server_instructions"]
384
398
  @server_instructions = parse_instructions(config["server_instructions"])
385
399
  end
400
+
401
+ # Extract pagination page size (nil = off, positive integer = on)
402
+ if config.key?("pagination_page_size")
403
+ self.pagination_page_size = config["pagination_page_size"]
404
+ end
386
405
  end
387
406
 
388
407
  def should_include_all?(type)
@@ -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