actionmcp 0.109.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 +33 -3
- data/app/controllers/action_mcp/application_controller.rb +3 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +13 -3
- data/app/models/action_mcp/session/task.rb +61 -14
- 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 +47 -1
- 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_template.rb +61 -2
- data/lib/action_mcp/server/base_session.rb +17 -1
- 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/tasks.rb +64 -37
- data/lib/action_mcp/server/tools.rb +50 -10
- data/lib/action_mcp/tool.rb +31 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -0
- metadata +4 -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
|
|
|
@@ -1282,6 +1297,21 @@ For comprehensive client documentation, including examples, session management,
|
|
|
1282
1297
|
- **Implement proper authorization** in your tools and prompts
|
|
1283
1298
|
- **Validate all inputs** using property definitions and Rails validations
|
|
1284
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
|
+
```
|
|
1285
1315
|
|
|
1286
1316
|
### Performance
|
|
1287
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.
|
|
@@ -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 }
|
|
@@ -139,6 +141,10 @@ module ActionMCP
|
|
|
139
141
|
status.in?(%w[completed failed cancelled])
|
|
140
142
|
end
|
|
141
143
|
|
|
144
|
+
def result_ready?
|
|
145
|
+
terminal? || input_required?
|
|
146
|
+
end
|
|
147
|
+
|
|
142
148
|
# Check if task is in a non-terminal state
|
|
143
149
|
def non_terminal?
|
|
144
150
|
!terminal?
|
|
@@ -148,32 +154,73 @@ module ActionMCP
|
|
|
148
154
|
# @return [Hash] Task data for JSON-RPC responses
|
|
149
155
|
def to_task_data
|
|
150
156
|
data = {
|
|
151
|
-
|
|
157
|
+
taskId: id,
|
|
152
158
|
status: status,
|
|
153
|
-
|
|
159
|
+
createdAt: created_at.iso8601(3),
|
|
160
|
+
lastUpdatedAt: last_updated_at.iso8601(3),
|
|
161
|
+
ttl: ttl
|
|
154
162
|
}
|
|
155
163
|
data[:statusMessage] = status_message if status_message.present?
|
|
156
|
-
|
|
157
|
-
# Add progress if available (ActiveJob::Continuable support)
|
|
158
|
-
if progress_percent.present? || progress_message.present?
|
|
159
|
-
data[:progress] = {}.tap do |progress|
|
|
160
|
-
progress[:percent] = progress_percent if progress_percent.present?
|
|
161
|
-
progress[:message] = progress_message if progress_message.present?
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
+
data[:pollInterval] = poll_interval if poll_interval.present?
|
|
164
165
|
|
|
165
166
|
data
|
|
166
167
|
end
|
|
167
168
|
|
|
168
|
-
|
|
169
|
-
# @return [Hash] Complete task with result for tasks/result response
|
|
170
|
-
def to_task_result
|
|
169
|
+
def to_create_task_result
|
|
171
170
|
{
|
|
172
171
|
task: to_task_data,
|
|
173
|
-
|
|
172
|
+
_meta: related_task_meta
|
|
174
173
|
}
|
|
175
174
|
end
|
|
176
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
|
+
|
|
177
224
|
# Broadcast status change notification to the session
|
|
178
225
|
# @param transition [StateMachines::Transition] The state transition that occurred
|
|
179
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
|
|
@@ -45,12 +45,17 @@ module ActionMCP
|
|
|
45
45
|
:tasks_enabled,
|
|
46
46
|
:tasks_list_enabled,
|
|
47
47
|
:tasks_cancel_enabled,
|
|
48
|
+
:tasks_result_strategy,
|
|
49
|
+
:tasks_result_timeout,
|
|
50
|
+
:tasks_result_poll_interval,
|
|
48
51
|
# --- Schema Validation Options ---
|
|
49
52
|
:validate_structured_content,
|
|
50
53
|
# --- Allowed identity keys for gateway ---
|
|
51
54
|
:allowed_identity_keys,
|
|
52
55
|
# --- JSON-RPC Path ---
|
|
53
|
-
:base_path
|
|
56
|
+
:base_path,
|
|
57
|
+
# --- Origin validation (DNS rebinding protection) ---
|
|
58
|
+
:allowed_origins
|
|
54
59
|
|
|
55
60
|
def initialize
|
|
56
61
|
@logging_enabled = false
|
|
@@ -70,6 +75,9 @@ module ActionMCP
|
|
|
70
75
|
@tasks_enabled = false
|
|
71
76
|
@tasks_list_enabled = true
|
|
72
77
|
@tasks_cancel_enabled = true
|
|
78
|
+
@tasks_result_strategy = :blocking_http
|
|
79
|
+
@tasks_result_timeout = 30.seconds
|
|
80
|
+
@tasks_result_poll_interval = 0.25
|
|
73
81
|
|
|
74
82
|
# Pagination - nil means off. Set a number to enable with that page size.
|
|
75
83
|
# Most MCP clients (including Claude Code) don't follow nextCursor yet.
|
|
@@ -96,6 +104,9 @@ module ActionMCP
|
|
|
96
104
|
|
|
97
105
|
# Path for JSON-RPC endpoint
|
|
98
106
|
@base_path = "/"
|
|
107
|
+
|
|
108
|
+
# Allowed origins for DNS rebinding protection (nil = derive from request.host)
|
|
109
|
+
@allowed_origins = nil
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
def name
|
|
@@ -147,6 +158,23 @@ module ActionMCP
|
|
|
147
158
|
end
|
|
148
159
|
end
|
|
149
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
|
+
|
|
150
178
|
def gateway_class
|
|
151
179
|
# Resolve gateway class lazily to account for Zeitwerk autoloading
|
|
152
180
|
# This allows ApplicationGateway to be loaded from app/mcp even if the
|
|
@@ -402,6 +430,17 @@ module ActionMCP
|
|
|
402
430
|
if config.key?("pagination_page_size")
|
|
403
431
|
self.pagination_page_size = config["pagination_page_size"]
|
|
404
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
|
|
405
444
|
end
|
|
406
445
|
|
|
407
446
|
def should_include_all?(type)
|
|
@@ -424,6 +463,13 @@ module ActionMCP
|
|
|
424
463
|
Array(instructions).map(&:to_s)
|
|
425
464
|
end
|
|
426
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
|
+
|
|
427
473
|
def ensure_mcp_components_loaded
|
|
428
474
|
# Only load if we haven't loaded yet - but in development, always reload
|
|
429
475
|
return if @mcp_components_loaded && !Rails.env.development?
|
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
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ActionMCP
|
|
6
|
+
module Middleware
|
|
7
|
+
# Rack middleware that validates the Origin header on MCP requests to
|
|
8
|
+
# prevent DNS rebinding attacks per the MCP Streamable HTTP security
|
|
9
|
+
# section. Non-browser clients (Claude Desktop, curl) never send Origin
|
|
10
|
+
# and are always allowed. Present Origins must match either
|
|
11
|
+
# `ActionMCP.configuration.allowed_origins` or the server's own host.
|
|
12
|
+
#
|
|
13
|
+
# Runs as middleware — same layer as `ActionDispatch::HostAuthorization` —
|
|
14
|
+
# so invalid requests are rejected before they reach routing.
|
|
15
|
+
class OriginValidation
|
|
16
|
+
INVALID_REQUEST_CODE = -32_600
|
|
17
|
+
|
|
18
|
+
# @param app [#call]
|
|
19
|
+
# @param paths [Array<String, Regexp, Proc>, nil] paths to guard.
|
|
20
|
+
# Nil or empty means every request is guarded.
|
|
21
|
+
def initialize(app, paths = nil)
|
|
22
|
+
@app = app
|
|
23
|
+
@paths = Array(paths)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(env)
|
|
27
|
+
return @app.call(env) unless guard_path?(env["PATH_INFO"])
|
|
28
|
+
|
|
29
|
+
request = ActionDispatch::Request.new(env)
|
|
30
|
+
origin = request.origin
|
|
31
|
+
return @app.call(env) if origin.nil? || origin.empty?
|
|
32
|
+
return @app.call(env) if origin_allowed?(origin, request)
|
|
33
|
+
|
|
34
|
+
forbidden_response
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def guard_path?(path)
|
|
40
|
+
return true if @paths.empty?
|
|
41
|
+
|
|
42
|
+
@paths.any? do |matcher|
|
|
43
|
+
case matcher
|
|
44
|
+
when String then path == matcher
|
|
45
|
+
when Regexp then matcher.match?(path)
|
|
46
|
+
when Proc then matcher.call(path)
|
|
47
|
+
else false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def origin_allowed?(origin, request)
|
|
53
|
+
return false if origin == "null"
|
|
54
|
+
|
|
55
|
+
uri = URI.parse(origin)
|
|
56
|
+
return false if uri.host.nil? || uri.host.empty?
|
|
57
|
+
|
|
58
|
+
origin_host = strip_brackets(uri.host)
|
|
59
|
+
allowed = ActionMCP.configuration.allowed_origins
|
|
60
|
+
|
|
61
|
+
if allowed && !allowed.empty?
|
|
62
|
+
allowed.any? { |pattern| match?(pattern, origin_host) }
|
|
63
|
+
else
|
|
64
|
+
# ActionDispatch::Request#host strips the port and handles IPv6 brackets
|
|
65
|
+
origin_host.casecmp?(strip_brackets(request.host))
|
|
66
|
+
end
|
|
67
|
+
rescue URI::InvalidURIError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def match?(pattern, host)
|
|
72
|
+
case pattern
|
|
73
|
+
when Regexp then pattern.match?(host)
|
|
74
|
+
when String then host.casecmp?(strip_brackets(pattern))
|
|
75
|
+
else false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def strip_brackets(host)
|
|
80
|
+
host.to_s.delete_prefix("[").delete_suffix("]")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def forbidden_response
|
|
84
|
+
body = {
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
id: nil,
|
|
87
|
+
error: { code: INVALID_REQUEST_CODE, message: "Forbidden: invalid Origin header" }
|
|
88
|
+
}.to_json
|
|
89
|
+
|
|
90
|
+
[ 403, { "Content-Type" => "application/json" }, [ body ] ]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMCP
|
|
4
|
+
# Engine-owned MIME type registry. Keeps protocol-level MIME values out of
|
|
5
|
+
# Rails' global `Mime::Type` registry while still letting the DSL accept
|
|
6
|
+
# short symbols. For symbols not in our table, falls back to Rails' global
|
|
7
|
+
# registry so apps can still use their own registered formats.
|
|
8
|
+
module MimeTypes
|
|
9
|
+
APP_HTML = "text/html;profile=mcp-app" # MCP Apps (ext-apps, SEP-1865)
|
|
10
|
+
|
|
11
|
+
TYPES = {
|
|
12
|
+
mcp_app: APP_HTML
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# Resolve a user-supplied MIME value to a wire string.
|
|
16
|
+
#
|
|
17
|
+
# @param value [Symbol, String, Mime::Type]
|
|
18
|
+
# @return [String]
|
|
19
|
+
def self.resolve(value)
|
|
20
|
+
case value
|
|
21
|
+
when Symbol
|
|
22
|
+
TYPES[value] || Mime[value]&.to_s || raise(KeyError, "unknown MIME type: #{value.inspect}")
|
|
23
|
+
when Mime::Type
|
|
24
|
+
value.to_s
|
|
25
|
+
else
|
|
26
|
+
value.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -20,7 +20,7 @@ module ActionMCP
|
|
|
20
20
|
|
|
21
21
|
class << self
|
|
22
22
|
attr_reader :registered_templates, :description, :uri_template,
|
|
23
|
-
:mime_type, :template_name, :parameters, :_meta
|
|
23
|
+
:mime_type, :template_name, :parameters, :_meta, :ui_meta
|
|
24
24
|
|
|
25
25
|
def abstract?
|
|
26
26
|
@abstract ||= false
|
|
@@ -94,7 +94,9 @@ module ActionMCP
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def mime_type(value = nil)
|
|
97
|
-
|
|
97
|
+
return @mime_type unless value
|
|
98
|
+
|
|
99
|
+
@mime_type = ActionMCP::MimeTypes.resolve(value)
|
|
98
100
|
end
|
|
99
101
|
|
|
100
102
|
# Sets or retrieves the _meta field
|
|
@@ -109,6 +111,38 @@ module ActionMCP
|
|
|
109
111
|
end
|
|
110
112
|
end
|
|
111
113
|
|
|
114
|
+
# Declares MCP Apps UI metadata for this resource template. Stored verbatim
|
|
115
|
+
# (camelCase keys per the ext-apps spec). Used both for the `resources/list`
|
|
116
|
+
# entry's `_meta.ui` and the default content `_meta.ui` produced by
|
|
117
|
+
# `render_ui`.
|
|
118
|
+
#
|
|
119
|
+
# @example
|
|
120
|
+
# ui csp: { connectDomains: %w[api.openweathermap.org] }, prefersBorder: true
|
|
121
|
+
def ui(**data)
|
|
122
|
+
raise ArgumentError, "ui metadata must not be empty" if data.empty?
|
|
123
|
+
|
|
124
|
+
validate_ui_csp_origins!(data[:csp])
|
|
125
|
+
@ui_meta ||= {}
|
|
126
|
+
@ui_meta = @ui_meta.deep_merge(data)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def validate_ui_csp_origins!(csp)
|
|
132
|
+
return unless csp.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
Apps::CSP_KEYS.each do |key|
|
|
135
|
+
Array(csp[key]).each do |origin|
|
|
136
|
+
next if origin.is_a?(String) && Apps::ORIGIN_PATTERN.match?(origin)
|
|
137
|
+
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"ui csp #{key} entries must be http(s):// origins, got: #{origin.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
public
|
|
145
|
+
|
|
112
146
|
def to_h
|
|
113
147
|
name_value = defined?(@template_name) ? @template_name : name.demodulize.underscore.gsub(/_template$/, "")
|
|
114
148
|
|
|
@@ -298,6 +332,31 @@ module ActionMCP
|
|
|
298
332
|
end
|
|
299
333
|
end
|
|
300
334
|
|
|
335
|
+
# Build a `Content::Resource` for an MCP Apps UI view. Accepts either a raw
|
|
336
|
+
# `:text` string or a Rails `:template` path; the class-level `ui` macro
|
|
337
|
+
# supplies the content-level `_meta.ui` automatically.
|
|
338
|
+
#
|
|
339
|
+
# @example
|
|
340
|
+
# render_ui(template: "mcp/ui/weather_dashboard")
|
|
341
|
+
def render_ui(text: nil, template: nil, layout: false, locals: {})
|
|
342
|
+
resolved =
|
|
343
|
+
if text
|
|
344
|
+
text
|
|
345
|
+
elsif template
|
|
346
|
+
ApplicationController.render(template: template, layout: layout, locals: locals)
|
|
347
|
+
else
|
|
348
|
+
raise ArgumentError, "render_ui requires :text or :template"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
ui = self.class.ui_meta
|
|
352
|
+
ActionMCP::Content::Resource.new(
|
|
353
|
+
self.class.uri_template,
|
|
354
|
+
self.class.mime_type || ActionMCP::Apps::MIME_TYPE,
|
|
355
|
+
text: resolved,
|
|
356
|
+
meta: (ui&.any? ? { ui: ui } : nil)
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
301
360
|
# Initialize with attribute values
|
|
302
361
|
def initialize(attributes = {})
|
|
303
362
|
super(attributes)
|
|
@@ -118,7 +118,7 @@ module ActionMCP
|
|
|
118
118
|
payload = {
|
|
119
119
|
protocolVersion: ActionMCP::LATEST_VERSION,
|
|
120
120
|
serverInfo: server_info,
|
|
121
|
-
capabilities: server_capabilities
|
|
121
|
+
capabilities: capabilities_for_protocol(server_capabilities)
|
|
122
122
|
}
|
|
123
123
|
# Add instructions at top level if configured
|
|
124
124
|
instructions = ActionMCP.configuration.instructions
|
|
@@ -280,6 +280,22 @@ module ActionMCP
|
|
|
280
280
|
|
|
281
281
|
private
|
|
282
282
|
|
|
283
|
+
def capabilities_for_protocol(capabilities)
|
|
284
|
+
filtered =
|
|
285
|
+
if capabilities.respond_to?(:deep_dup)
|
|
286
|
+
capabilities.deep_dup
|
|
287
|
+
elsif capabilities
|
|
288
|
+
capabilities.dup
|
|
289
|
+
else
|
|
290
|
+
{}
|
|
291
|
+
end
|
|
292
|
+
return filtered if protocol_version == "2025-11-25"
|
|
293
|
+
|
|
294
|
+
filtered.delete("tasks")
|
|
295
|
+
filtered.delete(:tasks)
|
|
296
|
+
filtered
|
|
297
|
+
end
|
|
298
|
+
|
|
283
299
|
def normalize_name(class_or_name, type)
|
|
284
300
|
case class_or_name
|
|
285
301
|
when String
|
|
@@ -12,6 +12,11 @@ module ActionMCP
|
|
|
12
12
|
params ||= {}
|
|
13
13
|
|
|
14
14
|
with_error_handling(id) do
|
|
15
|
+
unless transport.session.protocol_version == "2025-11-25"
|
|
16
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
17
|
+
message: "Tasks are only available in MCP 2025-11-25")
|
|
18
|
+
end
|
|
19
|
+
|
|
15
20
|
handler = task_method_handlers[rpc_method]
|
|
16
21
|
if handler
|
|
17
22
|
send(handler, id, params)
|
|
@@ -29,8 +34,7 @@ module ActionMCP
|
|
|
29
34
|
JsonRpcHandlerBase::Methods::TASKS_GET => :handle_tasks_get,
|
|
30
35
|
JsonRpcHandlerBase::Methods::TASKS_RESULT => :handle_tasks_result,
|
|
31
36
|
JsonRpcHandlerBase::Methods::TASKS_LIST => :handle_tasks_list,
|
|
32
|
-
JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel
|
|
33
|
-
JsonRpcHandlerBase::Methods::TASKS_RESUME => :handle_tasks_resume
|
|
37
|
+
JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel
|
|
34
38
|
}
|
|
35
39
|
end
|
|
36
40
|
|
|
@@ -63,15 +67,6 @@ module ActionMCP
|
|
|
63
67
|
transport.send_tasks_cancel(id, task_id)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
def handle_tasks_resume(id, params)
|
|
67
|
-
task_id = validate_required_param(params, "taskId", "Task ID is required")
|
|
68
|
-
input = params["input"]
|
|
69
|
-
task = find_task_or_error(id, task_id)
|
|
70
|
-
return unless task
|
|
71
|
-
|
|
72
|
-
transport.send_tasks_resume(id, task_id, input)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
70
|
def find_task_or_error(id, task_id)
|
|
76
71
|
task = transport.session.tasks.find_by(id: task_id)
|
|
77
72
|
unless task
|
|
@@ -37,7 +37,8 @@ module ActionMCP
|
|
|
37
37
|
name = validate_required_param(params, "name", "Tool name is required")
|
|
38
38
|
arguments = extract_arguments(params)
|
|
39
39
|
_meta = params["_meta"] || params[:_meta] || {}
|
|
40
|
-
|
|
40
|
+
task_params = params.key?("task") ? params["task"] : params[:task]
|
|
41
|
+
transport.send_tools_call(id, name, arguments, _meta, task_params)
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def extract_arguments(params)
|
|
@@ -5,7 +5,7 @@ module ActionMCP
|
|
|
5
5
|
# Tasks module for MCP 2025-11-25 specification
|
|
6
6
|
# Provides methods for handling task-related requests:
|
|
7
7
|
# - tasks/get: Get task status and data
|
|
8
|
-
# - tasks/result: Get task result (blocking until terminal state)
|
|
8
|
+
# - tasks/result: Get task result (blocking until terminal or input_required state)
|
|
9
9
|
# - tasks/list: List tasks for the session
|
|
10
10
|
# - tasks/cancel: Cancel a task
|
|
11
11
|
module Tasks
|
|
@@ -16,25 +16,47 @@ module ActionMCP
|
|
|
16
16
|
task = find_task(task_id)
|
|
17
17
|
return unless task
|
|
18
18
|
|
|
19
|
-
send_jsonrpc_response(request_id, result:
|
|
19
|
+
send_jsonrpc_response(request_id, result: task.to_task_data)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# Get task result, blocking until task reaches terminal state
|
|
22
|
+
# Get task result, blocking until task reaches terminal or input_required state
|
|
23
23
|
# @param request_id [String, Integer] JSON-RPC request ID
|
|
24
24
|
# @param task_id [String] Task ID to get result for
|
|
25
25
|
def send_tasks_result(request_id, task_id)
|
|
26
26
|
task = find_task(task_id)
|
|
27
27
|
return unless task
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
unless task.result_ready?
|
|
30
|
+
case ActionMCP.configuration.tasks_result_strategy
|
|
31
|
+
when :polling_only
|
|
32
|
+
send_jsonrpc_error(
|
|
33
|
+
request_id,
|
|
34
|
+
:invalid_request,
|
|
35
|
+
"Task is not ready. Poll tasks/get over HTTP until the task reaches a terminal or input_required status, then retry tasks/result."
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
else
|
|
39
|
+
task = wait_for_result_ready_task(task_id)
|
|
40
|
+
unless task&.result_ready?
|
|
41
|
+
send_jsonrpc_response(
|
|
42
|
+
request_id,
|
|
43
|
+
error: {
|
|
44
|
+
code: -32_000,
|
|
45
|
+
message: "Timed out waiting for task '#{task_id}' to reach a terminal or input_required status"
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
end
|
|
35
51
|
end
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
if (error = task.to_task_error)
|
|
54
|
+
send_jsonrpc_response(request_id, error: error)
|
|
55
|
+
else
|
|
56
|
+
send_jsonrpc_response(request_id, result: task.to_task_result)
|
|
57
|
+
end
|
|
58
|
+
rescue ActiveRecord::RecordNotFound
|
|
59
|
+
send_jsonrpc_error(request_id, :invalid_params, "Task '#{task_id}' not found")
|
|
38
60
|
end
|
|
39
61
|
|
|
40
62
|
# List tasks for the session with keyset pagination.
|
|
@@ -67,33 +89,14 @@ module ActionMCP
|
|
|
67
89
|
return
|
|
68
90
|
end
|
|
69
91
|
|
|
92
|
+
task.status_message = "The task was cancelled by request." if task.status_message.blank?
|
|
93
|
+
task.result_payload ||= {
|
|
94
|
+
code: -32_000,
|
|
95
|
+
message: "Task was cancelled"
|
|
96
|
+
}
|
|
97
|
+
task.save! if task.changed?
|
|
70
98
|
task.cancel!
|
|
71
|
-
send_jsonrpc_response(request_id, result:
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Resume a task from input_required state
|
|
75
|
-
# @param request_id [String, Integer] JSON-RPC request ID
|
|
76
|
-
# @param task_id [String] Task ID to resume
|
|
77
|
-
# @param input [Object] Input data for the task
|
|
78
|
-
def send_tasks_resume(request_id, task_id, input)
|
|
79
|
-
task = find_task(task_id)
|
|
80
|
-
return unless task
|
|
81
|
-
|
|
82
|
-
unless task.input_required?
|
|
83
|
-
send_jsonrpc_error(request_id, :invalid_params,
|
|
84
|
-
"Task is not awaiting input. Current status: #{task.status}")
|
|
85
|
-
return
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Store input in continuation state
|
|
89
|
-
continuation = task.continuation_state || {}
|
|
90
|
-
continuation[:input] = input
|
|
91
|
-
task.update!(continuation_state: continuation)
|
|
92
|
-
|
|
93
|
-
# Resume task and re-enqueue job
|
|
94
|
-
task.resume_from_continuation!
|
|
95
|
-
|
|
96
|
-
send_jsonrpc_response(request_id, result: { task: task.to_task_data })
|
|
99
|
+
send_jsonrpc_response(request_id, result: task.to_task_data)
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
# Send task status notification
|
|
@@ -101,7 +104,7 @@ module ActionMCP
|
|
|
101
104
|
def send_task_status_notification(task)
|
|
102
105
|
send_jsonrpc_notification(
|
|
103
106
|
JsonRpcHandlerBase::Methods::NOTIFICATIONS_TASKS_STATUS,
|
|
104
|
-
|
|
107
|
+
task.to_task_data
|
|
105
108
|
)
|
|
106
109
|
end
|
|
107
110
|
|
|
@@ -140,6 +143,30 @@ module ActionMCP
|
|
|
140
143
|
|
|
141
144
|
task
|
|
142
145
|
end
|
|
146
|
+
|
|
147
|
+
def wait_for_result_ready_task(task_id)
|
|
148
|
+
deadline = monotonic_time + ActionMCP.configuration.tasks_result_timeout.to_f
|
|
149
|
+
|
|
150
|
+
loop do
|
|
151
|
+
task = load_task_for_result(task_id)
|
|
152
|
+
return task if task.nil? || task.result_ready?
|
|
153
|
+
|
|
154
|
+
remaining = deadline - monotonic_time
|
|
155
|
+
return task unless remaining.positive?
|
|
156
|
+
|
|
157
|
+
sleep [ ActionMCP.configuration.tasks_result_poll_interval.to_f, remaining ].min
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def load_task_for_result(task_id)
|
|
162
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
163
|
+
session.tasks.find_by(id: task_id)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def monotonic_time
|
|
168
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
169
|
+
end
|
|
143
170
|
end
|
|
144
171
|
end
|
|
145
172
|
end
|
|
@@ -25,7 +25,7 @@ module ActionMCP
|
|
|
25
25
|
send_jsonrpc_error(request_id, :invalid_params, e.message)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def send_tools_call(request_id, tool_name, arguments, _meta = {})
|
|
28
|
+
def send_tools_call(request_id, tool_name, arguments, _meta = {}, task_params = nil)
|
|
29
29
|
# Find tool in session's registry
|
|
30
30
|
tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
|
|
31
31
|
|
|
@@ -47,13 +47,39 @@ module ActionMCP
|
|
|
47
47
|
return
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
task_support = tool_task_support(tool_class)
|
|
51
|
+
task_requested = !task_params.nil?
|
|
52
|
+
|
|
53
|
+
if task_requested && !tasks_enabled?
|
|
54
|
+
send_jsonrpc_error(request_id, :method_not_found,
|
|
55
|
+
"Task-augmented execution is not available for this session")
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if task_requested
|
|
60
|
+
unless task_params.respond_to?(:to_h)
|
|
61
|
+
send_jsonrpc_error(request_id, :invalid_params, "Task parameters must be an object")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
task_params = task_params.to_h
|
|
66
|
+
|
|
67
|
+
if task_support == :forbidden
|
|
68
|
+
send_jsonrpc_error(request_id, :method_not_found,
|
|
69
|
+
"Tool '#{tool_name}' does not support task-augmented execution")
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
53
73
|
handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
|
|
54
74
|
return
|
|
55
75
|
end
|
|
56
76
|
|
|
77
|
+
if !task_requested && task_support == :required
|
|
78
|
+
send_jsonrpc_error(request_id, :method_not_found,
|
|
79
|
+
"Tool '#{tool_name}' requires task-augmented execution")
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
57
83
|
# Standard synchronous execution
|
|
58
84
|
execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
|
|
59
85
|
end
|
|
@@ -114,7 +140,6 @@ module ActionMCP
|
|
|
114
140
|
def handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
|
|
115
141
|
# Extract task configuration
|
|
116
142
|
ttl = task_params["ttl"] || task_params[:ttl] || 60_000
|
|
117
|
-
poll_interval = task_params["pollInterval"] || task_params[:pollInterval] || 5_000
|
|
118
143
|
|
|
119
144
|
# Create task record
|
|
120
145
|
task = session.tasks.create!(
|
|
@@ -123,24 +148,39 @@ module ActionMCP
|
|
|
123
148
|
request_params: {
|
|
124
149
|
name: tool_name,
|
|
125
150
|
arguments: arguments,
|
|
151
|
+
task: task_params,
|
|
126
152
|
_meta: _meta
|
|
127
153
|
},
|
|
128
|
-
ttl: ttl
|
|
129
|
-
|
|
154
|
+
ttl: ttl
|
|
155
|
+
)
|
|
156
|
+
request_meta = task.request_meta_with_related_task(_meta)
|
|
157
|
+
task.update!(
|
|
158
|
+
request_params: {
|
|
159
|
+
name: tool_name,
|
|
160
|
+
arguments: arguments,
|
|
161
|
+
task: task_params,
|
|
162
|
+
_meta: request_meta
|
|
163
|
+
}
|
|
130
164
|
)
|
|
131
165
|
|
|
132
166
|
# Return CreateTaskResult immediately
|
|
133
|
-
send_jsonrpc_response(request_id, result:
|
|
167
|
+
send_jsonrpc_response(request_id, result: task.to_create_task_result)
|
|
134
168
|
|
|
135
169
|
# Execute tool asynchronously via ActiveJob
|
|
136
|
-
ToolExecutionJob.perform_later(task.id, tool_name, arguments,
|
|
170
|
+
ToolExecutionJob.perform_later(task.id, tool_name, arguments, request_meta)
|
|
137
171
|
rescue StandardError => e
|
|
138
172
|
Rails.logger.error "Failed to create task: #{e.class} - #{e.message}"
|
|
139
173
|
send_jsonrpc_error(request_id, :internal_error, "Failed to create task")
|
|
140
174
|
end
|
|
141
175
|
|
|
142
176
|
def tasks_enabled?
|
|
143
|
-
ActionMCP.configuration.tasks_enabled
|
|
177
|
+
ActionMCP.configuration.tasks_enabled && session.protocol_version == "2025-11-25"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def tool_task_support(tool_class)
|
|
181
|
+
return :forbidden unless tool_class.respond_to?(:task_support)
|
|
182
|
+
|
|
183
|
+
(tool_class.task_support || :forbidden).to_sym
|
|
144
184
|
end
|
|
145
185
|
|
|
146
186
|
def format_registry_items(registry, protocol_version = nil)
|
data/lib/action_mcp/tool.rb
CHANGED
|
@@ -187,6 +187,31 @@ module ActionMCP
|
|
|
187
187
|
end
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
+
# Declares the UI resource this tool renders. Merges a `ui:` entry into
|
|
191
|
+
# `_meta` so the tool listing advertises the dashboard.
|
|
192
|
+
#
|
|
193
|
+
# @param resource_uri [String] a `ui://` URI
|
|
194
|
+
# @param visibility [Array<Symbol, String>, nil] subset of `[:model, :app]`
|
|
195
|
+
def renders_ui(resource_uri, visibility: nil)
|
|
196
|
+
unless resource_uri.is_a?(String) && Apps::URI_SCHEME.match?(resource_uri)
|
|
197
|
+
raise ArgumentError, "renders_ui requires a ui:// URI, got: #{resource_uri.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
normalized_visibility =
|
|
201
|
+
if visibility
|
|
202
|
+
normalized = Array(visibility).map(&:to_s)
|
|
203
|
+
invalid = normalized - Apps::VISIBILITY_VALUES
|
|
204
|
+
if invalid.any?
|
|
205
|
+
raise ArgumentError,
|
|
206
|
+
"renders_ui visibility must be #{Apps::VISIBILITY_VALUES.join('/')}, got: #{visibility.inspect}"
|
|
207
|
+
end
|
|
208
|
+
normalized
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
ui_meta = { resourceUri: resource_uri, visibility: normalized_visibility }.compact
|
|
212
|
+
self._meta = _meta.deep_merge(ui: ui_meta)
|
|
213
|
+
end
|
|
214
|
+
|
|
190
215
|
# Marks this tool as requiring consent before execution
|
|
191
216
|
def requires_consent!
|
|
192
217
|
self._requires_consent = true
|
|
@@ -397,7 +422,7 @@ module ActionMCP
|
|
|
397
422
|
|
|
398
423
|
# Add execution metadata (MCP 2025-11-25)
|
|
399
424
|
# Only include if not default (forbidden) to minimize payload
|
|
400
|
-
if _task_support && _task_support != :forbidden
|
|
425
|
+
if _task_support && _task_support != :forbidden && task_metadata_supported?(protocol_version)
|
|
401
426
|
result[:execution] = execution_metadata
|
|
402
427
|
end
|
|
403
428
|
|
|
@@ -407,6 +432,11 @@ module ActionMCP
|
|
|
407
432
|
result
|
|
408
433
|
end
|
|
409
434
|
|
|
435
|
+
def self.task_metadata_supported?(protocol_version)
|
|
436
|
+
protocol_version.nil? || protocol_version == "2025-11-25"
|
|
437
|
+
end
|
|
438
|
+
private_class_method :task_metadata_supported?
|
|
439
|
+
|
|
410
440
|
# --------------------------------------------------------------------------
|
|
411
441
|
# Instance Methods
|
|
412
442
|
# --------------------------------------------------------------------------
|
data/lib/action_mcp/version.rb
CHANGED
data/lib/action_mcp.rb
CHANGED
|
@@ -41,6 +41,8 @@ module ActionMCP
|
|
|
41
41
|
|
|
42
42
|
LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
|
|
43
43
|
DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
|
|
44
|
+
|
|
45
|
+
MIME_TYPE_APP_HTML = Apps::MIME_TYPE # MCP Apps UI resources (ext-apps, draft 2026-01-26)
|
|
44
46
|
class << self
|
|
45
47
|
# Returns a Rack-compatible application for serving MCP requests
|
|
46
48
|
# @return [#call] A Rack application that can be used with `run ActionMCP.server`
|
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.110.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -160,9 +160,9 @@ files:
|
|
|
160
160
|
- app/models/concerns/action_mcp/mcp_message_inspect.rb
|
|
161
161
|
- config/routes.rb
|
|
162
162
|
- db/migrate/20250512154359_consolidated_migration.rb
|
|
163
|
-
- db/test.sqlite3
|
|
164
163
|
- exe/actionmcp_cli
|
|
165
164
|
- lib/action_mcp.rb
|
|
165
|
+
- lib/action_mcp/apps.rb
|
|
166
166
|
- lib/action_mcp/base_response.rb
|
|
167
167
|
- lib/action_mcp/callbacks.rb
|
|
168
168
|
- lib/action_mcp/capability.rb
|
|
@@ -199,6 +199,8 @@ files:
|
|
|
199
199
|
- lib/action_mcp/logging/mixin.rb
|
|
200
200
|
- lib/action_mcp/logging/null_logger.rb
|
|
201
201
|
- lib/action_mcp/logging/state.rb
|
|
202
|
+
- lib/action_mcp/middleware/origin_validation.rb
|
|
203
|
+
- lib/action_mcp/mime_types.rb
|
|
202
204
|
- lib/action_mcp/output_schema_builder.rb
|
|
203
205
|
- lib/action_mcp/prompt.rb
|
|
204
206
|
- lib/action_mcp/prompt_response.rb
|
data/db/test.sqlite3
DELETED
|
File without changes
|