actionmcp 0.108.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 917d5120acc0a5cb82cb4a9516a65e59370f922dfe2828670e1117a5d1d26934
4
- data.tar.gz: a77bd78234661ee4ef52cafff59f5d6c856e94728bca98832c926928635e8e94
3
+ metadata.gz: dbc03b1ba75f2efaf3c94ff2e664d3ca4e21013e8c55651f1fccff0b8c4e889b
4
+ data.tar.gz: 1576d864115954653182d05080352a28b7be2a11c8fb376f0e426503a320eb7f
5
5
  SHA512:
6
- metadata.gz: 6110ec397f08c3e8cbc2fe54a20f22bae3f600ad005e82f254bf6eeea7ba07c3bee1fc28a337a6263725fff213fdcbf092b08000285bdff0958b16fa5a958ccf
7
- data.tar.gz: f57f192f165bf2a5d44d18189e6107eb0ab951eeaf4f88ddc92fa2e3208bcc4bfe7a7d6aed9cd0e01b2951d07e6e8b2826f75e6033054130b304707b0bec03b1
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).
@@ -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
 
@@ -95,7 +96,7 @@ module ActionMCP
95
96
  result = json_rpc_handler.call(jsonrpc_params)
96
97
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
97
98
  rescue StandardError => e
98
- Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
99
+ Rails.error.report(e, handled: true, severity: :error)
99
100
  id = begin
100
101
  jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
101
102
  rescue StandardError
@@ -128,7 +129,7 @@ module ActionMCP
128
129
  Rails.logger.info "Unified DELETE: Terminated session: #{session.id}" if ActionMCP.configuration.verbose_logging
129
130
  head :no_content
130
131
  rescue StandardError => e
131
- Rails.logger.error "Unified DELETE: Error terminating session #{session.id}: #{e.class} - #{e.message}"
132
+ Rails.error.report(e, handled: true, severity: :error)
132
133
  render_internal_server_error("Failed to terminate session.")
133
134
  end
134
135
  end
@@ -329,7 +330,7 @@ module ActionMCP
329
330
  rescue ActionMCP::UnauthorizedError => e
330
331
  render_unauthorized(e.message)
331
332
  rescue StandardError => e
332
- Rails.logger.error "Gateway authentication error: #{e.class} - #{e.message}"
333
+ Rails.error.report(e, handled: true, severity: :error)
333
334
  render_unauthorized("Authentication system error")
334
335
  end
335
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
@@ -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
@@ -4,7 +4,7 @@ module ActionMCP
4
4
  # Represents a resource with its metadata.
5
5
  # Used by resources/list to describe concrete resources.
6
6
  class Resource
7
- attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations
7
+ attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :meta
8
8
 
9
9
  # @param uri [String] The URI of the resource
10
10
  # @param name [String] Display name of the resource
@@ -13,7 +13,8 @@ module ActionMCP
13
13
  # @param mime_type [String, nil] MIME type of the resource content
14
14
  # @param size [Integer, nil] Size of the resource in bytes
15
15
  # @param annotations [Hash, nil] Optional annotations
16
- def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
16
+ # @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
17
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, meta: nil)
17
18
  @uri = uri
18
19
  @name = name
19
20
  @title = title
@@ -21,6 +22,16 @@ module ActionMCP
21
22
  @mime_type = mime_type
22
23
  @size = size
23
24
  @annotations = annotations
25
+ @meta =
26
+ if meta.nil?
27
+ nil
28
+ elsif meta.respond_to?(:to_hash)
29
+ meta.to_hash
30
+ elsif meta.respond_to?(:to_h)
31
+ meta.to_h
32
+ else
33
+ raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
34
+ end
24
35
  freeze
25
36
  end
26
37
 
@@ -35,6 +46,7 @@ module ActionMCP
35
46
  hash[:mimeType] = mime_type if mime_type
36
47
  hash[:size] = size if size
37
48
  hash[:annotations] = annotations if annotations
49
+ hash[:_meta] = meta if meta && !meta.empty?
38
50
  hash
39
51
  end
40
52
 
@@ -46,12 +58,12 @@ module ActionMCP
46
58
  other.is_a?(Resource) && uri == other.uri && name == other.name &&
47
59
  title == other.title && description == other.description &&
48
60
  mime_type == other.mime_type && size == other.size &&
49
- annotations == other.annotations
61
+ annotations == other.annotations && meta == other.meta
50
62
  end
51
63
  alias eql? ==
52
64
 
53
65
  def hash
54
- [ uri, name, title, description, mime_type, size, annotations ].hash
66
+ [ uri, name, title, description, mime_type, size, annotations, meta ].hash
55
67
  end
56
68
  end
57
69
  end
@@ -156,8 +156,9 @@ module ActionMCP
156
156
  # @param mime_type [String, nil] Falls back to template mime_type
157
157
  # @param size [Integer, nil] Size in bytes
158
158
  # @param annotations [Hash, nil] Optional annotations
159
+ # @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata passed through to the Resource (emitted as `_meta`)
159
160
  # @return [ActionMCP::Resource]
160
- def build_resource(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
161
+ def build_resource(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, meta: nil)
161
162
  ActionMCP::Resource.new(
162
163
  uri: uri,
163
164
  name: name,
@@ -165,7 +166,8 @@ module ActionMCP
165
166
  description: description || @description,
166
167
  mime_type: mime_type || @mime_type,
167
168
  size: size,
168
- annotations: annotations
169
+ annotations: annotations,
170
+ meta: meta
169
171
  )
170
172
  end
171
173
 
@@ -1,58 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "elicitation_request"
4
+ require_relative "url_elicitation_request"
5
+
3
6
  module ActionMCP
4
7
  module Server
5
- # Handles elicitation requests from the server to the client
8
+ # Handles elicitation requests from the server to the client.
9
+ #
10
+ # Two modes per MCP 2025-11-25:
11
+ # - Form mode: structured data collection with JSON Schema validation
12
+ # - URL mode: out-of-band interaction via external URL (sensitive data, OAuth flows)
6
13
  module Elicitation
7
- # Sends an elicitation request to the client to gather additional information
8
- # @param request_id [String, Integer] The JSON-RPC request ID
9
- # @param message [String] The message to present to the user
10
- # @param requested_schema [Hash] The schema for the requested information
11
- # @return [Hash] The elicitation response
12
- def send_elicitation_request(request_id, message:, requested_schema:)
13
- # Validate the requested schema
14
- validate_elicitation_schema!(requested_schema)
15
-
16
- params = {
14
+ URL_ELICITATION_REQUIRED_CODE = -32_042
15
+
16
+ # Send a form mode elicitation request to the client.
17
+ # @param message [String] Human-readable message explaining why input is needed
18
+ # @param requested_schema [Hash] JSON Schema for the expected response (primitive types only)
19
+ # @param _meta [Hash] Optional metadata (e.g. related task)
20
+ def send_elicitation_create(message:, requested_schema:, _meta: {})
21
+ require_client_elicitation_support!(:form)
22
+
23
+ request = ElicitationRequest.new(
24
+ message: message,
25
+ requested_schema: requested_schema,
26
+ _meta: _meta
27
+ )
28
+ request.assert_valid!
29
+
30
+ send_jsonrpc_request("elicitation/create", params: request.to_params)
31
+ end
32
+
33
+ # Send a URL mode elicitation request to the client.
34
+ # Used for sensitive data collection (API keys, OAuth, payments) that must not
35
+ # pass through the MCP client.
36
+ # @param message [String] Human-readable message explaining why navigation is needed
37
+ # @param url [String] The URL the user should navigate to
38
+ # @param elicitation_id [String] Unique identifier for this elicitation
39
+ # @param _meta [Hash] Optional metadata (e.g. related task)
40
+ def send_elicitation_create_url(message:, url:, elicitation_id: nil, _meta: {})
41
+ require_client_elicitation_support!(:url)
42
+
43
+ request = UrlElicitationRequest.new(
44
+ message: message,
45
+ url: url,
46
+ elicitation_id: elicitation_id,
47
+ _meta: _meta
48
+ )
49
+ request.assert_valid!
50
+
51
+ send_jsonrpc_request("elicitation/create", params: request.to_params)
52
+ end
53
+
54
+ # Send a completion notification for a URL mode elicitation.
55
+ # Informs the client that the out-of-band interaction has completed.
56
+ # @param elicitation_id [String] The elicitation ID from the original request
57
+ def send_elicitation_complete_notification(elicitation_id)
58
+ require_client_elicitation_support!(:url)
59
+ send_jsonrpc_notification(
60
+ "notifications/elicitation/complete",
61
+ { elicitationId: elicitation_id }
62
+ )
63
+ end
64
+
65
+ # Build a URLElicitationRequiredError response (-32042).
66
+ # Used when a request cannot proceed until an elicitation is completed.
67
+ # @param request_id [String, Integer] The JSON-RPC request ID to respond to
68
+ # @param message [String] Human-readable error message
69
+ # @param elicitations [Array<Hash>] Required URL mode elicitations
70
+ def send_url_elicitation_required_error(request_id, message:, elicitations:)
71
+ require_client_elicitation_support!(:url)
72
+
73
+ elicitations.each do |e|
74
+ raise ArgumentError, "Each elicitation must have mode: 'url'" unless e[:mode] == "url"
75
+ raise ArgumentError, "Each elicitation must have an elicitationId" unless e[:elicitationId].present?
76
+
77
+ UrlElicitationRequest.new(
78
+ message: e[:message],
79
+ url: e[:url],
80
+ elicitation_id: e[:elicitationId]
81
+ ).assert_valid!
82
+ end
83
+
84
+ error = {
85
+ code: URL_ELICITATION_REQUIRED_CODE,
17
86
  message: message,
18
- requestedSchema: requested_schema
87
+ data: { elicitations: elicitations }
19
88
  }
20
89
 
21
- send_jsonrpc_request(request_id, method: "elicitation/create", params: params)
90
+ send_jsonrpc_response(request_id, error: error)
22
91
  end
23
92
 
24
93
  private
25
94
 
26
- # Validates that the requested schema follows the elicitation constraints
27
- # Only allows primitive types without nesting
28
- def validate_elicitation_schema!(schema)
29
- unless schema.is_a?(Hash) && schema[:type] == "object"
30
- raise ArgumentError, "Elicitation schema must be an object type"
31
- end
95
+ # Check that the client declared support for the given elicitation mode.
96
+ # Elicitation is a client capability — servers MUST NOT send modes the client didn't declare.
97
+ def require_client_elicitation_support!(mode)
98
+ client_caps = session.client_capabilities || {}
99
+ elicitation_caps = client_caps["elicitation"] || client_caps[:elicitation]
32
100
 
33
- properties = schema[:properties]
34
- raise ArgumentError, "Elicitation schema must have properties" unless properties.is_a?(Hash)
101
+ raise UnsupportedElicitationError, "Client does not support elicitation" unless elicitation_caps.is_a?(Hash)
35
102
 
36
- properties.each do |key, prop_schema|
37
- validate_primitive_schema!(key, prop_schema)
103
+ if mode == :form
104
+ # Empty hash or explicit form: {} both mean form support (backward compat with 2025-06-18)
105
+ # But if client only declared url: {} without form, reject
106
+ form_cap = elicitation_caps["form"] || elicitation_caps[:form]
107
+ unless elicitation_caps.empty? || form_cap
108
+ raise UnsupportedElicitationError, "Client does not support form mode elicitation"
109
+ end
110
+ return
38
111
  end
39
- end
40
112
 
41
- # Validates individual property schemas are primitive types
42
- def validate_primitive_schema!(key, schema)
43
- raise ArgumentError, "Property '#{key}' must have a schema definition" unless schema.is_a?(Hash)
44
-
45
- type = schema[:type]
46
- case type
47
- when "string"
48
- # Valid string schema, check for enums
49
- raise ArgumentError, "Property '#{key}' enum must be an array" if schema[:enum] && !schema[:enum].is_a?(Array)
50
- when "number", "integer", "boolean"
51
- # Valid primitive types
52
- else
53
- raise ArgumentError, "Property '#{key}' must be a primitive type (string, number, integer, boolean)"
113
+ # URL mode requires protocol version 2025-11-25+
114
+ unless session.protocol_version == "2025-11-25"
115
+ raise UnsupportedElicitationError, "URL mode elicitation requires protocol version 2025-11-25"
54
116
  end
117
+
118
+ # Client must explicitly declare url mode support (empty hash = form-only for 2025-06-18 clients)
119
+ url_cap = elicitation_caps["url"] || elicitation_caps[:url]
120
+ raise UnsupportedElicitationError, "Client does not support URL mode elicitation" unless url_cap
55
121
  end
56
122
  end
123
+
124
+ class UnsupportedElicitationError < StandardError; end
57
125
  end
58
126
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Value object for form-mode elicitation requests.
6
+ # Validates that the requested schema follows MCP constraints:
7
+ # flat object with primitive properties only.
8
+ class ElicitationRequest
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+
12
+ attribute :message, :string
13
+ attribute :requested_schema # Hash
14
+ attribute :_meta # Hash, optional
15
+
16
+ validates :message, presence: true
17
+ validates :requested_schema, presence: true
18
+ validate :schema_must_be_object_with_properties, if: -> { requested_schema.present? }
19
+ validate :properties_must_be_primitive, if: -> { errors[:requested_schema].empty? && requested_schema.present? }
20
+
21
+ # Wrap incoming schema in indifferent access so both string and symbol keys work.
22
+ def requested_schema=(value)
23
+ super(value.is_a?(Hash) ? value.with_indifferent_access : value)
24
+ end
25
+
26
+ # @return [Hash] JSON-RPC params for elicitation/create
27
+ def to_params
28
+ params = { mode: "form", message: message, requestedSchema: requested_schema.to_hash.deep_symbolize_keys }
29
+ params[:_meta] = _meta if _meta.present?
30
+ params
31
+ end
32
+
33
+ # Validates and raises ArgumentError on failure (preserving public API).
34
+ # Named assert_valid! to avoid shadowing ActiveModel#validate!
35
+ def assert_valid!
36
+ return if valid?
37
+
38
+ raise ArgumentError, errors.full_messages.join(", ")
39
+ end
40
+
41
+ private
42
+
43
+ def schema_must_be_object_with_properties
44
+ unless requested_schema.is_a?(Hash) && requested_schema[:type] == "object"
45
+ errors.add(:requested_schema, "must be an object type")
46
+ return
47
+ end
48
+
49
+ properties = requested_schema[:properties]
50
+ errors.add(:requested_schema, "must have properties") unless properties.is_a?(Hash)
51
+ end
52
+
53
+ def properties_must_be_primitive
54
+ properties = requested_schema[:properties]
55
+ return unless properties.is_a?(Hash)
56
+
57
+ properties.each do |key, prop_schema|
58
+ validate_primitive_property(key, prop_schema)
59
+ end
60
+ end
61
+
62
+ def validate_primitive_property(key, schema)
63
+ unless schema.is_a?(Hash)
64
+ errors.add(:requested_schema, "property '#{key}' must have a schema definition")
65
+ return
66
+ end
67
+
68
+ case schema[:type]
69
+ when "string"
70
+ validate_string_enum(key, schema)
71
+ when "number", "integer", "boolean"
72
+ # valid primitive types
73
+ when "array"
74
+ validate_enum_array(key, schema)
75
+ else
76
+ errors.add(:requested_schema,
77
+ "property '#{key}' must be a primitive type (string, number, integer, boolean) or enum array")
78
+ end
79
+ end
80
+
81
+ def validate_string_enum(key, schema)
82
+ if schema[:enum] && !schema[:enum].is_a?(Array)
83
+ errors.add(:requested_schema, "property '#{key}' enum must be an array")
84
+ end
85
+ end
86
+
87
+ def validate_enum_array(key, schema)
88
+ items = schema[:items]
89
+ unless items.is_a?(Hash)
90
+ errors.add(:requested_schema, "property '#{key}' array must have items schema")
91
+ return
92
+ end
93
+
94
+ unless items[:enum].is_a?(Array) || items[:anyOf].is_a?(Array)
95
+ errors.add(:requested_schema, "property '#{key}' array items must be an enum (enum or anyOf)")
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -35,8 +35,8 @@ module ActionMCP
35
35
  transport.send_prompts_get(id, name, arguments)
36
36
  end
37
37
 
38
- def handle_prompts_list(id, _params)
39
- transport.send_prompts_list(id)
38
+ def handle_prompts_list(id, params)
39
+ transport.send_prompts_list(id, params)
40
40
  end
41
41
 
42
42
  def extract_name(params)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Shared cursor-based pagination for all list endpoints.
6
+ #
7
+ # Two strategies:
8
+ # 1. Offset-based — for in-memory arrays (tools, prompts, resource templates).
9
+ # 2. Keyset-based — for ActiveRecord relations (tasks). Stable under concurrent writes.
10
+ #
11
+ # When pagination_page_size is nil (default), returns all items
12
+ # unless the caller passes force: true or the client sends a cursor.
13
+ module Pagination
14
+ DEFAULT_PAGE_SIZE = 10
15
+
16
+ # Offset-based pagination for in-memory collections (tools, prompts, resources).
17
+ # For ActiveRecord relations, use paginate_by_keyset instead.
18
+ #
19
+ # @param collection [Array] Items to paginate
20
+ # @param cursor [String, nil] Opaque cursor from the client
21
+ # @param page_size [Integer, nil] Override page size (nil = use config)
22
+ # @param force [Boolean] Force pagination even when globally disabled
23
+ # @return [Array(Array, String|nil)] [page_items, next_cursor_or_nil]
24
+ def paginate(collection, cursor: nil, page_size: nil, force: false)
25
+ effective_page_size = page_size || pagination_page_size
26
+ items = Array(collection)
27
+
28
+ return [ items, nil ] unless force || effective_page_size || cursor
29
+
30
+ page_size = effective_page_size || DEFAULT_PAGE_SIZE
31
+ offset = decode_offset_cursor(cursor)
32
+ page = items.drop(offset).take(page_size + 1)
33
+
34
+ has_more = page.size > page_size
35
+ page = page.first(page_size) if has_more
36
+ next_cursor = has_more ? encode_offset_cursor(offset + page_size) : nil
37
+
38
+ [ page, next_cursor ]
39
+ end
40
+
41
+ # Keyset-based pagination for ActiveRecord relations.
42
+ # Uses a single column as cursor (must be unique + ordered).
43
+ # The relation MUST already be ordered by that column.
44
+ #
45
+ # @param relation [ActiveRecord::Relation] Ordered AR relation
46
+ # @param cursor [String, nil] Opaque keyset cursor (Base64-encoded column value)
47
+ # @param page_size [Integer] Page size
48
+ # @param column [Symbol] Column to use as cursor key (default: :id)
49
+ # @return [Array(Array, String|nil)] [page_items, next_cursor_or_nil]
50
+ def paginate_by_keyset(relation, cursor: nil, page_size: DEFAULT_PAGE_SIZE, column: :id)
51
+ if cursor
52
+ value = decode_keyset_cursor(cursor)
53
+ relation = relation.where(column => ...value)
54
+ end
55
+
56
+ page = relation.limit(page_size + 1).to_a
57
+ has_more = page.size > page_size
58
+ items = has_more ? page.first(page_size) : page
59
+ next_cursor = has_more ? encode_keyset_cursor(items.last, column) : nil
60
+
61
+ [ items, next_cursor ]
62
+ end
63
+
64
+ private
65
+
66
+ def pagination_page_size
67
+ ActionMCP.configuration.pagination_page_size
68
+ end
69
+
70
+ # --- Offset cursors (in-memory arrays) ---
71
+
72
+ def decode_offset_cursor(cursor)
73
+ return 0 if cursor.nil?
74
+ raise CursorError, "Cursor must be a non-empty string" unless cursor.is_a?(String) && !cursor.empty?
75
+
76
+ decoded = Base64.urlsafe_decode64(cursor)
77
+ raise CursorError, "Invalid cursor format" unless decoded.match?(/\A\d+\z/)
78
+ decoded.to_i
79
+ rescue ArgumentError
80
+ raise CursorError, "Invalid cursor encoding"
81
+ end
82
+
83
+ def encode_offset_cursor(offset)
84
+ Base64.urlsafe_encode64(offset.to_s, padding: false)
85
+ end
86
+
87
+ # --- Keyset cursors (ActiveRecord) ---
88
+
89
+ def decode_keyset_cursor(cursor)
90
+ raise CursorError, "Cursor must be a non-empty string" unless cursor.is_a?(String) && !cursor.empty?
91
+ Base64.urlsafe_decode64(cursor)
92
+ rescue ArgumentError
93
+ raise CursorError, "Invalid cursor encoding"
94
+ end
95
+
96
+ def encode_keyset_cursor(record, column)
97
+ value = record.public_send(column)
98
+ Base64.urlsafe_encode64(value.to_s, padding: false)
99
+ end
100
+ end
101
+
102
+ # Raised when a client provides a malformed cursor.
103
+ # Handlers catch this and return -32602 (Invalid params).
104
+ class CursorError < StandardError; end
105
+ end
106
+ end
@@ -3,10 +3,15 @@
3
3
  module ActionMCP
4
4
  module Server
5
5
  module Prompts
6
- def send_prompts_list(request_id)
7
- # Use session's registered prompts
8
- prompts = session.registered_prompts.map(&:to_h)
9
- send_jsonrpc_response(request_id, result: { prompts: prompts })
6
+ def send_prompts_list(request_id, params = {})
7
+ page, next_cursor = paginate(session.registered_prompts, cursor: params["cursor"])
8
+
9
+ result = { prompts: page.map(&:to_h) }
10
+ result[:nextCursor] = next_cursor if next_cursor
11
+
12
+ send_jsonrpc_response(request_id, result: result)
13
+ rescue Server::CursorError => e
14
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
10
15
  end
11
16
 
12
17
  def send_prompts_get(request_id, prompt_name, params)
@@ -3,73 +3,23 @@
3
3
  module ActionMCP
4
4
  module Server
5
5
  module Resources
6
- # Default page size for cursor-based pagination
7
- RESOURCES_PAGE_SIZE = 100
8
-
9
6
  # Send list of concrete resources to the client.
10
7
  # Aggregates resources from templates that implement self.list.
11
8
  #
12
9
  # @param request_id [String, Integer] The ID of the request to respond to
13
10
  # @param params [Hash] Optional params including "cursor" for pagination
14
11
  def send_resources_list(request_id, params = {})
15
- templates = session.registered_resource_templates
16
-
17
- # Collect resources from templates that implement list
18
- all_resources = []
19
- seen_uris = {}
20
-
21
- templates.each do |template_class|
22
- next unless template_class.lists_resources?
23
-
24
- begin
25
- listed = template_class.list(session: session)
26
- rescue StandardError => e
27
- Rails.logger.error "[MCP] Error listing resources from #{template_class.name}: #{e.message}"
28
- next
29
- end
30
-
31
- unless listed.is_a?(Array)
32
- Rails.logger.warn "[MCP] #{template_class.name}.list returned #{listed.class}, expected Array; skipping"
33
- next
34
- end
12
+ all_resources = collect_resources(request_id)
13
+ return unless all_resources # nil means an error was already sent
35
14
 
36
- listed.each do |resource|
37
- unless resource.is_a?(ActionMCP::Resource)
38
- Rails.logger.warn "[MCP] #{template_class.name}.list returned non-Resource: #{resource.class}"
39
- next
40
- end
15
+ page, next_cursor = paginate(all_resources, cursor: params["cursor"])
41
16
 
42
- # Validate the listed URI is readable by the declaring template
43
- unless template_class.readable_uri?(resource.uri)
44
- Rails.logger.warn "[MCP] #{template_class.name}.list returned URI not readable by its own template: #{resource.uri}"
45
- next
46
- end
47
-
48
- # Deduplicate by URI
49
- if (existing = seen_uris[resource.uri])
50
- if existing == resource
51
- # Identical duplicate, skip silently
52
- next
53
- else
54
- # Conflicting metadata for same URI
55
- send_jsonrpc_error(request_id, :invalid_params,
56
- "Resource URI collision: '#{resource.uri}' listed by multiple templates with conflicting metadata")
57
- return
58
- end
59
- end
60
-
61
- seen_uris[resource.uri] = resource
62
- all_resources << resource
63
- end
64
- end
17
+ result = { resources: page.map(&:to_h) }
18
+ result[:nextCursor] = next_cursor if next_cursor
65
19
 
66
- # Apply cursor-based pagination
67
- result = paginate_resources(all_resources, params["cursor"])
68
- if result == :invalid_cursor
69
- send_jsonrpc_error(request_id, :invalid_params, "Invalid cursor value")
70
- return
71
- end
72
20
  send_jsonrpc_response(request_id, result: result)
21
+ rescue Server::CursorError => e
22
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
73
23
  end
74
24
 
75
25
  # Send list of resource templates to the client
@@ -80,13 +30,14 @@ module ActionMCP
80
30
  templates = session.registered_resource_templates.map(&:to_h)
81
31
  log_resource_templates
82
32
 
83
- # Apply cursor-based pagination
84
- result = paginate_templates(templates, params["cursor"])
85
- if result == :invalid_cursor
86
- send_jsonrpc_error(request_id, :invalid_params, "Invalid cursor value")
87
- return
88
- end
33
+ page, next_cursor = paginate(templates, cursor: params["cursor"])
34
+
35
+ result = { resourceTemplates: page }
36
+ result[:nextCursor] = next_cursor if next_cursor
37
+
89
38
  send_jsonrpc_response(request_id, result: result)
39
+ rescue Server::CursorError => e
40
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
90
41
  end
91
42
 
92
43
  # Read and return the contents of a resource
@@ -159,82 +110,73 @@ module ActionMCP
159
110
 
160
111
  private
161
112
 
113
+ # Collect all concrete resources from templates that implement list.
114
+ # Returns nil if a URI collision error was sent.
115
+ # @return [Array<ActionMCP::Resource>, nil]
116
+ def collect_resources(request_id)
117
+ all_resources = []
118
+ seen_uris = {}
119
+
120
+ session.registered_resource_templates.each do |template_class|
121
+ next unless template_class.lists_resources?
122
+
123
+ begin
124
+ listed = template_class.list(session: session)
125
+ rescue StandardError => e
126
+ Rails.logger.error "[MCP] Error listing resources from #{template_class.name}: #{e.message}"
127
+ next
128
+ end
129
+
130
+ unless listed.is_a?(Array)
131
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned #{listed.class}, expected Array; skipping"
132
+ next
133
+ end
134
+
135
+ listed.each do |resource|
136
+ unless resource.is_a?(ActionMCP::Resource)
137
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned non-Resource: #{resource.class}"
138
+ next
139
+ end
140
+
141
+ unless template_class.readable_uri?(resource.uri)
142
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned URI not readable by its own template: #{resource.uri}"
143
+ next
144
+ end
145
+
146
+ if (existing = seen_uris[resource.uri])
147
+ if existing == resource
148
+ next
149
+ else
150
+ send_jsonrpc_error(request_id, :invalid_params,
151
+ "Resource URI collision: '#{resource.uri}' listed by multiple templates with conflicting metadata")
152
+ return nil
153
+ end
154
+ end
155
+
156
+ seen_uris[resource.uri] = resource
157
+ all_resources << resource
158
+ end
159
+ end
160
+
161
+ all_resources
162
+ end
163
+
162
164
  # Normalize a content object to MCP ReadResourceResult content shape.
163
165
  #
164
- # @return [Hash] with keys: uri, mimeType, and text or blob
166
+ # @return [Hash] with keys: uri, mimeType, text or blob, and optional _meta
165
167
  def normalize_read_content(content, _uri)
166
168
  case content
167
169
  when ActionMCP::Content::Resource
168
170
  inner = { uri: content.uri, mimeType: content.mime_type }
169
171
  inner[:text] = content.text if content.text
170
172
  inner[:blob] = content.blob if content.blob
173
+ inner[:_meta] = content.meta if content.meta && !content.meta.empty?
171
174
  inner
172
175
  else
173
176
  content.respond_to?(:to_h) ? content.to_h : content
174
177
  end
175
178
  end
176
179
 
177
- # Paginate a list of resources with cursor support.
178
- #
179
- # @param resources [Array<ActionMCP::Resource>] All resources
180
- # @param cursor [String, nil] Base64-encoded offset cursor
181
- # @return [Hash] Result hash with :resources and optional :nextCursor
182
- def paginate_resources(resources, cursor)
183
- offset = decode_cursor(cursor)
184
- return :invalid_cursor if offset == :invalid
185
-
186
- page = resources[offset, RESOURCES_PAGE_SIZE] || []
187
-
188
- result = { resources: page.map(&:to_h) }
189
-
190
- next_offset = offset + RESOURCES_PAGE_SIZE
191
- if next_offset < resources.size
192
- result[:nextCursor] = encode_cursor(next_offset)
193
- end
194
-
195
- result
196
- end
197
-
198
- # Paginate a list of templates with cursor support.
199
- #
200
- # @param templates [Array<Hash>] All template hashes
201
- # @param cursor [String, nil] Base64-encoded offset cursor
202
- # @return [Hash] Result hash with :resourceTemplates and optional :nextCursor
203
- def paginate_templates(templates, cursor)
204
- offset = decode_cursor(cursor)
205
- return :invalid_cursor if offset == :invalid
206
-
207
- page = templates[offset, RESOURCES_PAGE_SIZE] || []
208
-
209
- result = { resourceTemplates: page }
210
-
211
- next_offset = offset + RESOURCES_PAGE_SIZE
212
- if next_offset < templates.size
213
- result[:nextCursor] = encode_cursor(next_offset)
214
- end
215
-
216
- result
217
- end
218
-
219
- # Decode a cursor string to a non-negative integer offset.
220
- # Returns 0 for nil cursors, :invalid for malformed/negative/non-string values.
221
- def decode_cursor(cursor)
222
- return 0 if cursor.nil?
223
- return :invalid unless cursor.is_a?(String) && !cursor.empty?
224
-
225
- decoded = Base64.strict_decode64(cursor)
226
- return :invalid unless decoded.match?(/\A\d+\z/)
227
-
228
- decoded.to_i
229
- rescue ArgumentError
230
- :invalid
231
- end
232
-
233
- # Encode an integer offset as a cursor string.
234
- def encode_cursor(offset)
235
- Base64.strict_encode64(offset.to_s)
236
- end
237
-
238
180
  # Log all registered resource templates
239
181
  def log_resource_templates
240
182
  Rails.logger.debug "Registered Resource Templates: #{ActionMCP::ResourceTemplatesRegistry.resource_templates.keys}"
@@ -37,25 +37,21 @@ module ActionMCP
37
37
  send_jsonrpc_response(request_id, result: task.to_task_result)
38
38
  end
39
39
 
40
- # List tasks for the session with optional pagination
40
+ # List tasks for the session with keyset pagination.
41
+ # Tasks always paginate (AR-backed, can grow unbounded).
42
+ # Cursor is the last task id from the previous page. We resolve it
43
+ # through AR so the boundary matches the recent scope exactly.
41
44
  # @param request_id [String, Integer] JSON-RPC request ID
42
45
  # @param cursor [String, nil] Pagination cursor
43
46
  def send_tasks_list(request_id, cursor: nil)
44
- # Parse cursor if provided
45
- offset = cursor.to_i if cursor.present?
46
- offset ||= 0
47
- limit = 50
47
+ page, next_cursor = paginate_tasks_by_recent(cursor: cursor, page_size: pagination_page_size || 50)
48
48
 
49
- tasks = session.tasks.recent.offset(offset).limit(limit + 1)
50
- has_more = tasks.length > limit
51
- tasks = tasks.first(limit)
52
-
53
- result = {
54
- tasks: tasks.map(&:to_task_data)
55
- }
56
- result[:nextCursor] = (offset + limit).to_s if has_more
49
+ result = { tasks: page.map(&:to_task_data) }
50
+ result[:nextCursor] = next_cursor if next_cursor
57
51
 
58
52
  send_jsonrpc_response(request_id, result: result)
53
+ rescue Server::CursorError => e
54
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
59
55
  end
60
56
 
61
57
  # Cancel a task
@@ -120,6 +116,30 @@ module ActionMCP
120
116
  end
121
117
  task
122
118
  end
119
+
120
+ def paginate_tasks_by_recent(cursor:, page_size:)
121
+ relation = session.tasks.recent
122
+
123
+ if cursor
124
+ cursor_task = find_task_cursor(cursor)
125
+ relation = relation.before_recent(cursor_task)
126
+ end
127
+
128
+ page = relation.limit(page_size + 1).to_a
129
+ has_more = page.size > page_size
130
+ items = has_more ? page.first(page_size) : page
131
+ next_cursor = has_more ? encode_keyset_cursor(items.last, :id) : nil
132
+
133
+ [ items, next_cursor ]
134
+ end
135
+
136
+ def find_task_cursor(cursor)
137
+ task_id = decode_keyset_cursor(cursor)
138
+ task = session.tasks.select(:id, :created_at).find_by(id: task_id)
139
+ raise Server::CursorError, "Invalid cursor" unless task
140
+
141
+ task
142
+ end
123
143
  end
124
144
  end
125
145
  end
@@ -5,35 +5,24 @@ module ActionMCP
5
5
  module Tools
6
6
  def send_tools_list(request_id, params = {})
7
7
  protocol_version = session.protocol_version
8
- # Extract progress token from _meta if provided
9
8
  progress_token = params.dig("_meta", "progressToken")
10
9
 
11
- # Send initial progress notification if token is provided
12
10
  if progress_token
13
- send_progress_notification(
14
- progressToken: progress_token,
15
- progress: 0,
16
- message: "Starting tools list retrieval"
17
- )
11
+ send_progress_notification(progressToken: progress_token, progress: 0, message: "Starting tools list retrieval")
18
12
  end
19
13
 
20
- # Use session's registered tools instead of global registry
21
- registered_tools = session.registered_tools
14
+ page, next_cursor = paginate(session.registered_tools, cursor: params["cursor"])
22
15
 
23
- tools = registered_tools.map do |tool_class|
24
- tool_class.to_h(protocol_version: protocol_version)
25
- end
16
+ result = { tools: page.map { |t| t.to_h(protocol_version: protocol_version) } }
17
+ result[:nextCursor] = next_cursor if next_cursor
26
18
 
27
- # Send completion progress notification if token is provided
28
19
  if progress_token
29
- send_progress_notification(
30
- progressToken: progress_token,
31
- progress: 100,
32
- message: "Tools list retrieval complete"
33
- )
20
+ send_progress_notification(progressToken: progress_token, progress: 100, message: "Tools list retrieval complete")
34
21
  end
35
22
 
36
- send_jsonrpc_response(request_id, result: { tools: tools })
23
+ send_jsonrpc_response(request_id, result: result)
24
+ rescue Server::CursorError => e
25
+ send_jsonrpc_error(request_id, :invalid_params, e.message)
37
26
  end
38
27
 
39
28
  def send_tools_call(request_id, tool_name, arguments, _meta = {})
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "pagination"
3
4
  require_relative "response_collector"
4
5
  require_relative "base_messaging"
5
6
 
@@ -11,6 +12,7 @@ module ActionMCP
11
12
  delegate :initialize!, :initialized?, to: :session
12
13
  delegate :read, :write, to: :session
13
14
 
15
+ include Pagination
14
16
  include MessagingService
15
17
  include Capabilities
16
18
  include Tools
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ActionMCP
6
+ module Server
7
+ # Value object for URL-mode elicitation requests.
8
+ # Used for sensitive data collection (API keys, OAuth, payments)
9
+ # that must not pass through the MCP client.
10
+ class UrlElicitationRequest
11
+ include ActiveModel::Model
12
+ include ActiveModel::Attributes
13
+
14
+ attribute :message, :string
15
+ attribute :url, :string
16
+ attribute :elicitation_id, :string
17
+ attribute :_meta # Hash, optional
18
+
19
+ validates :message, presence: true
20
+ validates :url, presence: true
21
+ validate :url_must_be_valid_http, if: -> { url.present? }
22
+
23
+ def initialize(attributes = {})
24
+ super
25
+ self.elicitation_id = SecureRandom.uuid_v7 if elicitation_id.blank?
26
+ end
27
+
28
+ # @return [Hash] JSON-RPC params for elicitation/create
29
+ def to_params
30
+ params = {
31
+ mode: "url",
32
+ message: message,
33
+ url: url,
34
+ elicitationId: elicitation_id
35
+ }
36
+ params[:_meta] = _meta if _meta.present?
37
+ params
38
+ end
39
+
40
+ # Validates and raises ArgumentError on failure (preserving public API).
41
+ # Named assert_valid! to avoid shadowing ActiveModel#validate!
42
+ def assert_valid!
43
+ return if valid?
44
+
45
+ raise ArgumentError, errors.full_messages.join(", ")
46
+ end
47
+
48
+ private
49
+
50
+ def url_must_be_valid_http
51
+ parsed = URI.parse(url)
52
+ unless parsed.is_a?(URI::HTTP) && parsed.host.present?
53
+ errors.add(:url, "must be an HTTP or HTTPS URL with a host")
54
+ end
55
+ rescue URI::InvalidURIError
56
+ errors.add(:url, "is not a valid URI")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.108.0"
5
+ VERSION = "0.109.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
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.108.0
4
+ version: 0.109.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -218,6 +218,7 @@ files:
218
218
  - lib/action_mcp/server/base_session_store.rb
219
219
  - lib/action_mcp/server/capabilities.rb
220
220
  - lib/action_mcp/server/elicitation.rb
221
+ - lib/action_mcp/server/elicitation_request.rb
221
222
  - lib/action_mcp/server/error_aware.rb
222
223
  - lib/action_mcp/server/error_handling.rb
223
224
  - lib/action_mcp/server/handlers/logging_handler.rb
@@ -228,6 +229,7 @@ files:
228
229
  - lib/action_mcp/server/handlers/tool_handler.rb
229
230
  - lib/action_mcp/server/json_rpc_handler.rb
230
231
  - lib/action_mcp/server/messaging_service.rb
232
+ - lib/action_mcp/server/pagination.rb
231
233
  - lib/action_mcp/server/prompts.rb
232
234
  - lib/action_mcp/server/registry_management.rb
233
235
  - lib/action_mcp/server/resources.rb
@@ -241,6 +243,7 @@ files:
241
243
  - lib/action_mcp/server/test_session_store.rb
242
244
  - lib/action_mcp/server/tools.rb
243
245
  - lib/action_mcp/server/transport_handler.rb
246
+ - lib/action_mcp/server/url_elicitation_request.rb
244
247
  - lib/action_mcp/server/volatile_session_store.rb
245
248
  - lib/action_mcp/string_array.rb
246
249
  - lib/action_mcp/tagged_stream_logging.rb