actionmcp 0.108.0 → 0.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +52 -3
- data/app/controllers/action_mcp/application_controller.rb +7 -3
- data/app/jobs/action_mcp/tool_execution_job.rb +13 -3
- data/app/models/action_mcp/session/task.rb +70 -15
- data/app/models/action_mcp/session.rb +18 -1
- data/lib/action_mcp/apps.rb +20 -0
- data/lib/action_mcp/capability.rb +5 -0
- data/lib/action_mcp/configuration.rb +69 -4
- data/lib/action_mcp/content/resource.rb +17 -2
- data/lib/action_mcp/engine.rb +3 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +0 -1
- data/lib/action_mcp/middleware/origin_validation.rb +94 -0
- data/lib/action_mcp/mime_types.rb +30 -0
- data/lib/action_mcp/resource.rb +16 -4
- data/lib/action_mcp/resource_template.rb +65 -4
- data/lib/action_mcp/server/base_session.rb +17 -1
- 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/handlers/task_handler.rb +6 -11
- data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
- 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 +97 -50
- data/lib/action_mcp/server/tools.rb +58 -29
- 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/tool.rb +31 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -0
- metadata +7 -2
- data/db/test.sqlite3 +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b49e1bd20853c60ec17b35b3ba822a275b8258800ce615707dcf85ecaa606dc8
|
|
4
|
+
data.tar.gz: c1abbbe666dbea818abde461aaae5d65f867700fa96e6d5c8011b12b10be1fcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 984a0a14b69ed4684ee7054db880320711c18613d4f5242922ac6eeb6946fb4c21954831c4ac935796f234f3abf8e691bc24c8ab219910fce8f9dd7a77600828
|
|
7
|
+
data.tar.gz: 8356febeb0097488d56a50e42160cf09c5e9be1e9952bf170fefb6f56e991079efc183bbca2e2080d2d3d9406c21a57e9211888eee405171bc4730903904559e
|
data/README.md
CHANGED
|
@@ -315,7 +315,7 @@ class BatchIndexTool < ApplicationMCPTool
|
|
|
315
315
|
end
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
-
Call it as a task from a client by adding `
|
|
318
|
+
Call it as a task from a client by adding top-level `task` params (creates a Task record and runs the tool via `ToolExecutionJob`):
|
|
319
319
|
|
|
320
320
|
```json
|
|
321
321
|
{
|
|
@@ -325,12 +325,27 @@ Call it as a task from a client by adding `_meta.task` (creates a Task record an
|
|
|
325
325
|
"params": {
|
|
326
326
|
"name": "batch_index",
|
|
327
327
|
"arguments": { "items": ["a", "b", "c"] },
|
|
328
|
-
"
|
|
328
|
+
"task": { "ttl": 120000 }
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
-
Poll task status with `tasks/get` or fetch the result
|
|
333
|
+
Poll task status with `tasks/get` or fetch the result with `tasks/result`.
|
|
334
|
+
By default, `tasks/result` uses spec-aligned blocking HTTP: if the task is still working, the request waits until the task reaches `completed`, `failed`, `cancelled`, or `input_required`, then returns one JSON response. Configure the wait bounds for your Rails app:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
config.action_mcp.tasks_result_strategy = :blocking_http
|
|
338
|
+
config.action_mcp.tasks_result_timeout = 30.seconds
|
|
339
|
+
config.action_mcp.tasks_result_poll_interval = 0.25.seconds
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Rails apps that cannot hold request workers open can opt into `:polling_only`, where clients must poll `tasks/get` until terminal or `input_required` before calling `tasks/result`. This is a deliberate MCP spec deviation:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
config.action_mcp.tasks_result_strategy = :polling_only
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available; treat cursors as opaque tokens.
|
|
334
349
|
|
|
335
350
|
### ActionMCP::ResourceTemplate
|
|
336
351
|
|
|
@@ -491,6 +506,25 @@ end
|
|
|
491
506
|
|
|
492
507
|
For dynamic versioning, consider adding the `rails_app_version` gem.
|
|
493
508
|
|
|
509
|
+
### Pagination
|
|
510
|
+
|
|
511
|
+
All list endpoints (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`) support cursor-based pagination per the MCP specification. Pagination is **off by default** — set `pagination_page_size` to enable:
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
config.action_mcp.pagination_page_size = 10
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Or in `config/mcp.yml`:
|
|
518
|
+
|
|
519
|
+
```yaml
|
|
520
|
+
shared:
|
|
521
|
+
pagination_page_size: 10
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
When set, responses include an opaque `nextCursor` when more items are available. When `nil` (default), all items are returned in a single response. Enable only when your clients support cursor-based pagination.
|
|
525
|
+
|
|
526
|
+
`tasks/list` always paginates regardless of this setting (defaults to 50 per page, or `pagination_page_size` if configured).
|
|
527
|
+
|
|
494
528
|
### Server Instructions
|
|
495
529
|
|
|
496
530
|
Server instructions help LLMs understand **what your server is for** and **when to use it**. They describe the server's purpose and goal, not technical details like rate limits or authentication (tools are self-documented via their own descriptions).
|
|
@@ -1263,6 +1297,21 @@ For comprehensive client documentation, including examples, session management,
|
|
|
1263
1297
|
- **Implement proper authorization** in your tools and prompts
|
|
1264
1298
|
- **Validate all inputs** using property definitions and Rails validations
|
|
1265
1299
|
- **Use consent management** for sensitive operations
|
|
1300
|
+
- **Protect against DNS rebinding**: ActionMCP validates the `Origin` header on every request. If `Origin` is present and its host doesn't match the server's own host, the server returns HTTP 403 with a JSON-RPC error body (no `id`), as required by the MCP spec. Non-browser clients (Claude Desktop, CLI tools) don't send `Origin` and are unaffected. To allow additional trusted origins, configure `allowed_origins`:
|
|
1301
|
+
|
|
1302
|
+
```ruby
|
|
1303
|
+
# config/initializers/action_mcp.rb
|
|
1304
|
+
ActionMCP.configure do |config|
|
|
1305
|
+
config.allowed_origins = ["app.example.com", "api.example.com"]
|
|
1306
|
+
end
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
For defence-in-depth, also configure Rails' `ActionDispatch::HostAuthorization` to restrict which `Host` headers are accepted (a separate check against host-header injection):
|
|
1310
|
+
|
|
1311
|
+
```ruby
|
|
1312
|
+
# config/environments/production.rb
|
|
1313
|
+
config.hosts = ["api.example.com"]
|
|
1314
|
+
```
|
|
1266
1315
|
|
|
1267
1316
|
### Performance
|
|
1268
1317
|
|
|
@@ -11,6 +11,9 @@ module ActionMCP
|
|
|
11
11
|
include JSONRPC_Rails::ControllerHelpers
|
|
12
12
|
include ActionController::Instrumentation
|
|
13
13
|
|
|
14
|
+
# Origin validation is enforced by ActionMCP::Middleware::OriginValidation
|
|
15
|
+
# (see engine.rb) so invalid requests are rejected before routing.
|
|
16
|
+
|
|
14
17
|
# Provides the ActionMCP::Session for the current request.
|
|
15
18
|
# Handles finding existing sessions via header/param or initializing a new one.
|
|
16
19
|
# Specific controllers/handlers might need to enforce session ID presence based on context.
|
|
@@ -34,6 +37,7 @@ module ActionMCP
|
|
|
34
37
|
def show
|
|
35
38
|
# MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
|
|
36
39
|
# ActionMCP uses Tasks for async operations instead of SSE streaming.
|
|
40
|
+
response.headers["Allow"] = "POST, DELETE"
|
|
37
41
|
head :method_not_allowed
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -95,7 +99,7 @@ module ActionMCP
|
|
|
95
99
|
result = json_rpc_handler.call(jsonrpc_params)
|
|
96
100
|
process_handler_results(result, session, session_initially_missing, is_initialize_request)
|
|
97
101
|
rescue StandardError => e
|
|
98
|
-
Rails.
|
|
102
|
+
Rails.error.report(e, handled: true, severity: :error)
|
|
99
103
|
id = begin
|
|
100
104
|
jsonrpc_params.respond_to?(:id) ? jsonrpc_params.id : nil
|
|
101
105
|
rescue StandardError
|
|
@@ -128,7 +132,7 @@ module ActionMCP
|
|
|
128
132
|
Rails.logger.info "Unified DELETE: Terminated session: #{session.id}" if ActionMCP.configuration.verbose_logging
|
|
129
133
|
head :no_content
|
|
130
134
|
rescue StandardError => e
|
|
131
|
-
Rails.
|
|
135
|
+
Rails.error.report(e, handled: true, severity: :error)
|
|
132
136
|
render_internal_server_error("Failed to terminate session.")
|
|
133
137
|
end
|
|
134
138
|
end
|
|
@@ -329,7 +333,7 @@ module ActionMCP
|
|
|
329
333
|
rescue ActionMCP::UnauthorizedError => e
|
|
330
334
|
render_unauthorized(e.message)
|
|
331
335
|
rescue StandardError => e
|
|
332
|
-
Rails.
|
|
336
|
+
Rails.error.report(e, handled: true, severity: :error)
|
|
333
337
|
render_unauthorized("Authentication system error")
|
|
334
338
|
end
|
|
335
339
|
end
|
|
@@ -53,6 +53,7 @@ module ActionMCP
|
|
|
53
53
|
session = task.session
|
|
54
54
|
unless session
|
|
55
55
|
task.update(status_message: "Session not found")
|
|
56
|
+
task.result_payload = { code: -32_603, message: "Session not found" }
|
|
56
57
|
task.mark_failed!
|
|
57
58
|
return nil
|
|
58
59
|
end
|
|
@@ -64,6 +65,10 @@ module ActionMCP
|
|
|
64
65
|
tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
|
|
65
66
|
unless tool_class
|
|
66
67
|
@task.update(status_message: "Tool '#{tool_name}' not found")
|
|
68
|
+
@task.result_payload = {
|
|
69
|
+
code: -32_601,
|
|
70
|
+
message: "Tool '#{tool_name}' not found"
|
|
71
|
+
}
|
|
67
72
|
@task.mark_failed!
|
|
68
73
|
return nil
|
|
69
74
|
end
|
|
@@ -101,12 +106,13 @@ module ActionMCP
|
|
|
101
106
|
def update_task_result(task, result)
|
|
102
107
|
return if task.terminal? # Guard against double-complete
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
payload = result.to_h
|
|
110
|
+
if result.is_error || payload[:isError] || payload["isError"]
|
|
111
|
+
task.result_payload = payload
|
|
106
112
|
task.status_message = result.respond_to?(:error_message) ? result.error_message : "Tool returned error"
|
|
107
113
|
task.mark_failed!
|
|
108
114
|
else
|
|
109
|
-
task.result_payload =
|
|
115
|
+
task.result_payload = payload
|
|
110
116
|
task.record_step!(:completed)
|
|
111
117
|
task.complete!
|
|
112
118
|
end
|
|
@@ -123,6 +129,10 @@ module ActionMCP
|
|
|
123
129
|
|
|
124
130
|
task.update(
|
|
125
131
|
status_message: "Job failed: #{error.message}",
|
|
132
|
+
result_payload: {
|
|
133
|
+
code: -32_603,
|
|
134
|
+
message: "Job failed: #{error.message}"
|
|
135
|
+
},
|
|
126
136
|
continuation_state: {
|
|
127
137
|
step: :failed,
|
|
128
138
|
error: { class: error.class.name, message: error.message },
|
|
@@ -52,6 +52,8 @@ module ActionMCP
|
|
|
52
52
|
# input_required -> completed | failed | cancelled
|
|
53
53
|
#
|
|
54
54
|
class Task < ApplicationRecord
|
|
55
|
+
RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"
|
|
56
|
+
|
|
55
57
|
self.table_name = "action_mcp_session_tasks"
|
|
56
58
|
|
|
57
59
|
attribute :id, :string, default: -> { SecureRandom.uuid_v7 }
|
|
@@ -68,7 +70,15 @@ module ActionMCP
|
|
|
68
70
|
# Scopes - state_machines >= 0.100.0 auto-generates .with_status(:state) scopes
|
|
69
71
|
scope :terminal, -> { with_status(:completed, :failed, :cancelled) }
|
|
70
72
|
scope :non_terminal, -> { with_status(:working, :input_required) }
|
|
71
|
-
scope :recent, -> { order(created_at: :desc) }
|
|
73
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
74
|
+
scope :before_recent, lambda { |task|
|
|
75
|
+
table = arel_table
|
|
76
|
+
|
|
77
|
+
where(
|
|
78
|
+
table[:created_at].lt(task.created_at)
|
|
79
|
+
.or(table[:created_at].eq(task.created_at).and(table[:id].lt(task.id)))
|
|
80
|
+
)
|
|
81
|
+
}
|
|
72
82
|
|
|
73
83
|
# State machine definition per MCP spec
|
|
74
84
|
state_machine :status, initial: :working do
|
|
@@ -131,6 +141,10 @@ module ActionMCP
|
|
|
131
141
|
status.in?(%w[completed failed cancelled])
|
|
132
142
|
end
|
|
133
143
|
|
|
144
|
+
def result_ready?
|
|
145
|
+
terminal? || input_required?
|
|
146
|
+
end
|
|
147
|
+
|
|
134
148
|
# Check if task is in a non-terminal state
|
|
135
149
|
def non_terminal?
|
|
136
150
|
!terminal?
|
|
@@ -140,32 +154,73 @@ module ActionMCP
|
|
|
140
154
|
# @return [Hash] Task data for JSON-RPC responses
|
|
141
155
|
def to_task_data
|
|
142
156
|
data = {
|
|
143
|
-
|
|
157
|
+
taskId: id,
|
|
144
158
|
status: status,
|
|
145
|
-
|
|
159
|
+
createdAt: created_at.iso8601(3),
|
|
160
|
+
lastUpdatedAt: last_updated_at.iso8601(3),
|
|
161
|
+
ttl: ttl
|
|
146
162
|
}
|
|
147
163
|
data[:statusMessage] = status_message if status_message.present?
|
|
148
|
-
|
|
149
|
-
# Add progress if available (ActiveJob::Continuable support)
|
|
150
|
-
if progress_percent.present? || progress_message.present?
|
|
151
|
-
data[:progress] = {}.tap do |progress|
|
|
152
|
-
progress[:percent] = progress_percent if progress_percent.present?
|
|
153
|
-
progress[:message] = progress_message if progress_message.present?
|
|
154
|
-
end
|
|
155
|
-
end
|
|
164
|
+
data[:pollInterval] = poll_interval if poll_interval.present?
|
|
156
165
|
|
|
157
166
|
data
|
|
158
167
|
end
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
# @return [Hash] Complete task with result for tasks/result response
|
|
162
|
-
def to_task_result
|
|
169
|
+
def to_create_task_result
|
|
163
170
|
{
|
|
164
171
|
task: to_task_data,
|
|
165
|
-
|
|
172
|
+
_meta: related_task_meta
|
|
166
173
|
}
|
|
167
174
|
end
|
|
168
175
|
|
|
176
|
+
# Convert to the original request's result payload for tasks/result.
|
|
177
|
+
# The result carries related-task metadata because its structure does not
|
|
178
|
+
# otherwise include the task identifier.
|
|
179
|
+
# @return [Hash] Result payload for tasks/result response
|
|
180
|
+
def to_task_result
|
|
181
|
+
payload = result_payload.is_a?(Hash) ? result_payload.deep_dup : {}
|
|
182
|
+
meta = payload.delete("_meta") || payload.delete(:_meta) || {}
|
|
183
|
+
meta = meta.to_h if meta.respond_to?(:to_h)
|
|
184
|
+
meta = {} unless meta.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
payload[:_meta] = meta.deep_merge(related_task_meta)
|
|
187
|
+
payload
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_task_error
|
|
191
|
+
return unless result_payload.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
code = result_payload["code"] || result_payload[:code]
|
|
194
|
+
message = result_payload["message"] || result_payload[:message]
|
|
195
|
+
return unless code && message
|
|
196
|
+
return if result_payload.key?("content") || result_payload.key?(:content)
|
|
197
|
+
return if result_payload.key?("isError") || result_payload.key?(:isError)
|
|
198
|
+
|
|
199
|
+
error = { code: code, message: message }
|
|
200
|
+
data = result_payload["data"] || result_payload[:data]
|
|
201
|
+
error[:data] = data unless data.nil?
|
|
202
|
+
error
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def related_task_meta
|
|
206
|
+
{
|
|
207
|
+
RELATED_TASK_META_KEY => {
|
|
208
|
+
"taskId" => id
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def request_meta_with_related_task(meta = nil)
|
|
214
|
+
existing_meta =
|
|
215
|
+
if meta.respond_to?(:to_h)
|
|
216
|
+
meta.to_h.deep_dup
|
|
217
|
+
else
|
|
218
|
+
{}
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
existing_meta.deep_merge(related_task_meta)
|
|
222
|
+
end
|
|
223
|
+
|
|
169
224
|
# Broadcast status change notification to the session
|
|
170
225
|
# @param transition [StateMachines::Transition] The state transition that occurred
|
|
171
226
|
def broadcast_status_change(transition = nil)
|
|
@@ -114,7 +114,7 @@ module ActionMCP
|
|
|
114
114
|
payload = {
|
|
115
115
|
protocolVersion: protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION,
|
|
116
116
|
serverInfo: server_info,
|
|
117
|
-
capabilities: server_capabilities
|
|
117
|
+
capabilities: capabilities_for_protocol(server_capabilities)
|
|
118
118
|
}
|
|
119
119
|
# Add instructions at top level if configured
|
|
120
120
|
instructions = ActionMCP.configuration.instructions
|
|
@@ -130,6 +130,23 @@ module ActionMCP
|
|
|
130
130
|
super(parsed_json_attribute(value))
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
def capabilities_for_protocol(capabilities)
|
|
134
|
+
parsed = parsed_json_attribute(capabilities)
|
|
135
|
+
filtered =
|
|
136
|
+
if parsed.respond_to?(:deep_dup)
|
|
137
|
+
parsed.deep_dup
|
|
138
|
+
elsif parsed
|
|
139
|
+
parsed.dup
|
|
140
|
+
else
|
|
141
|
+
{}
|
|
142
|
+
end
|
|
143
|
+
return filtered if protocol_version == "2025-11-25"
|
|
144
|
+
|
|
145
|
+
filtered.delete("tasks")
|
|
146
|
+
filtered.delete(:tasks)
|
|
147
|
+
filtered
|
|
148
|
+
end
|
|
149
|
+
|
|
133
150
|
def initialize!
|
|
134
151
|
# update the session initialized to true
|
|
135
152
|
return false if initialized?
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMCP
|
|
4
|
+
# Constants for the MCP Apps extension (ext-apps, SEP-1865, draft 2026-01-26).
|
|
5
|
+
# See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
|
|
6
|
+
module Apps
|
|
7
|
+
EXTENSION_KEY = "io.modelcontextprotocol/ui"
|
|
8
|
+
VISIBILITY_VALUES = %w[model app].freeze
|
|
9
|
+
URI_SCHEME = %r{\Aui://\S+\z}
|
|
10
|
+
MIME_TYPE = MimeTypes::APP_HTML
|
|
11
|
+
|
|
12
|
+
# `_meta.ui.csp` directive keys per ext-apps spec.
|
|
13
|
+
CSP_KEYS = %i[connectDomains resourceDomains frameDomains baseUriDomains].freeze
|
|
14
|
+
|
|
15
|
+
# Accepts http/https origins only. Wildcard subdomain (`https://*.example.com`),
|
|
16
|
+
# ports, and paths are allowed. WebSocket origins (ws://, wss://) are not
|
|
17
|
+
# accepted by ActionMCP — declare them via `ws://` over fetch if you must.
|
|
18
|
+
ORIGIN_PATTERN = %r{\Ahttps?://[^\s"'<>]+\z}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -32,6 +32,11 @@ module ActionMCP
|
|
|
32
32
|
|
|
33
33
|
delegate :session_data, to: :session, allow_nil: true
|
|
34
34
|
|
|
35
|
+
# Returns true when the connected client advertises the MCP Apps UI extension.
|
|
36
|
+
def client_supports_ui?
|
|
37
|
+
!session&.client_capabilities&.dig("extensions", Apps::EXTENSION_KEY).nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
# use _capability_name or default_capability_name
|
|
36
41
|
def self.capability_name
|
|
37
42
|
_capability_name || default_capability_name
|
|
@@ -25,7 +25,6 @@ module ActionMCP
|
|
|
25
25
|
:logging_level,
|
|
26
26
|
:active_profile,
|
|
27
27
|
:profiles,
|
|
28
|
-
:elicitation_enabled,
|
|
29
28
|
:verbose_logging,
|
|
30
29
|
# --- Authentication Options ---
|
|
31
30
|
:authentication_methods,
|
|
@@ -46,19 +45,23 @@ module ActionMCP
|
|
|
46
45
|
:tasks_enabled,
|
|
47
46
|
:tasks_list_enabled,
|
|
48
47
|
:tasks_cancel_enabled,
|
|
48
|
+
:tasks_result_strategy,
|
|
49
|
+
:tasks_result_timeout,
|
|
50
|
+
:tasks_result_poll_interval,
|
|
49
51
|
# --- Schema Validation Options ---
|
|
50
52
|
:validate_structured_content,
|
|
51
53
|
# --- Allowed identity keys for gateway ---
|
|
52
54
|
:allowed_identity_keys,
|
|
53
55
|
# --- JSON-RPC Path ---
|
|
54
|
-
:base_path
|
|
56
|
+
:base_path,
|
|
57
|
+
# --- Origin validation (DNS rebinding protection) ---
|
|
58
|
+
:allowed_origins
|
|
55
59
|
|
|
56
60
|
def initialize
|
|
57
61
|
@logging_enabled = false
|
|
58
62
|
@list_changed = true
|
|
59
63
|
@logging_level = :warning
|
|
60
64
|
@resources_subscribe = false
|
|
61
|
-
@elicitation_enabled = false
|
|
62
65
|
@verbose_logging = false
|
|
63
66
|
@active_profile = :primary
|
|
64
67
|
@profiles = default_profiles
|
|
@@ -72,6 +75,13 @@ module ActionMCP
|
|
|
72
75
|
@tasks_enabled = false
|
|
73
76
|
@tasks_list_enabled = true
|
|
74
77
|
@tasks_cancel_enabled = true
|
|
78
|
+
@tasks_result_strategy = :blocking_http
|
|
79
|
+
@tasks_result_timeout = 30.seconds
|
|
80
|
+
@tasks_result_poll_interval = 0.25
|
|
81
|
+
|
|
82
|
+
# Pagination - nil means off. Set a number to enable with that page size.
|
|
83
|
+
# Most MCP clients (including Claude Code) don't follow nextCursor yet.
|
|
84
|
+
@pagination_page_size = nil
|
|
75
85
|
|
|
76
86
|
# Schema validation - disabled by default for backward compatibility
|
|
77
87
|
@validate_structured_content = false
|
|
@@ -94,6 +104,9 @@ module ActionMCP
|
|
|
94
104
|
|
|
95
105
|
# Path for JSON-RPC endpoint
|
|
96
106
|
@base_path = "/"
|
|
107
|
+
|
|
108
|
+
# Allowed origins for DNS rebinding protection (nil = derive from request.host)
|
|
109
|
+
@allowed_origins = nil
|
|
97
110
|
end
|
|
98
111
|
|
|
99
112
|
def name
|
|
@@ -132,6 +145,36 @@ module ActionMCP
|
|
|
132
145
|
@allowed_identity_keys = Array(value).map(&:to_s).freeze
|
|
133
146
|
end
|
|
134
147
|
|
|
148
|
+
# Pagination page size. nil = pagination disabled, positive integer = enabled.
|
|
149
|
+
attr_reader :pagination_page_size
|
|
150
|
+
|
|
151
|
+
def pagination_page_size=(value)
|
|
152
|
+
if value.nil?
|
|
153
|
+
@pagination_page_size = nil
|
|
154
|
+
else
|
|
155
|
+
size = value.to_i
|
|
156
|
+
raise ArgumentError, "pagination_page_size must be a positive integer, got: #{value.inspect}" unless size > 0
|
|
157
|
+
@pagination_page_size = size
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def tasks_result_strategy=(value)
|
|
162
|
+
strategy = value.to_sym
|
|
163
|
+
unless %i[blocking_http polling_only].include?(strategy)
|
|
164
|
+
raise ArgumentError, "tasks_result_strategy must be :blocking_http or :polling_only, got: #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@tasks_result_strategy = strategy
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def tasks_result_timeout=(value)
|
|
171
|
+
@tasks_result_timeout = normalize_positive_duration(value, "tasks_result_timeout")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def tasks_result_poll_interval=(value)
|
|
175
|
+
@tasks_result_poll_interval = normalize_positive_duration(value, "tasks_result_poll_interval")
|
|
176
|
+
end
|
|
177
|
+
|
|
135
178
|
def gateway_class
|
|
136
179
|
# Resolve gateway class lazily to account for Zeitwerk autoloading
|
|
137
180
|
# This allows ApplicationGateway to be loaded from app/mcp even if the
|
|
@@ -263,7 +306,6 @@ module ActionMCP
|
|
|
263
306
|
capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
|
|
264
307
|
end
|
|
265
308
|
|
|
266
|
-
capabilities[:elicitation] = {} if @elicitation_enabled
|
|
267
309
|
|
|
268
310
|
# Tasks capability (MCP 2025-11-25)
|
|
269
311
|
if @tasks_enabled
|
|
@@ -383,6 +425,22 @@ module ActionMCP
|
|
|
383
425
|
if config["server_instructions"]
|
|
384
426
|
@server_instructions = parse_instructions(config["server_instructions"])
|
|
385
427
|
end
|
|
428
|
+
|
|
429
|
+
# Extract pagination page size (nil = off, positive integer = on)
|
|
430
|
+
if config.key?("pagination_page_size")
|
|
431
|
+
self.pagination_page_size = config["pagination_page_size"]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
self.tasks_result_strategy = config["tasks_result_strategy"] if config.key?("tasks_result_strategy")
|
|
435
|
+
self.tasks_result_timeout = config["tasks_result_timeout"] if config.key?("tasks_result_timeout")
|
|
436
|
+
if config.key?("tasks_result_poll_interval")
|
|
437
|
+
self.tasks_result_poll_interval = config["tasks_result_poll_interval"]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Extract allowed origins for DNS rebinding protection
|
|
441
|
+
if config["allowed_origins"]
|
|
442
|
+
@allowed_origins = Array(config["allowed_origins"])
|
|
443
|
+
end
|
|
386
444
|
end
|
|
387
445
|
|
|
388
446
|
def should_include_all?(type)
|
|
@@ -405,6 +463,13 @@ module ActionMCP
|
|
|
405
463
|
Array(instructions).map(&:to_s)
|
|
406
464
|
end
|
|
407
465
|
|
|
466
|
+
def normalize_positive_duration(value, setting_name)
|
|
467
|
+
duration = value.respond_to?(:to_f) ? value.to_f : value.to_s.to_f
|
|
468
|
+
raise ArgumentError, "#{setting_name} must be positive, got: #{value.inspect}" unless duration.positive?
|
|
469
|
+
|
|
470
|
+
duration
|
|
471
|
+
end
|
|
472
|
+
|
|
408
473
|
def ensure_mcp_components_loaded
|
|
409
474
|
# Only load if we haven't loaded yet - but in development, always reload
|
|
410
475
|
return if @mcp_components_loaded && !Rails.env.development?
|
|
@@ -9,7 +9,8 @@ module ActionMCP
|
|
|
9
9
|
# @return [String] The MIME type of the resource.
|
|
10
10
|
# @return [String, nil] The text content of the resource (optional).
|
|
11
11
|
# @return [String, nil] The base64-encoded blob of the resource (optional).
|
|
12
|
-
|
|
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/engine.rb
CHANGED
|
@@ -46,6 +46,9 @@ module ActionMCP
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
initializer "action_mcp.insert_middleware" do |app|
|
|
49
|
+
config.middleware.use ActionDispatch::HostAuthorization, app.config.hosts if app.config.hosts.present?
|
|
50
|
+
config.middleware.use ActionMCP::Middleware::OriginValidation,
|
|
51
|
+
[ ActionMCP.configuration.base_path ].compact.freeze
|
|
49
52
|
config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.base_path ].compact.freeze
|
|
50
53
|
end
|
|
51
54
|
|