phronomy 0.13.0 → 0.14.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/CHANGELOG.md +90 -0
- data/README.md +51 -2
- data/docs/mcp-client.md +75 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/base.rb +17 -17
- data/lib/phronomy/agent/context/capability/base.rb +6 -0
- data/lib/phronomy/agent/invocation_session.rb +10 -4
- data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
- data/lib/phronomy/configuration.rb +15 -46
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +319 -272
- data/lib/phronomy/engine/fsm_session.rb +17 -14
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/metrics.rb +4 -3
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +385 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
- data/lib/phronomy/workflow_context.rb +1 -2
- data/lib/phronomy/workflow_runner.rb +22 -12
- data/lib/phronomy.rb +24 -19
- metadata +47 -6
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -46,12 +46,21 @@ module Phronomy
|
|
|
46
46
|
scheduler = Phronomy::Runtime::FakeScheduler.new
|
|
47
47
|
scheduler.clock = clock if clock
|
|
48
48
|
runtime = Phronomy::Runtime.new(scheduler: scheduler)
|
|
49
|
-
original = Phronomy::Runtime.
|
|
50
|
-
Phronomy::Runtime.
|
|
49
|
+
original = Phronomy::Runtime.default_if_initialized_for_test
|
|
50
|
+
Phronomy::Runtime.replace_default_for_test(runtime)
|
|
51
51
|
begin
|
|
52
52
|
yield scheduler, clock
|
|
53
53
|
ensure
|
|
54
|
-
|
|
54
|
+
result = nil
|
|
55
|
+
begin
|
|
56
|
+
result = runtime.shutdown
|
|
57
|
+
ensure
|
|
58
|
+
Phronomy::Runtime.restore_default_for_test(original)
|
|
59
|
+
end
|
|
60
|
+
unless result&.cleanup_complete?
|
|
61
|
+
raise Phronomy::RuntimeShutdownError,
|
|
62
|
+
"Temporary test Runtime did not shut down completely"
|
|
63
|
+
end
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
end
|
data/lib/phronomy/tools/mcp.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "mcp"
|
|
4
5
|
require "shellwords"
|
|
5
6
|
require "uri"
|
|
@@ -9,51 +10,73 @@ module Phronomy
|
|
|
9
10
|
# A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
|
|
10
11
|
# MCP (Model Context Protocol) server.
|
|
11
12
|
#
|
|
12
|
-
# Uses the official MCP Ruby SDK
|
|
13
|
-
#
|
|
13
|
+
# Uses the official MCP Ruby SDK v1.x for transport handling, which provides
|
|
14
|
+
# the MCP initialize handshake, HTTP/SSE parsing, and request cancellation.
|
|
14
15
|
#
|
|
15
16
|
# Supports two transport schemes:
|
|
16
17
|
# - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
|
|
17
18
|
# - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
|
|
18
19
|
#
|
|
20
|
+
# Each generated tool instance owns one MCP client. Calls, reconnects, and
|
|
21
|
+
# explicit close operations are serialized because the SDK's stdio transport
|
|
22
|
+
# does not support concurrent response readers.
|
|
23
|
+
#
|
|
19
24
|
# @example
|
|
20
25
|
# web_search = Phronomy::Tools::Mcp.from_server(
|
|
21
26
|
# "stdio://./mcp-server",
|
|
22
27
|
# tool_name: "search_web"
|
|
23
28
|
# )
|
|
24
|
-
# agent = MyAgent.new
|
|
25
29
|
# agent_class.tools(web_search)
|
|
26
30
|
class Mcp < Phronomy::Agent::Context::Capability::Base
|
|
31
|
+
SUPPORTED_SCHEMA_DIALECTS = [
|
|
32
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
33
|
+
"https://json-schema.org/draft/2020-12/schema#"
|
|
34
|
+
].freeze
|
|
35
|
+
SUPPORTED_ROOT_KEYS = %w[$schema type properties required title description additionalProperties].freeze
|
|
36
|
+
SUPPORTED_PROPERTY_KEYS = %w[type description enum title].freeze
|
|
37
|
+
IGNORED_PROPERTY_KEYS = %w[
|
|
38
|
+
minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
|
|
39
|
+
minLength maxLength pattern format default examples
|
|
40
|
+
].freeze
|
|
41
|
+
SUPPORTED_TYPES = %w[string integer number boolean].freeze
|
|
42
|
+
MCP_CLEANUP_POOL_SIZE = 2
|
|
43
|
+
MCP_CLEANUP_QUEUE_SIZE = 100
|
|
44
|
+
|
|
45
|
+
private_constant :SUPPORTED_SCHEMA_DIALECTS,
|
|
46
|
+
:SUPPORTED_ROOT_KEYS,
|
|
47
|
+
:SUPPORTED_PROPERTY_KEYS,
|
|
48
|
+
:IGNORED_PROPERTY_KEYS,
|
|
49
|
+
:SUPPORTED_TYPES,
|
|
50
|
+
:MCP_CLEANUP_POOL_SIZE,
|
|
51
|
+
:MCP_CLEANUP_QUEUE_SIZE
|
|
52
|
+
|
|
27
53
|
class << self
|
|
28
54
|
# Build a Mcp instance by querying a running MCP server for the
|
|
29
55
|
# tool definition identified by +tool_name+.
|
|
30
56
|
#
|
|
57
|
+
# +additionalProperties+ omitted from the remote schema is accepted, but
|
|
58
|
+
# Phronomy still exposes and accepts only parameters declared in +properties+.
|
|
59
|
+
#
|
|
31
60
|
# @param server_uri [String] URI of the MCP server.
|
|
32
|
-
# Supported schemes:
|
|
33
|
-
# - "stdio://<command>" — spawn a child process
|
|
34
|
-
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
35
61
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
36
|
-
# @param headers [Hash] additional HTTP
|
|
37
|
-
#
|
|
38
|
-
# Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
|
|
39
|
-
# @return [Mcp] a configured subclass instance ready for use with an Agent
|
|
62
|
+
# @param headers [Hash] additional HTTP headers forwarded to discovery and execution
|
|
63
|
+
# @return [Mcp] configured tool instance
|
|
40
64
|
# @api public
|
|
41
65
|
def from_server(server_uri, tool_name:, headers: {})
|
|
42
|
-
|
|
43
|
-
# Each Mcp instance creates its own client so concurrent agent threads
|
|
44
|
-
# never share IO streams, eliminating the need for synchronisation.
|
|
45
|
-
transport = build_transport(server_uri, headers: headers)
|
|
46
|
-
client = MCP::Client.new(transport: transport)
|
|
66
|
+
transport = nil
|
|
47
67
|
begin
|
|
68
|
+
transport = build_transport(server_uri, headers: headers)
|
|
69
|
+
client = MCP::Client.new(transport: transport)
|
|
48
70
|
client.connect
|
|
49
71
|
tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
|
|
50
|
-
rescue ArgumentError
|
|
72
|
+
rescue ArgumentError, Phronomy::ToolError
|
|
51
73
|
raise
|
|
52
74
|
rescue => e
|
|
53
75
|
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
54
76
|
ensure
|
|
55
|
-
transport
|
|
77
|
+
close_transport_safely(transport)
|
|
56
78
|
end
|
|
79
|
+
|
|
57
80
|
build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
|
|
58
81
|
end
|
|
59
82
|
|
|
@@ -63,21 +86,38 @@ module Phronomy
|
|
|
63
86
|
scheme, path = uri.split("://", 2)
|
|
64
87
|
case scheme
|
|
65
88
|
when "stdio"
|
|
66
|
-
argv = Shellwords.split(path)
|
|
89
|
+
argv = Shellwords.split(path.to_s)
|
|
90
|
+
if argv.empty? || argv[0].to_s.empty?
|
|
91
|
+
raise ArgumentError, "MCP stdio URI must include a command"
|
|
92
|
+
end
|
|
93
|
+
|
|
67
94
|
MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
|
|
68
95
|
when "http", "https"
|
|
69
96
|
MCP::Client::HTTP.new(url: uri, headers: headers)
|
|
70
97
|
else
|
|
71
|
-
raise ArgumentError,
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"Unsupported MCP transport scheme: #{scheme.inspect}. " \
|
|
100
|
+
"Supported: 'stdio://', 'http://', 'https://'."
|
|
72
101
|
end
|
|
73
102
|
end
|
|
74
103
|
|
|
75
104
|
def extract_tool_def(client, tool_name, server_uri)
|
|
76
|
-
mcp_tool = client.tools.find { |
|
|
77
|
-
|
|
105
|
+
mcp_tool = client.tools.find { |tool| tool.name == tool_name }
|
|
106
|
+
unless mcp_tool
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"Tool #{tool_name.inspect} not found on MCP server #{server_uri.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
validate_supported_schema!(
|
|
112
|
+
mcp_tool.input_schema,
|
|
113
|
+
output_schema: mcp_tool.output_schema,
|
|
114
|
+
tool_name: mcp_tool.name
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
input_schema = mcp_tool.input_schema
|
|
118
|
+
properties = input_schema.fetch("properties", {})
|
|
119
|
+
required_names = input_schema.fetch("required", [])
|
|
78
120
|
|
|
79
|
-
properties = mcp_tool.input_schema&.dig("properties") || {}
|
|
80
|
-
required_names = mcp_tool.input_schema&.dig("required") || []
|
|
81
121
|
{
|
|
82
122
|
description: mcp_tool.description || tool_name,
|
|
83
123
|
parameters: parse_schema_params(properties, required_names: required_names)
|
|
@@ -88,62 +128,17 @@ module Phronomy
|
|
|
88
128
|
klass = Class.new(Mcp)
|
|
89
129
|
klass.tool_name(tool_name)
|
|
90
130
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
91
|
-
klass.instance_variable_set(:@mcp_headers, headers)
|
|
131
|
+
klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
|
|
92
132
|
|
|
93
|
-
# Register description and params from the MCP tool definition.
|
|
94
133
|
klass.description(tool_def[:description] || tool_name)
|
|
95
|
-
(tool_def[:parameters] || []).each do |
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# never share IO streams.
|
|
104
|
-
klass.define_method(:initialize) do
|
|
105
|
-
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
106
|
-
hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
107
|
-
transport = self.class.send(:build_transport, uri, headers: hdrs)
|
|
108
|
-
@mcp_client = MCP::Client.new(transport: transport)
|
|
109
|
-
@mcp_client.connect
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
klass.define_method(:execute) do |cancellation_token: nil, **args|
|
|
113
|
-
# Bridge Phronomy::CancellationToken to MCP::Cancellation so that
|
|
114
|
-
# explicit cancel! calls propagate into the in-flight MCP request.
|
|
115
|
-
# Deadline-based expiry is handled cooperatively by BlockingAdapterPool
|
|
116
|
-
# (the worker slot is marked abandoned); no extra handling is needed here.
|
|
117
|
-
mcp_cancel = nil
|
|
118
|
-
if cancellation_token
|
|
119
|
-
mcp_cancel = MCP::Cancellation.new
|
|
120
|
-
cancellation_token.on_cancel { mcp_cancel.cancel(reason: "phronomy_cancelled") }
|
|
121
|
-
end
|
|
122
|
-
begin
|
|
123
|
-
response = @mcp_client.call_tool(
|
|
124
|
-
name: tool_name,
|
|
125
|
-
arguments: args.transform_keys(&:to_s),
|
|
126
|
-
cancellation: mcp_cancel
|
|
127
|
-
)
|
|
128
|
-
rescue => e
|
|
129
|
-
raise Phronomy::ToolError, "MCP call failed: #{e.message}"
|
|
130
|
-
end
|
|
131
|
-
if response["error"]
|
|
132
|
-
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
133
|
-
raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
|
|
134
|
-
end
|
|
135
|
-
content = response.dig("result", "content")
|
|
136
|
-
if content.is_a?(Array)
|
|
137
|
-
texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
|
|
138
|
-
(texts.length == 1) ? texts.first : texts
|
|
139
|
-
else
|
|
140
|
-
content
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Allow callers to deterministically shut down the underlying transport.
|
|
145
|
-
klass.define_method(:close) do
|
|
146
|
-
@mcp_client.transport.close
|
|
134
|
+
(tool_def[:parameters] || []).each do |parameter|
|
|
135
|
+
options = {
|
|
136
|
+
type: parameter.fetch(:type).to_sym,
|
|
137
|
+
desc: parameter[:description].to_s,
|
|
138
|
+
required: parameter.fetch(:required, false)
|
|
139
|
+
}
|
|
140
|
+
options[:enum] = parameter[:enum] if parameter.key?(:enum)
|
|
141
|
+
klass.param(parameter.fetch(:name).to_sym, **options)
|
|
147
142
|
end
|
|
148
143
|
|
|
149
144
|
klass
|
|
@@ -151,16 +146,325 @@ module Phronomy
|
|
|
151
146
|
|
|
152
147
|
def parse_schema_params(properties, required_names: [])
|
|
153
148
|
properties.map do |name, schema|
|
|
154
|
-
|
|
149
|
+
parameter = {
|
|
155
150
|
name: name.to_s,
|
|
156
|
-
type: schema
|
|
151
|
+
type: schema.fetch("type"),
|
|
157
152
|
description: schema["description"].to_s,
|
|
158
153
|
required: required_names.include?(name.to_s)
|
|
159
154
|
}
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
parameter[:enum] = schema["enum"] if schema.key?("enum")
|
|
156
|
+
parameter
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_supported_schema!(input_schema, output_schema:, tool_name:)
|
|
161
|
+
unless input_schema.is_a?(Hash) && input_schema["type"] == "object"
|
|
162
|
+
raise Phronomy::ToolError,
|
|
163
|
+
"MCP tool #{tool_name.inspect} must use an object input schema"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
dialect = input_schema["$schema"]
|
|
167
|
+
if dialect && !SUPPORTED_SCHEMA_DIALECTS.include?(dialect)
|
|
168
|
+
raise Phronomy::ToolError,
|
|
169
|
+
"MCP tool #{tool_name.inspect} uses unsupported JSON Schema dialect #{dialect.inspect}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unknown_root = input_schema.keys - SUPPORTED_ROOT_KEYS
|
|
173
|
+
if unknown_root.any?
|
|
174
|
+
raise Phronomy::ToolError,
|
|
175
|
+
"MCP tool #{tool_name.inspect} uses unsupported root schema keywords: " \
|
|
176
|
+
"#{unknown_root.join(", ")}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
additional_properties = input_schema["additionalProperties"]
|
|
180
|
+
unless additional_properties.nil? || additional_properties == false
|
|
181
|
+
raise Phronomy::ToolError,
|
|
182
|
+
"MCP tool #{tool_name.inspect} uses additionalProperties: " \
|
|
183
|
+
"#{additional_properties.inspect} (only false or omission is supported)"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
properties = input_schema["properties"] || {}
|
|
187
|
+
unless properties.is_a?(Hash)
|
|
188
|
+
raise Phronomy::ToolError,
|
|
189
|
+
"MCP tool #{tool_name.inspect} has an invalid properties schema"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
required_names = input_schema["required"] || []
|
|
193
|
+
unless required_names.is_a?(Array) && required_names.all? { |name| name.is_a?(String) }
|
|
194
|
+
raise Phronomy::ToolError,
|
|
195
|
+
"MCP tool #{tool_name.inspect} has an invalid required list"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
unknown_required = required_names - properties.keys
|
|
199
|
+
if unknown_required.any?
|
|
200
|
+
raise Phronomy::ToolError,
|
|
201
|
+
"MCP tool #{tool_name.inspect} requires undefined parameters: " \
|
|
202
|
+
"#{unknown_required.inspect}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
properties.each do |name, schema|
|
|
206
|
+
validate_property_schema!(tool_name, name, schema)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if output_schema
|
|
210
|
+
warn_mcp(
|
|
211
|
+
"[Phronomy] MCP tool '#{tool_name}' has an output schema; " \
|
|
212
|
+
"Phronomy does not yet use it for validation"
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_property_schema!(tool_name, name, schema)
|
|
218
|
+
unless name.is_a?(String)
|
|
219
|
+
raise Phronomy::ToolError,
|
|
220
|
+
"MCP tool #{tool_name.inspect} has a non-string property key: #{name.inspect}"
|
|
221
|
+
end
|
|
222
|
+
unless schema.is_a?(Hash)
|
|
223
|
+
raise Phronomy::ToolError,
|
|
224
|
+
"MCP parameter #{name.inspect} must have an object schema"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
type = schema["type"]
|
|
228
|
+
unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
|
|
229
|
+
raise Phronomy::ToolError,
|
|
230
|
+
"MCP parameter #{name.inspect} uses unsupported type #{type.inspect}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if schema.key?("enum")
|
|
234
|
+
enum = schema["enum"]
|
|
235
|
+
unless enum.is_a?(Array)
|
|
236
|
+
raise Phronomy::ToolError,
|
|
237
|
+
"MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
|
|
238
|
+
end
|
|
239
|
+
validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
ignored = IGNORED_PROPERTY_KEYS.select { |key| schema.key?(key) }
|
|
243
|
+
if ignored.any?
|
|
244
|
+
warn_mcp(
|
|
245
|
+
"[Phronomy] MCP tool '#{tool_name}' parameter '#{name}' has " \
|
|
246
|
+
"constraint keywords #{ignored.inspect}; they will be ignored"
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
unknown_property = schema.keys - SUPPORTED_PROPERTY_KEYS - IGNORED_PROPERTY_KEYS
|
|
251
|
+
if unknown_property.any?
|
|
252
|
+
raise Phronomy::ToolError,
|
|
253
|
+
"MCP parameter #{name.inspect} uses unsupported schema keywords: " \
|
|
254
|
+
"#{unknown_property.join(", ")}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_enum_values!(type, values, tool_name:, parameter_name:)
|
|
259
|
+
valid = values.all? do |value|
|
|
260
|
+
case type
|
|
261
|
+
when "string" then value.is_a?(String)
|
|
262
|
+
when "integer" then value.is_a?(Integer)
|
|
263
|
+
when "number" then value.is_a?(Numeric)
|
|
264
|
+
when "boolean" then value == true || value == false
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
return if valid
|
|
268
|
+
|
|
269
|
+
raise Phronomy::ToolError,
|
|
270
|
+
"MCP tool #{tool_name.inspect} parameter #{parameter_name.inspect} " \
|
|
271
|
+
"has enum values incompatible with #{type}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def warn_mcp(message)
|
|
275
|
+
if Phronomy.configuration.logger
|
|
276
|
+
Phronomy.configuration.logger.warn(message)
|
|
277
|
+
else
|
|
278
|
+
Kernel.warn(message)
|
|
162
279
|
end
|
|
163
280
|
end
|
|
281
|
+
|
|
282
|
+
def close_transport_safely(transport)
|
|
283
|
+
transport&.close
|
|
284
|
+
rescue
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# @api private
|
|
290
|
+
def initialize
|
|
291
|
+
@mcp_call_mutex = Mutex.new
|
|
292
|
+
@mcp_client = nil
|
|
293
|
+
build_and_connect_client!
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Executes the remote MCP tool.
|
|
297
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
298
|
+
# @return [String, Array, Hash]
|
|
299
|
+
# @api public
|
|
300
|
+
def execute(cancellation_token: nil, **args)
|
|
301
|
+
@mcp_call_mutex.synchronize do
|
|
302
|
+
ensure_mcp_client!
|
|
303
|
+
perform_mcp_call(cancellation_token: cancellation_token, args: args)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Closes the currently connected client synchronously. A transport already
|
|
308
|
+
# detached after cancellation is owned by the Runtime cleanup pool and is
|
|
309
|
+
# drained during Runtime shutdown; this method does not wait for that older
|
|
310
|
+
# cleanup operation.
|
|
311
|
+
#
|
|
312
|
+
# The instance can be used again after close; the next call reconnects.
|
|
313
|
+
# @return [void]
|
|
314
|
+
# @api public
|
|
315
|
+
def close
|
|
316
|
+
@mcp_call_mutex.synchronize { invalidate_mcp_client! }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def perform_mcp_call(cancellation_token:, args:)
|
|
322
|
+
mcp_cancellation = build_mcp_cancellation(cancellation_token)
|
|
323
|
+
response = begin
|
|
324
|
+
@mcp_client.call_tool(
|
|
325
|
+
name: self.class.tool_name,
|
|
326
|
+
arguments: args.transform_keys(&:to_s),
|
|
327
|
+
cancellation: mcp_cancellation
|
|
328
|
+
)
|
|
329
|
+
rescue MCP::CancelledError => e
|
|
330
|
+
invalidate_mcp_client_after_cancellation!
|
|
331
|
+
message = "MCP tool call was cancelled"
|
|
332
|
+
message += ": #{e.reason}" if e.respond_to?(:reason) && e.reason
|
|
333
|
+
raise Phronomy::CancellationError, message
|
|
334
|
+
rescue MCP::Client::SessionExpiredError
|
|
335
|
+
recover_expired_session!
|
|
336
|
+
rescue MCP::Client::ServerError => e
|
|
337
|
+
raise Phronomy::ToolError,
|
|
338
|
+
"MCP server returned error (#{e.code}): #{e.message}"
|
|
339
|
+
rescue MCP::Client::InputRequiredError => e
|
|
340
|
+
raise Phronomy::ToolError,
|
|
341
|
+
"MCP tool requires unsupported multi-round-trip input: #{e.message}"
|
|
342
|
+
rescue MCP::Client::ValidationError => e
|
|
343
|
+
raise Phronomy::ToolError,
|
|
344
|
+
"MCP response validation failed: #{e.message}"
|
|
345
|
+
rescue MCP::Client::RequestHandlerError => e
|
|
346
|
+
raise Phronomy::ToolError,
|
|
347
|
+
"MCP request handler failed: #{e.message}"
|
|
348
|
+
rescue => e
|
|
349
|
+
raise Phronomy::ToolError, "MCP call failed: #{e.message}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
result = validate_call_tool_response!(response)
|
|
353
|
+
format_tool_result(result)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_mcp_cancellation(cancellation_token)
|
|
357
|
+
return nil unless cancellation_token
|
|
358
|
+
|
|
359
|
+
mcp_cancellation = MCP::Cancellation.new
|
|
360
|
+
cancellation_token.on_cancel do
|
|
361
|
+
mcp_cancellation.cancel(reason: "phronomy_cancelled")
|
|
362
|
+
end
|
|
363
|
+
mcp_cancellation
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def recover_expired_session!
|
|
367
|
+
begin
|
|
368
|
+
@mcp_client.connect
|
|
369
|
+
rescue => reconnect_error
|
|
370
|
+
invalidate_mcp_client!
|
|
371
|
+
raise Phronomy::ToolError,
|
|
372
|
+
"MCP session expired and reconnection failed: #{reconnect_error.message}"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
raise Phronomy::ToolError,
|
|
376
|
+
"MCP session expired; the connection was restored, but the tool call was not replayed"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def validate_call_tool_response!(response)
|
|
380
|
+
unless response.is_a?(Hash)
|
|
381
|
+
raise Phronomy::ToolError, "MCP tool returned a non-object response"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
result = response["result"]
|
|
385
|
+
unless result.is_a?(Hash)
|
|
386
|
+
raise Phronomy::ToolError, "MCP tool response is missing a valid result"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
content = result["content"]
|
|
390
|
+
unless content.is_a?(Array)
|
|
391
|
+
raise Phronomy::ToolError, "MCP tool result is missing valid content"
|
|
392
|
+
end
|
|
393
|
+
unless content.all? { |item| item.is_a?(Hash) }
|
|
394
|
+
raise Phronomy::ToolError,
|
|
395
|
+
"MCP tool result contains an invalid content item"
|
|
396
|
+
end
|
|
397
|
+
if result.key?("isError") && result["isError"] != true && result["isError"] != false
|
|
398
|
+
raise Phronomy::ToolError, "MCP tool result has an invalid isError value"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
result
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def format_tool_result(result)
|
|
405
|
+
content = result.fetch("content")
|
|
406
|
+
texts = content.filter_map do |item|
|
|
407
|
+
item["text"] if item["type"] == "text" && item["text"].is_a?(String)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
if result["isError"] == true
|
|
411
|
+
message = texts.join("\n")
|
|
412
|
+
message = JSON.generate(result["structuredContent"] || content) if message.empty?
|
|
413
|
+
return "MCP tool execution error: #{message}"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
return texts.first if texts.length == 1
|
|
417
|
+
return texts if texts.any?
|
|
418
|
+
|
|
419
|
+
result["structuredContent"] || content
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def ensure_mcp_client!
|
|
423
|
+
build_and_connect_client! unless @mcp_client
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def build_and_connect_client!
|
|
427
|
+
transport = nil
|
|
428
|
+
begin
|
|
429
|
+
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
430
|
+
headers = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
431
|
+
transport = self.class.send(:build_transport, uri, headers: headers)
|
|
432
|
+
client = MCP::Client.new(transport: transport)
|
|
433
|
+
client.connect
|
|
434
|
+
@mcp_client = client
|
|
435
|
+
rescue => e
|
|
436
|
+
self.class.send(:close_transport_safely, transport)
|
|
437
|
+
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def invalidate_mcp_client!
|
|
442
|
+
old_client = @mcp_client
|
|
443
|
+
@mcp_client = nil
|
|
444
|
+
self.class.send(:close_transport_safely, old_client&.transport)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def invalidate_mcp_client_after_cancellation!
|
|
448
|
+
old_client = @mcp_client
|
|
449
|
+
@mcp_client = nil
|
|
450
|
+
return unless old_client
|
|
451
|
+
|
|
452
|
+
schedule_transport_cleanup(old_client.transport)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def schedule_transport_cleanup(transport)
|
|
456
|
+
cleanup_pool = Phronomy::Runtime.instance.pool(
|
|
457
|
+
:mcp_cleanup,
|
|
458
|
+
size: MCP_CLEANUP_POOL_SIZE,
|
|
459
|
+
queue_size: MCP_CLEANUP_QUEUE_SIZE
|
|
460
|
+
)
|
|
461
|
+
cleanup_pool.submit(on_full: :raise) do
|
|
462
|
+
self.class.send(:close_transport_safely, transport)
|
|
463
|
+
end
|
|
464
|
+
rescue Phronomy::BackpressureError, Phronomy::PoolShutdownError
|
|
465
|
+
# During shutdown or an exceptional cleanup burst, prefer a bounded
|
|
466
|
+
# synchronous fallback over leaking the child process/socket.
|
|
467
|
+
self.class.send(:close_transport_safely, transport)
|
|
164
468
|
end
|
|
165
469
|
end
|
|
166
470
|
end
|
data/lib/phronomy/version.rb
CHANGED
|
@@ -76,6 +76,10 @@ module Phronomy
|
|
|
76
76
|
# waits for the async worker thread to post a state_completed event back.
|
|
77
77
|
attr_accessor :async_pending
|
|
78
78
|
|
|
79
|
+
# Invocation-local routing. FSMSession sets these on each machine
|
|
80
|
+
# instance; the compiled Class captures no Runtime-specific object.
|
|
81
|
+
attr_accessor :event_loop, :timer_queue_provider, :session_id
|
|
82
|
+
|
|
79
83
|
state_machine :phase, initial: entry do
|
|
80
84
|
all_states.each { |s| state s }
|
|
81
85
|
|
|
@@ -190,16 +194,16 @@ module Phronomy
|
|
|
190
194
|
# @api private
|
|
191
195
|
def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
192
196
|
machine.async_pending = true
|
|
193
|
-
|
|
197
|
+
session_id = machine.session_id || machine.context.thread_id
|
|
194
198
|
if timeout_secs
|
|
195
|
-
|
|
199
|
+
machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
|
|
196
200
|
next if result.done?
|
|
197
201
|
|
|
198
|
-
|
|
202
|
+
machine.event_loop.post(
|
|
199
203
|
Phronomy::Event.new(
|
|
200
204
|
type: :error,
|
|
201
205
|
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
|
|
202
|
-
payload: {session_id:
|
|
206
|
+
payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
|
|
203
207
|
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
204
208
|
)}
|
|
205
209
|
)
|
|
@@ -208,17 +212,17 @@ module Phronomy
|
|
|
208
212
|
end
|
|
209
213
|
result.on_complete do |task_result, error|
|
|
210
214
|
if error
|
|
211
|
-
|
|
212
|
-
Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id:
|
|
215
|
+
machine.event_loop.post(
|
|
216
|
+
Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
|
|
213
217
|
)
|
|
214
218
|
next
|
|
215
219
|
end
|
|
216
220
|
ev = if task_result.is_a?(Phronomy::WorkflowContext)
|
|
217
|
-
Phronomy::Event.new(type: :action_completed, target_id:
|
|
221
|
+
Phronomy::Event.new(type: :action_completed, target_id: session_id, payload: task_result)
|
|
218
222
|
else
|
|
219
|
-
Phronomy::Event.new(type: :state_completed, target_id:
|
|
223
|
+
Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
|
|
220
224
|
end
|
|
221
|
-
|
|
225
|
+
machine.event_loop.post(ev)
|
|
222
226
|
end
|
|
223
227
|
end
|
|
224
228
|
|
|
@@ -167,10 +167,9 @@ module Phronomy
|
|
|
167
167
|
# @api private
|
|
168
168
|
# mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
|
|
169
169
|
def _assert_write_permitted!
|
|
170
|
-
return unless defined?(Phronomy::EventLoop)
|
|
171
170
|
# Allow mutations when executing synchronously (e.g. Workflow#stream via run_workflow).
|
|
172
171
|
return if Thread.current[:phronomy_sync_execution]
|
|
173
|
-
return if Phronomy::
|
|
172
|
+
return if Phronomy::Runtime.in_event_loop_context?
|
|
174
173
|
|
|
175
174
|
raise Phronomy::WorkflowContextOwnershipError,
|
|
176
175
|
"WorkflowContext fields may only be mutated from the EventLoop dispatch " \
|