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,114 +1,376 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
3
5
|
module ActionMCP
|
|
4
6
|
module Server
|
|
5
7
|
class SamplingRequest
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
UNSET = Object.new.freeze
|
|
9
|
+
|
|
10
|
+
TOOL_SCHEMA_OBJECT = {
|
|
11
|
+
"type" => "object",
|
|
12
|
+
"required" => [ "type" ],
|
|
13
|
+
"properties" => {
|
|
14
|
+
"$schema" => { "type" => "string" },
|
|
15
|
+
"properties" => {
|
|
16
|
+
"type" => "object",
|
|
17
|
+
"additionalProperties" => {
|
|
18
|
+
"type" => "object",
|
|
19
|
+
"properties" => {},
|
|
20
|
+
"additionalProperties" => true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"required" => { "type" => "array", "items" => { "type" => "string" } },
|
|
24
|
+
"type" => { "type" => "string", "const" => "object" }
|
|
25
|
+
}
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
SAMPLING_DEFINITIONS = Content::Validation::DEFINITIONS.merge(
|
|
29
|
+
"ProgressToken" => { "type" => %w[string integer] },
|
|
30
|
+
"Role" => { "type" => "string", "enum" => %w[assistant user] },
|
|
31
|
+
"ToolSchema" => TOOL_SCHEMA_OBJECT,
|
|
32
|
+
"ModelHint" => {
|
|
33
|
+
"type" => "object",
|
|
34
|
+
"properties" => { "name" => { "type" => "string" } }
|
|
35
|
+
},
|
|
36
|
+
"ModelPreferences" => {
|
|
37
|
+
"type" => "object",
|
|
38
|
+
"properties" => {
|
|
39
|
+
"hints" => {
|
|
40
|
+
"type" => "array",
|
|
41
|
+
"items" => { "$ref" => "#/$defs/ModelHint" }
|
|
42
|
+
},
|
|
43
|
+
"costPriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 },
|
|
44
|
+
"speedPriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 },
|
|
45
|
+
"intelligencePriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"ToolAnnotations" => {
|
|
49
|
+
"type" => "object",
|
|
50
|
+
"properties" => {
|
|
51
|
+
"destructiveHint" => { "type" => "boolean" },
|
|
52
|
+
"idempotentHint" => { "type" => "boolean" },
|
|
53
|
+
"openWorldHint" => { "type" => "boolean" },
|
|
54
|
+
"readOnlyHint" => { "type" => "boolean" },
|
|
55
|
+
"title" => { "type" => "string" }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"ToolExecution" => {
|
|
59
|
+
"type" => "object",
|
|
60
|
+
"properties" => {
|
|
61
|
+
"taskSupport" => {
|
|
62
|
+
"type" => "string",
|
|
63
|
+
"enum" => %w[forbidden optional required]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"Tool" => {
|
|
68
|
+
"type" => "object",
|
|
69
|
+
"required" => %w[inputSchema name],
|
|
70
|
+
"properties" => Content::Validation::META_PROPERTY.merge(
|
|
71
|
+
"annotations" => { "$ref" => "#/$defs/ToolAnnotations" },
|
|
72
|
+
"description" => { "type" => "string" },
|
|
73
|
+
"execution" => { "$ref" => "#/$defs/ToolExecution" },
|
|
74
|
+
"icons" => { "type" => "array", "items" => { "$ref" => "#/$defs/Icon" } },
|
|
75
|
+
"inputSchema" => { "$ref" => "#/$defs/ToolSchema" },
|
|
76
|
+
"name" => { "type" => "string" },
|
|
77
|
+
"outputSchema" => { "$ref" => "#/$defs/ToolSchema" },
|
|
78
|
+
"title" => { "type" => "string" }
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
"ToolChoice" => {
|
|
82
|
+
"type" => "object",
|
|
83
|
+
"properties" => {
|
|
84
|
+
"mode" => { "type" => "string", "enum" => %w[auto none required] }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"TaskMetadata" => {
|
|
88
|
+
"type" => "object",
|
|
89
|
+
"properties" => { "ttl" => { "type" => "integer" } }
|
|
90
|
+
},
|
|
91
|
+
"ToolUseContent" => {
|
|
92
|
+
"type" => "object",
|
|
93
|
+
"required" => %w[id input name type],
|
|
94
|
+
"properties" => Content::Validation::META_PROPERTY.merge(
|
|
95
|
+
"id" => { "type" => "string" },
|
|
96
|
+
"input" => { "type" => "object" },
|
|
97
|
+
"name" => { "type" => "string" },
|
|
98
|
+
"type" => { "type" => "string", "const" => "tool_use" }
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
"ToolResultContent" => {
|
|
102
|
+
"type" => "object",
|
|
103
|
+
"required" => %w[content toolUseId type],
|
|
104
|
+
"properties" => Content::Validation::META_PROPERTY.merge(
|
|
105
|
+
"content" => {
|
|
106
|
+
"type" => "array",
|
|
107
|
+
"items" => { "$ref" => "#/$defs/ContentBlock" }
|
|
108
|
+
},
|
|
109
|
+
"isError" => { "type" => "boolean" },
|
|
110
|
+
"structuredContent" => { "type" => "object" },
|
|
111
|
+
"toolUseId" => { "type" => "string" },
|
|
112
|
+
"type" => { "type" => "string", "const" => "tool_result" }
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
"SamplingMessageContentBlock" => {
|
|
116
|
+
"anyOf" => %w[TextContent ImageContent AudioContent ToolUseContent ToolResultContent].map do |name|
|
|
117
|
+
{ "$ref" => "#/$defs/#{name}" }
|
|
118
|
+
end
|
|
119
|
+
},
|
|
120
|
+
"SamplingMessage" => {
|
|
121
|
+
"type" => "object",
|
|
122
|
+
"required" => %w[content role],
|
|
123
|
+
"properties" => Content::Validation::META_PROPERTY.merge(
|
|
124
|
+
"content" => {
|
|
125
|
+
"anyOf" => [
|
|
126
|
+
{ "$ref" => "#/$defs/SamplingMessageContentBlock" },
|
|
127
|
+
{
|
|
128
|
+
"type" => "array",
|
|
129
|
+
"items" => { "$ref" => "#/$defs/SamplingMessageContentBlock" }
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"role" => { "$ref" => "#/$defs/Role" }
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
"CreateMessageRequestParams" => {
|
|
137
|
+
"type" => "object",
|
|
138
|
+
"required" => %w[maxTokens messages],
|
|
139
|
+
"properties" => {
|
|
140
|
+
"_meta" => {
|
|
141
|
+
"type" => "object",
|
|
142
|
+
"properties" => {
|
|
143
|
+
"progressToken" => { "$ref" => "#/$defs/ProgressToken" }
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
"includeContext" => {
|
|
147
|
+
"type" => "string",
|
|
148
|
+
"enum" => %w[allServers none thisServer]
|
|
149
|
+
},
|
|
150
|
+
"maxTokens" => { "type" => "integer" },
|
|
151
|
+
"messages" => {
|
|
152
|
+
"type" => "array",
|
|
153
|
+
"items" => { "$ref" => "#/$defs/SamplingMessage" }
|
|
154
|
+
},
|
|
155
|
+
"metadata" => { "type" => "object" },
|
|
156
|
+
"modelPreferences" => { "$ref" => "#/$defs/ModelPreferences" },
|
|
157
|
+
"stopSequences" => { "type" => "array", "items" => { "type" => "string" } },
|
|
158
|
+
"systemPrompt" => { "type" => "string" },
|
|
159
|
+
"task" => { "$ref" => "#/$defs/TaskMetadata" },
|
|
160
|
+
"temperature" => { "type" => "number" },
|
|
161
|
+
"toolChoice" => { "$ref" => "#/$defs/ToolChoice" },
|
|
162
|
+
"tools" => { "type" => "array", "items" => { "$ref" => "#/$defs/Tool" } }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
).freeze
|
|
166
|
+
|
|
167
|
+
PARAMS_SCHEMA = {
|
|
168
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
169
|
+
"$ref" => "#/$defs/CreateMessageRequestParams",
|
|
170
|
+
"$defs" => SAMPLING_DEFINITIONS
|
|
171
|
+
}.freeze
|
|
172
|
+
PARAMS_SCHEMER = JSONSchemer.schema(
|
|
173
|
+
PARAMS_SCHEMA,
|
|
174
|
+
formats: Content::Validation::FORMAT_VALIDATORS
|
|
175
|
+
)
|
|
10
176
|
|
|
177
|
+
class << self
|
|
11
178
|
def configure
|
|
12
179
|
yield self
|
|
13
180
|
end
|
|
14
181
|
|
|
15
|
-
def messages(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
mutate_content(msg)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
@messages ||= []
|
|
182
|
+
def messages(value = UNSET)
|
|
183
|
+
@default_messages = value.map { |message| normalize_message(message) } unless value.equal?(UNSET)
|
|
184
|
+
@default_messages ||= []
|
|
22
185
|
end
|
|
23
186
|
|
|
24
|
-
def system_prompt(
|
|
25
|
-
@default_system_prompt =
|
|
187
|
+
def system_prompt(value = UNSET)
|
|
188
|
+
@default_system_prompt = value unless value.equal?(UNSET)
|
|
26
189
|
@default_system_prompt
|
|
27
190
|
end
|
|
28
191
|
|
|
29
|
-
def include_context(
|
|
30
|
-
@default_context =
|
|
192
|
+
def include_context(value = UNSET)
|
|
193
|
+
@default_context = value unless value.equal?(UNSET)
|
|
31
194
|
@default_context
|
|
32
195
|
end
|
|
33
196
|
|
|
34
|
-
def model_hints(
|
|
35
|
-
@default_model_hints =
|
|
36
|
-
@
|
|
197
|
+
def model_hints(value = UNSET)
|
|
198
|
+
@default_model_hints = value unless value.equal?(UNSET)
|
|
199
|
+
@default_model_hints ||= []
|
|
37
200
|
end
|
|
38
201
|
|
|
39
|
-
def intelligence_priority(
|
|
40
|
-
@default_intelligence_priority =
|
|
41
|
-
@
|
|
202
|
+
def intelligence_priority(value = UNSET)
|
|
203
|
+
@default_intelligence_priority = value unless value.equal?(UNSET)
|
|
204
|
+
@default_intelligence_priority.nil? ? 0.9 : @default_intelligence_priority
|
|
42
205
|
end
|
|
43
206
|
|
|
44
|
-
def max_tokens(
|
|
45
|
-
@default_max_tokens =
|
|
46
|
-
@
|
|
207
|
+
def max_tokens(value = UNSET)
|
|
208
|
+
@default_max_tokens = value unless value.equal?(UNSET)
|
|
209
|
+
@default_max_tokens.nil? ? 500 : @default_max_tokens
|
|
47
210
|
end
|
|
48
211
|
|
|
49
|
-
def temperature(
|
|
50
|
-
@default_temperature =
|
|
51
|
-
@
|
|
212
|
+
def temperature(value = UNSET)
|
|
213
|
+
@default_temperature = value unless value.equal?(UNSET)
|
|
214
|
+
@default_temperature.nil? ? 0.7 : @default_temperature
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_params!(params)
|
|
218
|
+
raise ArgumentError, "Sampling request params must be an object" unless params.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
wire_params = params.deep_stringify_keys
|
|
221
|
+
errors = PARAMS_SCHEMER.validate(wire_params).to_a
|
|
222
|
+
unless errors.empty?
|
|
223
|
+
pointers = errors.map { |error| error["data_pointer"].presence || "/" }.uniq
|
|
224
|
+
raise ArgumentError, "Sampling request must match MCP 2025-11-25 at #{pointers.join(', ')}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
validate_tool_conversation!(wire_params.fetch("messages"))
|
|
228
|
+
params
|
|
52
229
|
end
|
|
53
230
|
|
|
54
231
|
private
|
|
55
232
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
233
|
+
def validate_tool_conversation!(messages)
|
|
234
|
+
expected_ids = nil
|
|
235
|
+
seen_tool_use_ids = {}
|
|
236
|
+
|
|
237
|
+
messages.each_with_index do |message, index|
|
|
238
|
+
blocks = message["content"].is_a?(Array) ? message["content"] : [ message["content"] ]
|
|
239
|
+
tool_results = blocks.select { |block| block["type"] == "tool_result" }
|
|
240
|
+
|
|
241
|
+
if expected_ids
|
|
242
|
+
validate_tool_results!(message, blocks, tool_results, expected_ids, index)
|
|
243
|
+
expected_ids = nil
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
if tool_results.any?
|
|
248
|
+
validate_tool_result_message!(message, blocks, index)
|
|
249
|
+
invalid_tool_conversation!(index, "tool results do not match a preceding tool use")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
tool_uses = blocks.select { |block| block["type"] == "tool_use" }
|
|
253
|
+
next if tool_uses.empty?
|
|
254
|
+
|
|
255
|
+
unless message["role"] == "assistant"
|
|
256
|
+
invalid_tool_conversation!(index, "tool uses must be in an assistant message")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
ids = tool_uses.map { |tool_use| tool_use.fetch("id") }
|
|
260
|
+
reject_duplicate_ids!(ids, index, "tool use")
|
|
261
|
+
|
|
262
|
+
if (duplicate = ids.find { |id| seen_tool_use_ids.key?(id) })
|
|
263
|
+
invalid_tool_conversation!(index, "tool use ID #{duplicate.inspect} is not unique")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ids.each { |id| seen_tool_use_ids[id] = true }
|
|
267
|
+
expected_ids = ids
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
return unless expected_ids
|
|
271
|
+
|
|
272
|
+
invalid_tool_conversation!(messages.length, "assistant tool uses are missing their next user results")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def validate_tool_results!(message, blocks, tool_results, expected_ids, index)
|
|
276
|
+
validate_tool_result_message!(message, blocks, index)
|
|
277
|
+
|
|
278
|
+
result_ids = tool_results.map { |tool_result| tool_result.fetch("toolUseId") }
|
|
279
|
+
reject_duplicate_ids!(result_ids, index, "tool result")
|
|
280
|
+
return if result_ids.sort == expected_ids.sort
|
|
281
|
+
|
|
282
|
+
invalid_tool_conversation!(index, "tool result IDs must exactly match the preceding tool use IDs")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def validate_tool_result_message!(message, blocks, index)
|
|
286
|
+
valid = message["role"] == "user" && blocks.any? &&
|
|
287
|
+
blocks.all? { |block| block["type"] == "tool_result" }
|
|
288
|
+
return if valid
|
|
289
|
+
|
|
290
|
+
invalid_tool_conversation!(index, "tool result messages must be user messages containing only results")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def reject_duplicate_ids!(ids, index, label)
|
|
294
|
+
return if ids.uniq.length == ids.length
|
|
295
|
+
|
|
296
|
+
invalid_tool_conversation!(index, "#{label} IDs must not contain duplicates")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def invalid_tool_conversation!(index, reason)
|
|
300
|
+
raise ArgumentError,
|
|
301
|
+
"Sampling request must match MCP 2025-11-25 at /messages/#{index}: #{reason}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def normalize_message(message)
|
|
305
|
+
raise ArgumentError, "Sampling messages must be objects" unless message.is_a?(Hash)
|
|
306
|
+
|
|
307
|
+
normalized = message.symbolize_keys
|
|
308
|
+
content = normalized[:content]
|
|
309
|
+
if content.respond_to?(:to_h) && !content.is_a?(Hash) && !content.is_a?(Array)
|
|
310
|
+
normalized[:content] = content.to_h
|
|
62
311
|
end
|
|
312
|
+
normalized
|
|
63
313
|
end
|
|
64
314
|
end
|
|
65
315
|
|
|
66
|
-
attr_accessor :system_prompt, :model_hints, :
|
|
316
|
+
attr_accessor :system_prompt, :model_hints, :cost_priority, :speed_priority,
|
|
317
|
+
:intelligence_priority, :max_tokens, :temperature, :stop_sequences,
|
|
318
|
+
:metadata, :tools, :tool_choice, :task, :request_meta
|
|
67
319
|
attr_reader :messages, :context
|
|
68
320
|
|
|
69
321
|
def initialize
|
|
70
|
-
@messages = self.class.
|
|
71
|
-
@system_prompt = self.class.
|
|
72
|
-
@context = self.class.
|
|
73
|
-
@model_hints = self.class.
|
|
74
|
-
@intelligence_priority = self.class.
|
|
75
|
-
@max_tokens = self.class.
|
|
76
|
-
@temperature = self.class.
|
|
322
|
+
@messages = self.class.messages.map(&:deep_dup)
|
|
323
|
+
@system_prompt = self.class.system_prompt
|
|
324
|
+
@context = self.class.include_context
|
|
325
|
+
@model_hints = self.class.model_hints.deep_dup
|
|
326
|
+
@intelligence_priority = self.class.intelligence_priority
|
|
327
|
+
@max_tokens = self.class.max_tokens
|
|
328
|
+
@temperature = self.class.temperature
|
|
77
329
|
|
|
78
330
|
yield self if block_given?
|
|
79
331
|
end
|
|
80
332
|
|
|
81
333
|
def messages=(value)
|
|
82
|
-
@messages = value.map
|
|
83
|
-
self.class.send(:mutate_content, msg)
|
|
84
|
-
end
|
|
334
|
+
@messages = value.map { |message| self.class.send(:normalize_message, message) }
|
|
85
335
|
end
|
|
86
336
|
|
|
87
337
|
def include_context=(value)
|
|
88
338
|
@context = value
|
|
89
339
|
end
|
|
90
340
|
|
|
91
|
-
def add_message(content, role: "user")
|
|
92
|
-
if content.
|
|
93
|
-
|
|
94
|
-
else
|
|
95
|
-
content = Content::Text.new(content, annotations: nil).to_h if content.is_a?(String)
|
|
96
|
-
@messages << { role: role, content: content }
|
|
341
|
+
def add_message(content, role: "user", _meta: nil)
|
|
342
|
+
if content.respond_to?(:to_h) && !content.is_a?(Hash) && !content.is_a?(Array)
|
|
343
|
+
content = content.to_h
|
|
97
344
|
end
|
|
345
|
+
content = Content::Text.new(content, annotations: nil).to_h if content.is_a?(String)
|
|
346
|
+
|
|
347
|
+
message = { role: role, content: content }
|
|
348
|
+
message[:_meta] = _meta if _meta
|
|
349
|
+
@messages << message
|
|
98
350
|
end
|
|
99
351
|
|
|
100
352
|
def to_h
|
|
101
|
-
{
|
|
102
|
-
messages: messages.map { |
|
|
353
|
+
params = {
|
|
354
|
+
messages: messages.map { |message| message.slice(:role, :content, :_meta).compact },
|
|
103
355
|
systemPrompt: system_prompt,
|
|
104
356
|
includeContext: context,
|
|
105
357
|
modelPreferences: {
|
|
106
|
-
hints: model_hints.map { |
|
|
358
|
+
hints: model_hints.map { |hint| hint.is_a?(Hash) ? hint : { name: hint } },
|
|
359
|
+
costPriority: cost_priority,
|
|
360
|
+
speedPriority: speed_priority,
|
|
107
361
|
intelligencePriority: intelligence_priority
|
|
108
|
-
},
|
|
362
|
+
}.compact,
|
|
109
363
|
maxTokens: max_tokens,
|
|
110
|
-
temperature: temperature
|
|
364
|
+
temperature: temperature,
|
|
365
|
+
stopSequences: stop_sequences,
|
|
366
|
+
metadata: metadata,
|
|
367
|
+
tools: tools,
|
|
368
|
+
toolChoice: tool_choice,
|
|
369
|
+
task: task,
|
|
370
|
+
_meta: request_meta
|
|
111
371
|
}.compact
|
|
372
|
+
|
|
373
|
+
self.class.validate_params!(params)
|
|
112
374
|
end
|
|
113
375
|
end
|
|
114
376
|
end
|
|
@@ -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
|
|
8
|
+
# - tasks/result: Get task result (blocking until a terminal state)
|
|
9
9
|
# - tasks/list: List tasks for the session
|
|
10
10
|
# - tasks/cancel: Cancel a task
|
|
11
11
|
module Tasks
|
|
@@ -19,35 +19,17 @@ module ActionMCP
|
|
|
19
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
|
|
22
|
+
# Get task result, blocking until the task reaches a terminal 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
|
-
unless task.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
29
|
+
task = wait_for_terminal_task(task_id) unless task.terminal?
|
|
30
|
+
unless task
|
|
31
|
+
send_jsonrpc_error(request_id, :invalid_params, "Task '#{task_id}' not found")
|
|
32
|
+
return
|
|
51
33
|
end
|
|
52
34
|
|
|
53
35
|
if (error = task.to_task_error)
|
|
@@ -83,19 +65,28 @@ module ActionMCP
|
|
|
83
65
|
task = find_task(task_id)
|
|
84
66
|
return unless task
|
|
85
67
|
|
|
86
|
-
|
|
68
|
+
terminal_status = nil
|
|
69
|
+
task.with_lock do
|
|
70
|
+
if task.terminal?
|
|
71
|
+
terminal_status = task.status
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
task.status_message = "The task was cancelled by request." if task.status_message.blank?
|
|
76
|
+
task.result_payload ||= {
|
|
77
|
+
code: -32_000,
|
|
78
|
+
message: "Task was cancelled"
|
|
79
|
+
}
|
|
80
|
+
task.save! if task.changed?
|
|
81
|
+
task.cancel!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if terminal_status
|
|
87
85
|
send_jsonrpc_error(request_id, :invalid_params,
|
|
88
|
-
"Cannot cancel task in terminal status: #{
|
|
86
|
+
"Cannot cancel task in terminal status: #{terminal_status}")
|
|
89
87
|
return
|
|
90
88
|
end
|
|
91
89
|
|
|
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?
|
|
98
|
-
task.cancel!
|
|
99
90
|
send_jsonrpc_response(request_id, result: task.to_task_data)
|
|
100
91
|
end
|
|
101
92
|
|
|
@@ -144,17 +135,12 @@ module ActionMCP
|
|
|
144
135
|
task
|
|
145
136
|
end
|
|
146
137
|
|
|
147
|
-
def
|
|
148
|
-
deadline = monotonic_time + ActionMCP.configuration.tasks_result_timeout.to_f
|
|
149
|
-
|
|
138
|
+
def wait_for_terminal_task(task_id)
|
|
150
139
|
loop do
|
|
151
140
|
task = load_task_for_result(task_id)
|
|
152
|
-
return task if task.nil? || task.
|
|
153
|
-
|
|
154
|
-
remaining = deadline - monotonic_time
|
|
155
|
-
return task unless remaining.positive?
|
|
141
|
+
return task if task.nil? || task.terminal?
|
|
156
142
|
|
|
157
|
-
sleep
|
|
143
|
+
sleep ActionMCP.configuration.tasks_result_poll_interval.to_f
|
|
158
144
|
end
|
|
159
145
|
end
|
|
160
146
|
|
|
@@ -163,10 +149,6 @@ module ActionMCP
|
|
|
163
149
|
session.tasks.find_by(id: task_id)
|
|
164
150
|
end
|
|
165
151
|
end
|
|
166
|
-
|
|
167
|
-
def monotonic_time
|
|
168
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
169
|
-
end
|
|
170
152
|
end
|
|
171
153
|
end
|
|
172
154
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMCP
|
|
4
|
+
module Server
|
|
5
|
+
module ToolResult
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def normalize(response)
|
|
9
|
+
payload = response.to_h
|
|
10
|
+
code = payload[:code] || payload["code"]
|
|
11
|
+
return response unless response.error? && code == -32_602
|
|
12
|
+
|
|
13
|
+
execution_error(
|
|
14
|
+
payload[:message] || payload["message"],
|
|
15
|
+
data: payload[:data] || payload["data"]
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execution_error(message, data: nil)
|
|
20
|
+
details = data.is_a?(Array) ? data.join(", ") : nil
|
|
21
|
+
text = [ message, details ].compact_blank.join(": ")
|
|
22
|
+
|
|
23
|
+
ToolResponse.new.tap { |response| response.report_tool_error(text) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -31,7 +31,7 @@ module ActionMCP
|
|
|
31
31
|
|
|
32
32
|
unless tool_class
|
|
33
33
|
Rails.logger.error "Tool not found: #{tool_name}. Registered tools: #{session.registered_tools.map(&:tool_name).join(', ')}"
|
|
34
|
-
send_jsonrpc_error(request_id, :
|
|
34
|
+
send_jsonrpc_error(request_id, :invalid_params,
|
|
35
35
|
"Tool '#{tool_name}' not found or not registered for this session")
|
|
36
36
|
return
|
|
37
37
|
end
|
|
@@ -51,8 +51,7 @@ module ActionMCP
|
|
|
51
51
|
task_requested = !task_params.nil?
|
|
52
52
|
|
|
53
53
|
if task_requested && !tasks_enabled?
|
|
54
|
-
|
|
55
|
-
"Task-augmented execution is not available for this session")
|
|
54
|
+
execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
|
|
56
55
|
return
|
|
57
56
|
end
|
|
58
57
|
|
|
@@ -74,7 +73,7 @@ module ActionMCP
|
|
|
74
73
|
return
|
|
75
74
|
end
|
|
76
75
|
|
|
77
|
-
if !task_requested && task_support == :required
|
|
76
|
+
if !task_requested && tasks_enabled? && task_support == :required
|
|
78
77
|
send_jsonrpc_error(request_id, :method_not_found,
|
|
79
78
|
"Tool '#{tool_name}' requires task-augmented execution")
|
|
80
79
|
return
|
|
@@ -89,7 +88,7 @@ module ActionMCP
|
|
|
89
88
|
def execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
|
|
90
89
|
begin
|
|
91
90
|
# Create tool and set execution context with request info
|
|
92
|
-
tool = tool_class.
|
|
91
|
+
tool = tool_class.from_wire(arguments)
|
|
93
92
|
tool.with_context({
|
|
94
93
|
session: session,
|
|
95
94
|
request: {
|
|
@@ -117,6 +116,8 @@ module ActionMCP
|
|
|
117
116
|
tool.call
|
|
118
117
|
end
|
|
119
118
|
|
|
119
|
+
result = ToolResult.normalize(result)
|
|
120
|
+
|
|
120
121
|
if result.is_error
|
|
121
122
|
# Protocol error
|
|
122
123
|
send_jsonrpc_response(request_id, error: result.to_h)
|
|
@@ -125,11 +126,9 @@ module ActionMCP
|
|
|
125
126
|
send_jsonrpc_response(request_id, result: result.to_h)
|
|
126
127
|
end
|
|
127
128
|
rescue ArgumentError => e
|
|
128
|
-
|
|
129
|
-
send_jsonrpc_error(request_id, :invalid_params, e.message)
|
|
129
|
+
send_jsonrpc_response(request_id, result: ToolResult.execution_error(e.message).to_h)
|
|
130
130
|
rescue ActiveModel::UnknownAttributeError => e
|
|
131
|
-
|
|
132
|
-
send_jsonrpc_error(request_id, :invalid_params, e.message)
|
|
131
|
+
send_jsonrpc_response(request_id, result: ToolResult.execution_error(e.message).to_h)
|
|
133
132
|
rescue StandardError => e
|
|
134
133
|
# Log the actual error for debugging
|
|
135
134
|
Rails.logger.error "Tool execution error: #{e.class} - #{e.message}"
|
|
@@ -177,7 +176,10 @@ module ActionMCP
|
|
|
177
176
|
end
|
|
178
177
|
|
|
179
178
|
def tasks_enabled?
|
|
180
|
-
|
|
179
|
+
return false unless session.protocol_version == "2025-11-25"
|
|
180
|
+
|
|
181
|
+
capabilities = (session.server_capabilities || {}).with_indifferent_access
|
|
182
|
+
capabilities.dig(:tasks, :requests, :tools, :call).is_a?(Hash)
|
|
181
183
|
end
|
|
182
184
|
|
|
183
185
|
def tool_task_support(tool_class)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "pagination"
|
|
4
4
|
require_relative "response_collector"
|
|
5
5
|
require_relative "base_messaging"
|
|
6
|
+
require_relative "client_notifications"
|
|
6
7
|
|
|
7
8
|
module ActionMCP
|
|
8
9
|
module Server
|
|
@@ -14,10 +15,12 @@ module ActionMCP
|
|
|
14
15
|
|
|
15
16
|
include Pagination
|
|
16
17
|
include MessagingService
|
|
18
|
+
include ClientNotifications
|
|
17
19
|
include Capabilities
|
|
18
20
|
include Tools
|
|
19
21
|
include Prompts
|
|
20
22
|
include Resources
|
|
23
|
+
include Completions
|
|
21
24
|
include Sampling
|
|
22
25
|
include Roots
|
|
23
26
|
include Elicitation
|