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 +4 -4
- data/README.md +20 -1
- data/app/controllers/action_mcp/application_controller.rb +4 -3
- data/app/models/action_mcp/session/task.rb +9 -1
- data/lib/action_mcp/configuration.rb +22 -3
- data/lib/action_mcp/content/resource.rb +17 -2
- data/lib/action_mcp/resource.rb +16 -4
- data/lib/action_mcp/resource_template.rb +4 -2
- data/lib/action_mcp/server/elicitation.rb +105 -37
- data/lib/action_mcp/server/elicitation_request.rb +100 -0
- data/lib/action_mcp/server/handlers/prompt_handler.rb +2 -2
- data/lib/action_mcp/server/pagination.rb +106 -0
- data/lib/action_mcp/server/prompts.rb +9 -4
- data/lib/action_mcp/server/resources.rb +67 -125
- data/lib/action_mcp/server/tasks.rb +33 -13
- data/lib/action_mcp/server/tools.rb +8 -19
- data/lib/action_mcp/server/transport_handler.rb +2 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +60 -0
- data/lib/action_mcp/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbc03b1ba75f2efaf3c94ff2e664d3ca4e21013e8c55651f1fccff0b8c4e889b
|
|
4
|
+
data.tar.gz: 1576d864115954653182d05080352a28b7be2a11c8fb376f0e426503a320eb7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/action_mcp/resource.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
# @param
|
|
11
|
-
# @
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
87
|
+
data: { elicitations: elicitations }
|
|
19
88
|
}
|
|
20
89
|
|
|
21
|
-
|
|
90
|
+
send_jsonrpc_response(request_id, error: error)
|
|
22
91
|
end
|
|
23
92
|
|
|
24
93
|
private
|
|
25
94
|
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
21
|
-
registered_tools = session.registered_tools
|
|
14
|
+
page, next_cursor = paginate(session.registered_tools, cursor: params["cursor"])
|
|
22
15
|
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
data/lib/action_mcp/version.rb
CHANGED
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.
|
|
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
|