actionmcp 0.111.1 → 0.200.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 +26 -18
- data/Rakefile +3 -0
- data/app/controllers/action_mcp/application_controller.rb +166 -109
- data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
- data/app/helpers/action_mcp/apps_helper.rb +48 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
- data/app/models/action_mcp/session/message.rb +8 -3
- data/app/models/action_mcp/session/task.rb +15 -5
- data/app/models/action_mcp/session.rb +93 -19
- data/config/routes.rb +1 -1
- data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
- data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
- data/lib/action_mcp/apps/view_manifest.rb +170 -0
- data/lib/action_mcp/apps.rb +17 -2
- data/lib/action_mcp/configuration.rb +14 -30
- data/lib/action_mcp/content/audio.rb +4 -1
- data/lib/action_mcp/content/base.rb +3 -1
- data/lib/action_mcp/content/image.rb +4 -1
- data/lib/action_mcp/content/resource.rb +15 -14
- data/lib/action_mcp/content/resource_link.rb +15 -5
- data/lib/action_mcp/content/text.rb +2 -1
- data/lib/action_mcp/content/validation.rb +253 -0
- data/lib/action_mcp/content.rb +3 -1
- data/lib/action_mcp/dev/runner.rb +203 -0
- data/lib/action_mcp/engine.rb +24 -2
- data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
- data/lib/action_mcp/logging/logger.rb +16 -11
- data/lib/action_mcp/logging.rb +34 -1
- data/lib/action_mcp/middleware/origin_validation.rb +9 -13
- data/lib/action_mcp/output_schema_builder.rb +2 -0
- data/lib/action_mcp/prompt_response.rb +12 -2
- data/lib/action_mcp/protocol_validator.rb +513 -0
- data/lib/action_mcp/renderable.rb +4 -4
- data/lib/action_mcp/resource.rb +15 -11
- data/lib/action_mcp/resource_template.rb +105 -90
- data/lib/action_mcp/resource_templates_registry.rb +31 -97
- data/lib/action_mcp/schema_validator.rb +43 -0
- data/lib/action_mcp/server/base_session.rb +134 -28
- data/lib/action_mcp/server/base_session_store.rb +1 -1
- data/lib/action_mcp/server/capabilities.rb +10 -30
- data/lib/action_mcp/server/client_notifications.rb +63 -0
- data/lib/action_mcp/server/completions.rb +68 -0
- data/lib/action_mcp/server/elicitation.rb +47 -25
- data/lib/action_mcp/server/elicitation_request.rb +136 -51
- data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
- data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
- data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
- data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
- data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
- data/lib/action_mcp/server/messaging_service.rb +3 -1
- data/lib/action_mcp/server/prompts.rb +5 -3
- data/lib/action_mcp/server/resources.rb +35 -2
- data/lib/action_mcp/server/response_collector.rb +5 -1
- data/lib/action_mcp/server/roots.rb +15 -2
- data/lib/action_mcp/server/sampling.rb +36 -1
- data/lib/action_mcp/server/sampling_request.rb +317 -55
- data/lib/action_mcp/server/tasks.rb +27 -45
- data/lib/action_mcp/server/tool_result.rb +27 -0
- data/lib/action_mcp/server/tools.rb +12 -10
- data/lib/action_mcp/server/transport_handler.rb +3 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
- data/lib/action_mcp/tool.rb +133 -199
- data/lib/action_mcp/tool_response.rb +35 -4
- data/lib/action_mcp/tools_registry.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -5
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
- data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
- data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
- data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
- data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
- data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
- data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
- data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
- data/lib/generators/action_mcp/view/view_generator.rb +57 -0
- data/lib/tasks/action_mcp_apps.rake +17 -0
- data/lib/tasks/action_mcp_tasks.rake +135 -74
- metadata +47 -17
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
3
5
|
module ActionMCP
|
|
4
6
|
module Server
|
|
5
7
|
# Value object for form-mode elicitation requests.
|
|
@@ -9,14 +11,135 @@ module ActionMCP
|
|
|
9
11
|
include ActiveModel::Model
|
|
10
12
|
include ActiveModel::Attributes
|
|
11
13
|
|
|
14
|
+
STRING_SCHEMA = {
|
|
15
|
+
"type" => "object",
|
|
16
|
+
"required" => [ "type" ],
|
|
17
|
+
"properties" => {
|
|
18
|
+
"type" => { "const" => "string" },
|
|
19
|
+
"title" => { "type" => "string" },
|
|
20
|
+
"description" => { "type" => "string" },
|
|
21
|
+
"default" => { "type" => "string" },
|
|
22
|
+
"format" => { "enum" => %w[date date-time email uri] },
|
|
23
|
+
"minLength" => { "type" => "integer" },
|
|
24
|
+
"maxLength" => { "type" => "integer" }
|
|
25
|
+
}
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
NUMBER_SCHEMA = {
|
|
29
|
+
"type" => "object",
|
|
30
|
+
"required" => [ "type" ],
|
|
31
|
+
"properties" => {
|
|
32
|
+
"type" => { "enum" => %w[integer number] },
|
|
33
|
+
"title" => { "type" => "string" },
|
|
34
|
+
"description" => { "type" => "string" },
|
|
35
|
+
"default" => { "type" => "integer" },
|
|
36
|
+
"minimum" => { "type" => "integer" },
|
|
37
|
+
"maximum" => { "type" => "integer" }
|
|
38
|
+
}
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
BOOLEAN_SCHEMA = {
|
|
42
|
+
"type" => "object",
|
|
43
|
+
"required" => [ "type" ],
|
|
44
|
+
"properties" => {
|
|
45
|
+
"type" => { "const" => "boolean" },
|
|
46
|
+
"title" => { "type" => "string" },
|
|
47
|
+
"description" => { "type" => "string" },
|
|
48
|
+
"default" => { "type" => "boolean" }
|
|
49
|
+
}
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
ENUM_OPTION_SCHEMA = {
|
|
53
|
+
"type" => "object",
|
|
54
|
+
"required" => %w[const title],
|
|
55
|
+
"properties" => {
|
|
56
|
+
"const" => { "type" => "string" },
|
|
57
|
+
"title" => { "type" => "string" }
|
|
58
|
+
}
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
UNTITLED_MULTI_SELECT_SCHEMA = {
|
|
62
|
+
"type" => "object",
|
|
63
|
+
"required" => %w[items type],
|
|
64
|
+
"properties" => {
|
|
65
|
+
"type" => { "const" => "array" },
|
|
66
|
+
"title" => { "type" => "string" },
|
|
67
|
+
"description" => { "type" => "string" },
|
|
68
|
+
"default" => { "type" => "array", "items" => { "type" => "string" } },
|
|
69
|
+
"minItems" => { "type" => "integer" },
|
|
70
|
+
"maxItems" => { "type" => "integer" },
|
|
71
|
+
"items" => {
|
|
72
|
+
"type" => "object",
|
|
73
|
+
"required" => %w[enum type],
|
|
74
|
+
"properties" => {
|
|
75
|
+
"type" => { "const" => "string" },
|
|
76
|
+
"enum" => { "type" => "array", "items" => { "type" => "string" } }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
TITLED_MULTI_SELECT_SCHEMA = {
|
|
83
|
+
"type" => "object",
|
|
84
|
+
"required" => %w[items type],
|
|
85
|
+
"properties" => {
|
|
86
|
+
"type" => { "const" => "array" },
|
|
87
|
+
"title" => { "type" => "string" },
|
|
88
|
+
"description" => { "type" => "string" },
|
|
89
|
+
"default" => { "type" => "array", "items" => { "type" => "string" } },
|
|
90
|
+
"minItems" => { "type" => "integer" },
|
|
91
|
+
"maxItems" => { "type" => "integer" },
|
|
92
|
+
"items" => {
|
|
93
|
+
"type" => "object",
|
|
94
|
+
"required" => [ "anyOf" ],
|
|
95
|
+
"properties" => {
|
|
96
|
+
"anyOf" => { "type" => "array", "items" => ENUM_OPTION_SCHEMA }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}.freeze
|
|
101
|
+
|
|
102
|
+
REQUESTED_SCHEMA = {
|
|
103
|
+
"type" => "object",
|
|
104
|
+
"required" => %w[properties type],
|
|
105
|
+
"properties" => {
|
|
106
|
+
"$schema" => { "type" => "string" },
|
|
107
|
+
"type" => { "const" => "object" },
|
|
108
|
+
"required" => { "type" => "array", "items" => { "type" => "string" } },
|
|
109
|
+
"properties" => {
|
|
110
|
+
"type" => "object",
|
|
111
|
+
"additionalProperties" => {
|
|
112
|
+
"anyOf" => [
|
|
113
|
+
STRING_SCHEMA,
|
|
114
|
+
NUMBER_SCHEMA,
|
|
115
|
+
BOOLEAN_SCHEMA,
|
|
116
|
+
UNTITLED_MULTI_SELECT_SCHEMA,
|
|
117
|
+
TITLED_MULTI_SELECT_SCHEMA
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}.freeze
|
|
123
|
+
REQUESTED_SCHEMA_SCHEMER = JSONSchemer.schema(REQUESTED_SCHEMA)
|
|
124
|
+
TASK_SCHEMA = {
|
|
125
|
+
"type" => "object",
|
|
126
|
+
"additionalProperties" => false,
|
|
127
|
+
"properties" => {
|
|
128
|
+
"ttl" => { "type" => "integer" }
|
|
129
|
+
}
|
|
130
|
+
}.freeze
|
|
131
|
+
TASK_SCHEMER = JSONSchemer.schema(TASK_SCHEMA)
|
|
132
|
+
|
|
12
133
|
attribute :message, :string
|
|
13
134
|
attribute :requested_schema # Hash
|
|
14
135
|
attribute :_meta # Hash, optional
|
|
136
|
+
attribute :task # TaskMetadata, optional
|
|
15
137
|
|
|
16
138
|
validates :message, presence: true
|
|
17
139
|
validates :requested_schema, presence: true
|
|
18
|
-
validate :
|
|
19
|
-
validate :
|
|
140
|
+
validate :requested_schema_must_match_protocol, if: -> { requested_schema.present? }
|
|
141
|
+
validate :meta_must_be_object, if: -> { _meta.present? }
|
|
142
|
+
validate :task_must_match_protocol, unless: -> { task.nil? }
|
|
20
143
|
|
|
21
144
|
# Wrap incoming schema in indifferent access so both string and symbol keys work.
|
|
22
145
|
def requested_schema=(value)
|
|
@@ -27,6 +150,7 @@ module ActionMCP
|
|
|
27
150
|
def to_params
|
|
28
151
|
params = { mode: "form", message: message, requestedSchema: requested_schema.to_hash.deep_symbolize_keys }
|
|
29
152
|
params[:_meta] = _meta if _meta.present?
|
|
153
|
+
params[:task] = task unless task.nil?
|
|
30
154
|
params
|
|
31
155
|
end
|
|
32
156
|
|
|
@@ -40,60 +164,21 @@ module ActionMCP
|
|
|
40
164
|
|
|
41
165
|
private
|
|
42
166
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
167
|
+
def requested_schema_must_match_protocol
|
|
168
|
+
validation_errors = REQUESTED_SCHEMA_SCHEMER.validate(requested_schema.to_h.deep_stringify_keys).to_a
|
|
169
|
+
return if validation_errors.empty?
|
|
61
170
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
171
|
+
pointers = validation_errors.map { |error| error["data_pointer"].presence || "/" }.uniq
|
|
172
|
+
errors.add(:requested_schema, "must match MCP 2025-11-25 at #{pointers.join(', ')}")
|
|
79
173
|
end
|
|
80
174
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
errors.add(:requested_schema, "property '#{key}' enum must be an array")
|
|
84
|
-
end
|
|
175
|
+
def meta_must_be_object
|
|
176
|
+
errors.add(:_meta, "must be an object") unless _meta.is_a?(Hash)
|
|
85
177
|
end
|
|
86
178
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
179
|
+
def task_must_match_protocol
|
|
180
|
+
errors.add(:task, "must match MCP 2025-11-25 TaskMetadata") unless
|
|
181
|
+
task.is_a?(Hash) && TASK_SCHEMER.valid?(task.deep_stringify_keys)
|
|
97
182
|
end
|
|
98
183
|
end
|
|
99
184
|
end
|
|
@@ -10,13 +10,16 @@ module ActionMCP
|
|
|
10
10
|
# @param params [Hash] Request parameters containing level
|
|
11
11
|
# @return [Hash] Empty hash on success
|
|
12
12
|
def handle_logging_set_level(id, params)
|
|
13
|
-
|
|
14
|
-
unless ActionMCP.configuration.logging_enabled
|
|
13
|
+
unless ActionMCP::Logging.supported_by?(transport.session)
|
|
15
14
|
transport.send_jsonrpc_error(id, :method_not_found, "Logging not enabled")
|
|
16
15
|
return
|
|
17
16
|
end
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
unless params.is_a?(Hash)
|
|
19
|
+
transport.send_jsonrpc_error(id, :invalid_params, "Logging params must be an object")
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
20
23
|
level = params[:level] || params["level"]
|
|
21
24
|
unless level
|
|
22
25
|
transport.send_jsonrpc_error(id, :invalid_params, "Missing required parameter: level")
|
|
@@ -24,8 +27,7 @@ module ActionMCP
|
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
begin
|
|
27
|
-
|
|
28
|
-
ActionMCP::Logging.set_level(level)
|
|
30
|
+
ActionMCP::Logging.set_level_for(transport.session, level)
|
|
29
31
|
|
|
30
32
|
# Send successful response (empty object per MCP spec)
|
|
31
33
|
transport.send_jsonrpc_response(id, result: {})
|
|
@@ -10,6 +10,10 @@ module ActionMCP
|
|
|
10
10
|
params ||= {}
|
|
11
11
|
|
|
12
12
|
with_error_handling(id) do
|
|
13
|
+
unless params.is_a?(Hash)
|
|
14
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt params must be an object")
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
handler = prompt_method_handlers[rpc_method]
|
|
14
18
|
if handler
|
|
15
19
|
send(handler, id, params)
|
|
@@ -30,8 +34,15 @@ module ActionMCP
|
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def handle_prompts_get(id, params)
|
|
33
|
-
name =
|
|
37
|
+
name = validate_required_param(params, "name", "Prompt name is required")
|
|
34
38
|
arguments = extract_arguments(params)
|
|
39
|
+
unless arguments.is_a?(Hash)
|
|
40
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt arguments must be an object")
|
|
41
|
+
end
|
|
42
|
+
unless arguments.values.all? { |value| value.is_a?(String) }
|
|
43
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Prompt argument values must be strings")
|
|
44
|
+
end
|
|
45
|
+
|
|
35
46
|
transport.send_prompts_get(id, name, arguments)
|
|
36
47
|
end
|
|
37
48
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module ActionMCP
|
|
4
6
|
module Server
|
|
5
7
|
module Handlers
|
|
@@ -10,6 +12,21 @@ module ActionMCP
|
|
|
10
12
|
params ||= {}
|
|
11
13
|
|
|
12
14
|
with_error_handling(id) do
|
|
15
|
+
unless params.is_a?(Hash)
|
|
16
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource params must be an object")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
capabilities = negotiated_resource_capabilities
|
|
20
|
+
unless capabilities
|
|
21
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
22
|
+
message: "Resources are not available for this session")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if subscription_method?(rpc_method) && capabilities[:subscribe] != true
|
|
26
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
27
|
+
message: "Resource subscriptions are not available for this session")
|
|
28
|
+
end
|
|
29
|
+
|
|
13
30
|
handler = resource_method_handlers[rpc_method]
|
|
14
31
|
if handler
|
|
15
32
|
send(handler, id, params)
|
|
@@ -32,6 +49,17 @@ module ActionMCP
|
|
|
32
49
|
}
|
|
33
50
|
end
|
|
34
51
|
|
|
52
|
+
def negotiated_resource_capabilities
|
|
53
|
+
capabilities = (transport.session.server_capabilities || {}).with_indifferent_access
|
|
54
|
+
resource_capabilities = capabilities[:resources]
|
|
55
|
+
resource_capabilities.with_indifferent_access if resource_capabilities.is_a?(Hash)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def subscription_method?(rpc_method)
|
|
59
|
+
rpc_method == JsonRpcHandlerBase::Methods::RESOURCES_SUBSCRIBE ||
|
|
60
|
+
rpc_method == JsonRpcHandlerBase::Methods::RESOURCES_UNSUBSCRIBE
|
|
61
|
+
end
|
|
62
|
+
|
|
35
63
|
def handle_resources_list(id, params)
|
|
36
64
|
transport.send_resources_list(id, params)
|
|
37
65
|
end
|
|
@@ -41,22 +69,28 @@ module ActionMCP
|
|
|
41
69
|
end
|
|
42
70
|
|
|
43
71
|
def handle_resources_read(id, params)
|
|
44
|
-
|
|
72
|
+
validate_resource_uri(params)
|
|
45
73
|
transport.send_resource_read(id, params)
|
|
46
74
|
end
|
|
47
75
|
|
|
48
76
|
def handle_resources_subscribe(id, params)
|
|
49
|
-
uri =
|
|
50
|
-
|
|
51
|
-
message = transport.send_resource_subscribe(id, uri)
|
|
52
|
-
extract_message_payload(message, id)
|
|
77
|
+
uri = validate_resource_uri(params)
|
|
78
|
+
transport.send_resource_subscribe(id, uri)
|
|
53
79
|
end
|
|
54
80
|
|
|
55
81
|
def handle_resources_unsubscribe(id, params)
|
|
82
|
+
uri = validate_resource_uri(params)
|
|
83
|
+
transport.send_resource_unsubscribe(id, uri)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_resource_uri(params)
|
|
56
87
|
uri = validate_required_param(params, "uri", "Resource URI is required")
|
|
88
|
+
parsed = URI.parse(uri) if uri.is_a?(String)
|
|
89
|
+
return uri if parsed&.scheme.present?
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource URI must be an absolute URI")
|
|
92
|
+
rescue URI::InvalidURIError
|
|
93
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Resource URI must be an absolute URI")
|
|
60
94
|
end
|
|
61
95
|
end
|
|
62
96
|
end
|
|
@@ -12,11 +12,31 @@ module ActionMCP
|
|
|
12
12
|
params ||= {}
|
|
13
13
|
|
|
14
14
|
with_error_handling(id) do
|
|
15
|
+
unless params.is_a?(Hash)
|
|
16
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Task params must be an object")
|
|
17
|
+
end
|
|
18
|
+
|
|
15
19
|
unless transport.session.protocol_version == "2025-11-25"
|
|
16
20
|
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
17
21
|
message: "Tasks are only available in MCP 2025-11-25")
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
task_capabilities = negotiated_task_capabilities
|
|
25
|
+
unless task_capabilities
|
|
26
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
27
|
+
message: "Tasks are not available for this session")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if rpc_method == JsonRpcHandlerBase::Methods::TASKS_LIST && !task_capabilities.key?(:list)
|
|
31
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
32
|
+
message: "Task listing is not available for this session")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if rpc_method == JsonRpcHandlerBase::Methods::TASKS_CANCEL && !task_capabilities.key?(:cancel)
|
|
36
|
+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
|
|
37
|
+
message: "Task cancellation is not available for this session")
|
|
38
|
+
end
|
|
39
|
+
|
|
20
40
|
handler = task_method_handlers[rpc_method]
|
|
21
41
|
if handler
|
|
22
42
|
send(handler, id, params)
|
|
@@ -38,6 +58,12 @@ module ActionMCP
|
|
|
38
58
|
}
|
|
39
59
|
end
|
|
40
60
|
|
|
61
|
+
def negotiated_task_capabilities
|
|
62
|
+
capabilities = (transport.session.server_capabilities || {}).with_indifferent_access
|
|
63
|
+
task_capabilities = capabilities[:tasks]
|
|
64
|
+
task_capabilities.with_indifferent_access if task_capabilities.is_a?(Hash)
|
|
65
|
+
end
|
|
66
|
+
|
|
41
67
|
def handle_tasks_get(id, params)
|
|
42
68
|
task_id = validate_required_param(params, "taskId", "Task ID is required")
|
|
43
69
|
task = find_task_or_error(id, task_id)
|
|
@@ -10,6 +10,10 @@ module ActionMCP
|
|
|
10
10
|
params ||= {}
|
|
11
11
|
|
|
12
12
|
with_error_handling(id) do
|
|
13
|
+
unless params.is_a?(Hash)
|
|
14
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Tool params must be an object")
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
handler = tool_method_handlers[rpc_method]
|
|
14
18
|
if handler
|
|
15
19
|
send(handler, id, params)
|
|
@@ -36,6 +40,10 @@ module ActionMCP
|
|
|
36
40
|
def handle_tools_call(id, params)
|
|
37
41
|
name = validate_required_param(params, "name", "Tool name is required")
|
|
38
42
|
arguments = extract_arguments(params)
|
|
43
|
+
unless arguments.is_a?(Hash)
|
|
44
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_params, message: "Tool arguments must be an object")
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
_meta = params["_meta"] || params[:_meta] || {}
|
|
40
48
|
task_params = params.key?("task") ? params["task"] : params[:task]
|
|
41
49
|
transport.send_tools_call(id, name, arguments, _meta, task_params)
|
|
@@ -81,20 +81,19 @@ module ActionMCP
|
|
|
81
81
|
|
|
82
82
|
def handle_response(response)
|
|
83
83
|
Rails.logger.debug("Received response: #{response.inspect}")
|
|
84
|
-
|
|
84
|
+
nil
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def process_completion_complete(id, params)
|
|
88
|
-
|
|
89
|
-
context = params["context"] if params.is_a?(Hash)
|
|
90
|
-
|
|
91
|
-
transport.send_jsonrpc_response(id, result: build_completion_result(params, context))
|
|
88
|
+
transport.send_completion_complete(id, params)
|
|
92
89
|
end
|
|
93
90
|
|
|
94
91
|
def process_notifications(rpc_method, params)
|
|
95
92
|
case rpc_method
|
|
96
93
|
when Methods::NOTIFICATIONS_INITIALIZED
|
|
97
|
-
transport.initialize!
|
|
94
|
+
raise JSON_RPC::JsonRpcError.new(:invalid_request, message: "Session is not awaiting initialization") unless transport.initialize!
|
|
95
|
+
when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED
|
|
96
|
+
transport.refresh_roots_list
|
|
98
97
|
else
|
|
99
98
|
super
|
|
100
99
|
end
|
|
@@ -107,21 +106,6 @@ module ActionMCP
|
|
|
107
106
|
result: response.result
|
|
108
107
|
}
|
|
109
108
|
end
|
|
110
|
-
|
|
111
|
-
def build_completion_result(_params = {}, _context = nil)
|
|
112
|
-
# In a real implementation, this would use the params and context
|
|
113
|
-
# to generate appropriate completion suggestions
|
|
114
|
-
# For now, we just return an empty result
|
|
115
|
-
#
|
|
116
|
-
# params contains:
|
|
117
|
-
# - ref: reference to prompt or resource template
|
|
118
|
-
# - argument: { name: string, value: string }
|
|
119
|
-
# - context: { arguments: { [key]: string } } (previously resolved variables)
|
|
120
|
-
|
|
121
|
-
{
|
|
122
|
-
completion: { values: [], total: 0, hasMore: false }
|
|
123
|
-
}
|
|
124
|
-
end
|
|
125
109
|
end
|
|
126
110
|
end
|
|
127
111
|
end
|
|
@@ -33,6 +33,8 @@ module ActionMCP
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def send_resource_updated_notification(uri)
|
|
36
|
+
return unless session.resource_subscribed?(uri)
|
|
37
|
+
|
|
36
38
|
send_jsonrpc_notification("notifications/resources/updated", { uri: uri })
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -47,7 +49,7 @@ module ActionMCP
|
|
|
47
49
|
def send_logging_message_notification(level:, data:, logger: nil)
|
|
48
50
|
params = { level: level, data: data }
|
|
49
51
|
params[:logger] = logger if logger.present?
|
|
50
|
-
send_jsonrpc_notification("notifications/
|
|
52
|
+
send_jsonrpc_notification("notifications/message", params)
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def send_progress_notification(progressToken:, progress:, total: nil, message: nil, **options)
|
|
@@ -33,13 +33,15 @@ module ActionMCP
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
if result.is_error
|
|
36
|
-
send_jsonrpc_response(request_id, error: result)
|
|
36
|
+
send_jsonrpc_response(request_id, error: result.to_h)
|
|
37
37
|
else
|
|
38
|
-
send_jsonrpc_response(request_id, result: result)
|
|
38
|
+
send_jsonrpc_response(request_id, result: result.to_h)
|
|
39
39
|
end
|
|
40
40
|
else
|
|
41
|
-
send_jsonrpc_error(request_id, :
|
|
41
|
+
send_jsonrpc_error(request_id, :invalid_params, "Prompt '#{prompt_name}' not available in this session")
|
|
42
42
|
end
|
|
43
|
+
rescue ArgumentError, ActiveModel::UnknownAttributeError => e
|
|
44
|
+
send_jsonrpc_error(request_id, :invalid_params, e.message)
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
47
|
end
|
|
@@ -46,7 +46,7 @@ module ActionMCP
|
|
|
46
46
|
# @param params [Hash] Parameters specifying which resource to read
|
|
47
47
|
def send_resource_read(id, params)
|
|
48
48
|
uri = params["uri"]
|
|
49
|
-
template =
|
|
49
|
+
template = find_registered_template_for_uri(uri)
|
|
50
50
|
|
|
51
51
|
unless template
|
|
52
52
|
error = {
|
|
@@ -108,8 +108,40 @@ module ActionMCP
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# Subscribe the current session to updates for an available resource.
|
|
112
|
+
def send_resource_subscribe(id, uri)
|
|
113
|
+
unless find_registered_template_for_uri(uri)
|
|
114
|
+
send_jsonrpc_response(id, error: resource_not_found_error(uri))
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
session.resource_subscribe(uri)
|
|
119
|
+
send_jsonrpc_response(id, result: {})
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Remove a resource update subscription. Unsubscribing is idempotent.
|
|
123
|
+
def send_resource_unsubscribe(id, uri)
|
|
124
|
+
session.resource_unsubscribe(uri)
|
|
125
|
+
send_jsonrpc_response(id, result: {})
|
|
126
|
+
end
|
|
127
|
+
|
|
111
128
|
private
|
|
112
129
|
|
|
130
|
+
def find_registered_template_for_uri(uri)
|
|
131
|
+
ResourceTemplatesRegistry.find_template_for_uri(
|
|
132
|
+
uri,
|
|
133
|
+
templates: session.registered_resource_templates
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resource_not_found_error(uri)
|
|
138
|
+
{
|
|
139
|
+
code: -32_002,
|
|
140
|
+
message: "Resource not found",
|
|
141
|
+
data: { uri: uri }
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
113
145
|
# Collect all concrete resources from templates that implement list.
|
|
114
146
|
# Returns nil if a URI collision error was sent.
|
|
115
147
|
# @return [Array<ActionMCP::Resource>, nil]
|
|
@@ -167,7 +199,8 @@ module ActionMCP
|
|
|
167
199
|
def normalize_read_content(content, _uri)
|
|
168
200
|
case content
|
|
169
201
|
when ActionMCP::Content::Resource
|
|
170
|
-
inner = { uri: content.uri
|
|
202
|
+
inner = { uri: content.uri }
|
|
203
|
+
inner[:mimeType] = content.mime_type if content.mime_type
|
|
171
204
|
inner[:text] = content.text if content.text
|
|
172
205
|
inner[:blob] = content.blob if content.blob
|
|
173
206
|
inner[:_meta] = content.meta if content.meta && !content.meta.empty?
|
|
@@ -10,11 +10,15 @@ module ActionMCP
|
|
|
10
10
|
@collected_responses = []
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# Collect messages for the HTTP response while retaining the session's
|
|
14
|
+
# complete wire history. Server-initiated requests cannot be placed in
|
|
15
|
+
# the empty 202 response required for an incoming notification, so the
|
|
16
|
+
# generated request must not be silently discarded.
|
|
14
17
|
def write_message(message)
|
|
15
18
|
if messaging_mode == :return
|
|
16
19
|
@collected_responses ||= []
|
|
17
20
|
@collected_responses << message
|
|
21
|
+
super
|
|
18
22
|
message
|
|
19
23
|
else
|
|
20
24
|
super
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
module ActionMCP
|
|
4
4
|
module Server
|
|
5
5
|
module Roots
|
|
6
|
-
def send_roots_list(id)
|
|
7
|
-
|
|
6
|
+
def send_roots_list(id = SecureRandom.uuid_v7)
|
|
7
|
+
roots = session.client_capabilities&.then { |capabilities| capabilities["roots"] || capabilities[:roots] }
|
|
8
|
+
raise UnsupportedRootsError, "Client does not support roots" unless roots.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
send_jsonrpc_request("roots/list", id: id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def refresh_roots_list
|
|
14
|
+
roots = session.client_capabilities&.then { |capabilities| capabilities["roots"] || capabilities[:roots] }
|
|
15
|
+
return unless roots.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
list_changed = roots["listChanged"] || roots[:listChanged]
|
|
18
|
+
send_roots_list if list_changed == true
|
|
8
19
|
end
|
|
9
20
|
end
|
|
21
|
+
|
|
22
|
+
class UnsupportedRootsError < StandardError; end
|
|
10
23
|
end
|
|
11
24
|
end
|
|
@@ -7,8 +7,43 @@ module ActionMCP
|
|
|
7
7
|
# @param [SamplingRequest] request
|
|
8
8
|
def send_sampling_create_message(id, request)
|
|
9
9
|
params = request.is_a?(SamplingRequest) ? request.to_h : request
|
|
10
|
-
|
|
10
|
+
SamplingRequest.validate_params!(params)
|
|
11
|
+
require_client_sampling_support!(params)
|
|
12
|
+
|
|
13
|
+
send_jsonrpc_request("sampling/createMessage", params: params, id: id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def require_client_sampling_support!(params)
|
|
19
|
+
client_capabilities = session.client_capabilities || {}
|
|
20
|
+
sampling = client_capabilities["sampling"] || client_capabilities[:sampling]
|
|
21
|
+
raise UnsupportedSamplingError, "Client does not support sampling" unless sampling.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
if params.key?(:tools) || params.key?("tools") || params.key?(:toolChoice) || params.key?("toolChoice")
|
|
24
|
+
tools = sampling["tools"] || sampling[:tools]
|
|
25
|
+
raise UnsupportedSamplingError, "Client does not support sampling with tools" unless tools.is_a?(Hash)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
include_context = params[:includeContext] || params["includeContext"]
|
|
29
|
+
if %w[thisServer allServers].include?(include_context)
|
|
30
|
+
context = sampling["context"] || sampling[:context]
|
|
31
|
+
raise UnsupportedSamplingError, "Client does not support sampling context inclusion" unless context.is_a?(Hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
return unless params.key?(:task) || params.key?("task")
|
|
35
|
+
|
|
36
|
+
tasks = client_capabilities["tasks"] || client_capabilities[:tasks]
|
|
37
|
+
task_requests = tasks.is_a?(Hash) && (tasks["requests"] || tasks[:requests])
|
|
38
|
+
task_sampling = task_requests.is_a?(Hash) && (task_requests["sampling"] || task_requests[:sampling])
|
|
39
|
+
create_message = task_sampling.is_a?(Hash) &&
|
|
40
|
+
(task_sampling["createMessage"] || task_sampling[:createMessage])
|
|
41
|
+
return if create_message.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
raise UnsupportedSamplingError, "Client does not support task-augmented sampling"
|
|
11
44
|
end
|
|
12
45
|
end
|
|
46
|
+
|
|
47
|
+
class UnsupportedSamplingError < StandardError; end
|
|
13
48
|
end
|
|
14
49
|
end
|