phronomy 0.13.0 → 0.15.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -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 (mcp gem) for transport handling, which provides
13
- # built-in support for the MCP initialize handshake, size limits, and SSE.
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 request headers forwarded to every
37
- # request (tool discovery and tool execution). Ignored for stdio transports.
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
- # Use a short-lived client only to discover the tool definition, then close.
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.close
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, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
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 { |t| t.name == tool_name }
77
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{server_uri.inspect}" unless mcp_tool
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)
@@ -87,63 +127,19 @@ module Phronomy
87
127
  def build_tool_class(tool_name, server_uri, tool_def, headers: {})
88
128
  klass = Class.new(Mcp)
89
129
  klass.tool_name(tool_name)
130
+ klass.requires_approval true
90
131
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
91
- klass.instance_variable_set(:@mcp_headers, headers)
132
+ klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
92
133
 
93
- # Register description and params from the MCP tool definition.
94
134
  klass.description(tool_def[:description] || tool_name)
95
- (tool_def[:parameters] || []).each do |p|
96
- opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
97
- opts[:required] = p[:required] if p.key?(:required)
98
- opts[:enum] = p[:enum] if p.key?(:enum)
99
- klass.param(p[:name].to_sym, **opts)
100
- end
101
-
102
- # Each instance creates its own MCP client so concurrent agent threads
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
135
+ (tool_def[:parameters] || []).each do |parameter|
136
+ options = {
137
+ type: parameter.fetch(:type).to_sym,
138
+ desc: parameter[:description].to_s,
139
+ required: parameter.fetch(:required, false)
140
+ }
141
+ options[:enum] = parameter[:enum] if parameter.key?(:enum)
142
+ klass.param(parameter.fetch(:name).to_sym, **options)
147
143
  end
148
144
 
149
145
  klass
@@ -151,16 +147,349 @@ module Phronomy
151
147
 
152
148
  def parse_schema_params(properties, required_names: [])
153
149
  properties.map do |name, schema|
154
- param = {
150
+ parameter = {
155
151
  name: name.to_s,
156
- type: schema["type"] || "string",
152
+ type: schema.fetch("type"),
157
153
  description: schema["description"].to_s,
158
154
  required: required_names.include?(name.to_s)
159
155
  }
160
- param[:enum] = schema["enum"] if schema["enum"]
161
- param
156
+ parameter[:enum] = schema["enum"] if schema.key?("enum")
157
+ parameter
158
+ end
159
+ end
160
+
161
+ def validate_supported_schema!(input_schema, output_schema:, tool_name:)
162
+ unless input_schema.is_a?(Hash) && input_schema["type"] == "object"
163
+ raise Phronomy::ToolError,
164
+ "MCP tool #{tool_name.inspect} must use an object input schema"
165
+ end
166
+
167
+ dialect = input_schema["$schema"]
168
+ if dialect && !SUPPORTED_SCHEMA_DIALECTS.include?(dialect)
169
+ raise Phronomy::ToolError,
170
+ "MCP tool #{tool_name.inspect} uses unsupported JSON Schema dialect #{dialect.inspect}"
171
+ end
172
+
173
+ unknown_root = input_schema.keys - SUPPORTED_ROOT_KEYS
174
+ if unknown_root.any?
175
+ raise Phronomy::ToolError,
176
+ "MCP tool #{tool_name.inspect} uses unsupported root schema keywords: " \
177
+ "#{unknown_root.join(", ")}"
178
+ end
179
+
180
+ additional_properties = input_schema["additionalProperties"]
181
+ unless additional_properties.nil? || additional_properties == false
182
+ raise Phronomy::ToolError,
183
+ "MCP tool #{tool_name.inspect} uses additionalProperties: " \
184
+ "#{additional_properties.inspect} (only false or omission is supported)"
185
+ end
186
+
187
+ properties = input_schema["properties"] || {}
188
+ unless properties.is_a?(Hash)
189
+ raise Phronomy::ToolError,
190
+ "MCP tool #{tool_name.inspect} has an invalid properties schema"
191
+ end
192
+
193
+ required_names = input_schema["required"] || []
194
+ unless required_names.is_a?(Array) && required_names.all? { |name| name.is_a?(String) }
195
+ raise Phronomy::ToolError,
196
+ "MCP tool #{tool_name.inspect} has an invalid required list"
197
+ end
198
+
199
+ unknown_required = required_names - properties.keys
200
+ if unknown_required.any?
201
+ raise Phronomy::ToolError,
202
+ "MCP tool #{tool_name.inspect} requires undefined parameters: " \
203
+ "#{unknown_required.inspect}"
204
+ end
205
+
206
+ properties.each do |name, schema|
207
+ validate_property_schema!(tool_name, name, schema)
208
+ end
209
+
210
+ if output_schema
211
+ warn_mcp(
212
+ "[Phronomy] MCP tool '#{tool_name}' has an output schema; " \
213
+ "Phronomy does not yet use it for validation"
214
+ )
215
+ end
216
+ end
217
+
218
+ def validate_property_schema!(tool_name, name, schema)
219
+ unless name.is_a?(String)
220
+ raise Phronomy::ToolError,
221
+ "MCP tool #{tool_name.inspect} has a non-string property key: #{name.inspect}"
222
+ end
223
+ unless schema.is_a?(Hash)
224
+ raise Phronomy::ToolError,
225
+ "MCP parameter #{name.inspect} must have an object schema"
226
+ end
227
+
228
+ type = schema["type"]
229
+ unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
230
+ raise Phronomy::ToolError,
231
+ "MCP parameter #{name.inspect} uses unsupported type #{type.inspect}"
232
+ end
233
+
234
+ if schema.key?("enum")
235
+ enum = schema["enum"]
236
+ unless enum.is_a?(Array)
237
+ raise Phronomy::ToolError,
238
+ "MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
239
+ end
240
+ validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
241
+ end
242
+
243
+ ignored = IGNORED_PROPERTY_KEYS.select { |key| schema.key?(key) }
244
+ if ignored.any?
245
+ warn_mcp(
246
+ "[Phronomy] MCP tool '#{tool_name}' parameter '#{name}' has " \
247
+ "constraint keywords #{ignored.inspect}; they will be ignored"
248
+ )
249
+ end
250
+
251
+ unknown_property = schema.keys - SUPPORTED_PROPERTY_KEYS - IGNORED_PROPERTY_KEYS
252
+ if unknown_property.any?
253
+ raise Phronomy::ToolError,
254
+ "MCP parameter #{name.inspect} uses unsupported schema keywords: " \
255
+ "#{unknown_property.join(", ")}"
256
+ end
257
+ end
258
+
259
+ def validate_enum_values!(type, values, tool_name:, parameter_name:)
260
+ valid = values.all? do |value|
261
+ case type
262
+ when "string" then value.is_a?(String)
263
+ when "integer" then value.is_a?(Integer)
264
+ when "number" then value.is_a?(Numeric)
265
+ when "boolean" then value == true || value == false
266
+ end
267
+ end
268
+ return if valid
269
+
270
+ raise Phronomy::ToolError,
271
+ "MCP tool #{tool_name.inspect} parameter #{parameter_name.inspect} " \
272
+ "has enum values incompatible with #{type}"
273
+ end
274
+
275
+ def warn_mcp(message)
276
+ if Phronomy.configuration.logger
277
+ Phronomy.configuration.logger.warn(message)
278
+ else
279
+ Kernel.warn(message)
162
280
  end
163
281
  end
282
+
283
+ def close_transport_safely(transport)
284
+ transport&.close
285
+ rescue
286
+ nil
287
+ end
288
+ end
289
+
290
+ # MCP Tools fail closed by default and identify their remote origin.
291
+ def tool_origin
292
+ :mcp
293
+ end
294
+
295
+ def approval_metadata
296
+ scheme, rest = self.class.instance_variable_get(:@mcp_server_uri).to_s.split("://", 2)
297
+ server_origin = case scheme
298
+ when "http", "https"
299
+ uri = URI.parse("#{scheme}://#{rest}")
300
+ default_port = (scheme == "https") ? 443 : 80
301
+ suffix = (uri.port == default_port) ? "" : ":#{uri.port}"
302
+ "#{scheme}://#{uri.host}#{suffix}"
303
+ when "stdio"
304
+ command = Shellwords.split(rest.to_s).first
305
+ "stdio://#{command}"
306
+ else
307
+ scheme.to_s
308
+ end
309
+ {transport: scheme&.to_sym, server_origin: server_origin}
310
+ rescue URI::InvalidURIError
311
+ {transport: :unknown, server_origin: "unknown"}
312
+ end
313
+
314
+ # @api private
315
+ def initialize
316
+ @mcp_call_mutex = Mutex.new
317
+ @mcp_client = nil
318
+ build_and_connect_client!
319
+ end
320
+
321
+ # Executes the remote MCP tool.
322
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
323
+ # @return [String, Array, Hash]
324
+ # @api public
325
+ def execute(cancellation_token: nil, **args)
326
+ @mcp_call_mutex.synchronize do
327
+ ensure_mcp_client!
328
+ perform_mcp_call(cancellation_token: cancellation_token, args: args)
329
+ end
330
+ end
331
+
332
+ # Closes the currently connected client synchronously. A transport already
333
+ # detached after cancellation is owned by the Runtime cleanup pool and is
334
+ # drained during Runtime shutdown; this method does not wait for that older
335
+ # cleanup operation.
336
+ #
337
+ # The instance can be used again after close; the next call reconnects.
338
+ # @return [void]
339
+ # @api public
340
+ def close
341
+ @mcp_call_mutex.synchronize { invalidate_mcp_client! }
342
+ end
343
+
344
+ private
345
+
346
+ def perform_mcp_call(cancellation_token:, args:)
347
+ mcp_cancellation = build_mcp_cancellation(cancellation_token)
348
+ response = begin
349
+ @mcp_client.call_tool(
350
+ name: self.class.tool_name,
351
+ arguments: args.transform_keys(&:to_s),
352
+ cancellation: mcp_cancellation
353
+ )
354
+ rescue MCP::CancelledError => e
355
+ invalidate_mcp_client_after_cancellation!
356
+ message = "MCP tool call was cancelled"
357
+ message += ": #{e.reason}" if e.respond_to?(:reason) && e.reason
358
+ raise Phronomy::CancellationError, message
359
+ rescue MCP::Client::SessionExpiredError
360
+ recover_expired_session!
361
+ rescue MCP::Client::ServerError => e
362
+ raise Phronomy::ToolError,
363
+ "MCP server returned error (#{e.code}): #{e.message}"
364
+ rescue MCP::Client::InputRequiredError => e
365
+ raise Phronomy::ToolError,
366
+ "MCP tool requires unsupported multi-round-trip input: #{e.message}"
367
+ rescue MCP::Client::ValidationError => e
368
+ raise Phronomy::ToolError,
369
+ "MCP response validation failed: #{e.message}"
370
+ rescue MCP::Client::RequestHandlerError => e
371
+ raise Phronomy::ToolError,
372
+ "MCP request handler failed: #{e.message}"
373
+ rescue => e
374
+ raise Phronomy::ToolError, "MCP call failed: #{e.message}"
375
+ end
376
+
377
+ result = validate_call_tool_response!(response)
378
+ format_tool_result(result)
379
+ end
380
+
381
+ def build_mcp_cancellation(cancellation_token)
382
+ return nil unless cancellation_token
383
+
384
+ mcp_cancellation = MCP::Cancellation.new
385
+ cancellation_token.on_cancel do
386
+ mcp_cancellation.cancel(reason: "phronomy_cancelled")
387
+ end
388
+ mcp_cancellation
389
+ end
390
+
391
+ def recover_expired_session!
392
+ begin
393
+ @mcp_client.connect
394
+ rescue => reconnect_error
395
+ invalidate_mcp_client!
396
+ raise Phronomy::ToolError,
397
+ "MCP session expired and reconnection failed: #{reconnect_error.message}"
398
+ end
399
+
400
+ raise Phronomy::ToolError,
401
+ "MCP session expired; the connection was restored, but the tool call was not replayed"
402
+ end
403
+
404
+ def validate_call_tool_response!(response)
405
+ unless response.is_a?(Hash)
406
+ raise Phronomy::ToolError, "MCP tool returned a non-object response"
407
+ end
408
+
409
+ result = response["result"]
410
+ unless result.is_a?(Hash)
411
+ raise Phronomy::ToolError, "MCP tool response is missing a valid result"
412
+ end
413
+
414
+ content = result["content"]
415
+ unless content.is_a?(Array)
416
+ raise Phronomy::ToolError, "MCP tool result is missing valid content"
417
+ end
418
+ unless content.all? { |item| item.is_a?(Hash) }
419
+ raise Phronomy::ToolError,
420
+ "MCP tool result contains an invalid content item"
421
+ end
422
+ if result.key?("isError") && result["isError"] != true && result["isError"] != false
423
+ raise Phronomy::ToolError, "MCP tool result has an invalid isError value"
424
+ end
425
+
426
+ result
427
+ end
428
+
429
+ def format_tool_result(result)
430
+ content = result.fetch("content")
431
+ texts = content.filter_map do |item|
432
+ item["text"] if item["type"] == "text" && item["text"].is_a?(String)
433
+ end
434
+
435
+ if result["isError"] == true
436
+ message = texts.join("\n")
437
+ message = JSON.generate(result["structuredContent"] || content) if message.empty?
438
+ return "MCP tool execution error: #{message}"
439
+ end
440
+
441
+ return texts.first if texts.length == 1
442
+ return texts if texts.any?
443
+
444
+ result["structuredContent"] || content
445
+ end
446
+
447
+ def ensure_mcp_client!
448
+ build_and_connect_client! unless @mcp_client
449
+ end
450
+
451
+ def build_and_connect_client!
452
+ transport = nil
453
+ begin
454
+ uri = self.class.instance_variable_get(:@mcp_server_uri)
455
+ headers = self.class.instance_variable_get(:@mcp_headers) || {}
456
+ transport = self.class.send(:build_transport, uri, headers: headers)
457
+ client = MCP::Client.new(transport: transport)
458
+ client.connect
459
+ @mcp_client = client
460
+ rescue => e
461
+ self.class.send(:close_transport_safely, transport)
462
+ raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
463
+ end
464
+ end
465
+
466
+ def invalidate_mcp_client!
467
+ old_client = @mcp_client
468
+ @mcp_client = nil
469
+ self.class.send(:close_transport_safely, old_client&.transport)
470
+ end
471
+
472
+ def invalidate_mcp_client_after_cancellation!
473
+ old_client = @mcp_client
474
+ @mcp_client = nil
475
+ return unless old_client
476
+
477
+ schedule_transport_cleanup(old_client.transport)
478
+ end
479
+
480
+ def schedule_transport_cleanup(transport)
481
+ cleanup_pool = Phronomy::Runtime.instance.pool(
482
+ :mcp_cleanup,
483
+ size: MCP_CLEANUP_POOL_SIZE,
484
+ queue_size: MCP_CLEANUP_QUEUE_SIZE
485
+ )
486
+ cleanup_pool.submit(on_full: :raise) do
487
+ self.class.send(:close_transport_safely, transport)
488
+ end
489
+ rescue Phronomy::BackpressureError, Phronomy::PoolShutdownError
490
+ # During shutdown or an exceptional cleanup burst, prefer a bounded
491
+ # synchronous fallback over leaking the child process/socket.
492
+ self.class.send(:close_transport_safely, transport)
164
493
  end
165
494
  end
166
495
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.13.0"
4
+ VERSION = "0.15.0"
5
5
  end