phronomy 0.12.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -0
  3. data/README.md +54 -5
  4. data/docs/mcp-client.md +75 -0
  5. data/gemfiles/mcp_1_0.gemfile +9 -0
  6. data/lib/phronomy/agent/base.rb +17 -17
  7. data/lib/phronomy/agent/context/capability/base.rb +9 -2
  8. data/lib/phronomy/agent/invocation_session.rb +11 -5
  9. data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
  10. data/lib/phronomy/agent/tool_executor.rb +9 -2
  11. data/lib/phronomy/configuration.rb +15 -46
  12. data/lib/phronomy/diagnostics.rb +1 -1
  13. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  14. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  15. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  16. data/lib/phronomy/engine/event_loop.rb +319 -272
  17. data/lib/phronomy/engine/fsm_session.rb +17 -14
  18. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  19. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  20. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  21. data/lib/phronomy/engine/runtime.rb +247 -57
  22. data/lib/phronomy/metrics.rb +4 -3
  23. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  24. data/lib/phronomy/tools/mcp.rb +361 -321
  25. data/lib/phronomy/version.rb +1 -1
  26. data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
  27. data/lib/phronomy/workflow_context.rb +1 -2
  28. data/lib/phronomy/workflow_runner.rb +22 -12
  29. data/lib/phronomy.rb +24 -19
  30. metadata +59 -4
  31. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  32. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "net/http"
5
- require "open3"
6
- require "securerandom"
4
+ require "mcp"
7
5
  require "shellwords"
8
6
  require "uri"
9
7
 
@@ -12,44 +10,73 @@ module Phronomy
12
10
  # A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
13
11
  # MCP (Model Context Protocol) server.
14
12
  #
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.
15
+ #
15
16
  # Supports two transport schemes:
16
- # - <b>"stdio://\<command\>"</b> — spawns a child process that communicates via
17
- # newline-delimited JSON-RPC on stdin/stdout.
18
- # - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects to a running
19
- # HTTP/SSE MCP server using +net/http+.
17
+ # - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
18
+ # - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
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.
20
23
  #
21
24
  # @example
22
25
  # web_search = Phronomy::Tools::Mcp.from_server(
23
26
  # "stdio://./mcp-server",
24
27
  # tool_name: "search_web"
25
28
  # )
26
- # agent = MyAgent.new
27
29
  # agent_class.tools(web_search)
28
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
+
29
53
  class << self
30
54
  # Build a Mcp instance by querying a running MCP server for the
31
55
  # tool definition identified by +tool_name+.
32
56
  #
57
+ # +additionalProperties+ omitted from the remote schema is accepted, but
58
+ # Phronomy still exposes and accepts only parameters declared in +properties+.
59
+ #
33
60
  # @param server_uri [String] URI of the MCP server.
34
- # Supported schemes:
35
- # - "stdio://<command>" — spawn a child process
36
- # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
61
  # @param tool_name [String] the tool name as registered in the MCP server
38
- # @param headers [Hash] additional HTTP request headers forwarded to every
39
- # request (tool discovery and tool execution). Ignored for stdio transports.
40
- # Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
41
- # @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
42
64
  # @api public
43
65
  def from_server(server_uri, tool_name:, headers: {})
44
- # Use a short-lived transport only to query the tool definition,
45
- # then close it. Each Mcp instance creates its own transport
46
- # so that concurrent callers never share IO streams.
47
- transport = build_transport(server_uri, headers: headers)
66
+ transport = nil
48
67
  begin
49
- tool_def = transport.fetch_tool(tool_name)
68
+ transport = build_transport(server_uri, headers: headers)
69
+ client = MCP::Client.new(transport: transport)
70
+ client.connect
71
+ tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
72
+ rescue ArgumentError, Phronomy::ToolError
73
+ raise
74
+ rescue => e
75
+ raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
50
76
  ensure
51
- transport.close
77
+ close_transport_safely(transport)
52
78
  end
79
+
53
80
  build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
54
81
  end
55
82
 
@@ -59,372 +86,385 @@ module Phronomy
59
86
  scheme, path = uri.split("://", 2)
60
87
  case scheme
61
88
  when "stdio"
62
- StdioTransport.new(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
+
94
+ MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
63
95
  when "http", "https"
64
- HttpTransport.new(uri, headers: headers)
96
+ MCP::Client::HTTP.new(url: uri, headers: headers)
65
97
  else
66
- 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://'."
101
+ end
102
+ end
103
+
104
+ def extract_tool_def(client, tool_name, server_uri)
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}"
67
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", [])
120
+
121
+ {
122
+ description: mcp_tool.description || tool_name,
123
+ parameters: parse_schema_params(properties, required_names: required_names)
124
+ }
68
125
  end
69
126
 
70
127
  def build_tool_class(tool_name, server_uri, tool_def, headers: {})
71
128
  klass = Class.new(Mcp)
72
129
  klass.tool_name(tool_name)
73
130
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
74
- klass.instance_variable_set(:@mcp_headers, headers)
131
+ klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
75
132
 
76
- # Register description and params from the MCP tool definition.
77
133
  klass.description(tool_def[:description] || tool_name)
78
- (tool_def[:parameters] || []).each do |p|
79
- opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
80
- opts[:required] = p[:required] if p.key?(:required)
81
- opts[:enum] = p[:enum] if p.key?(:enum)
82
- klass.param(p[:name].to_sym, **opts)
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)
83
142
  end
84
143
 
85
- # Each instance creates its own transport so concurrent agent threads
86
- # never share IO streams, eliminating the need for synchronisation.
87
- klass.define_method(:initialize) do
88
- uri = self.class.instance_variable_get(:@mcp_server_uri)
89
- hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
90
- @mcp_transport = self.class.send(:build_transport, uri, headers: hdrs)
144
+ klass
145
+ end
146
+
147
+ def parse_schema_params(properties, required_names: [])
148
+ properties.map do |name, schema|
149
+ parameter = {
150
+ name: name.to_s,
151
+ type: schema.fetch("type"),
152
+ description: schema["description"].to_s,
153
+ required: required_names.include?(name.to_s)
154
+ }
155
+ parameter[:enum] = schema["enum"] if schema.key?("enum")
156
+ parameter
91
157
  end
158
+ end
92
159
 
93
- klass.define_method(:execute) do |**args|
94
- @mcp_transport.call_tool(tool_name, args)
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"
95
164
  end
96
165
 
97
- # Allow callers to deterministically shut down the underlying child
98
- # process (stdio) or release the HTTP connection. For HttpTransport
99
- # this is a no-op. After close, calling execute will reopen the
100
- # transport automatically (stdio restarts the child process; HTTP
101
- # opens a fresh connection per call).
102
- klass.define_method(:close) do
103
- @mcp_transport.close
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}"
104
170
  end
105
171
 
106
- klass
107
- end
108
- end
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
109
178
 
110
- # -----------------------------------------------------------------------
111
- # Transports
112
- # -----------------------------------------------------------------------
113
-
114
- # Minimal stdio transport implementing a subset of the MCP JSON-RPC protocol.
115
- # Keeps the child process alive for the lifetime of this transport instance
116
- # so that session state (registered resources, tool context, etc.) is preserved
117
- # across multiple calls.
118
- class StdioTransport
119
- # @param command [String] shell command to spawn the MCP server process
120
- # @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
121
- # before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
122
- # {HttpTransport}. Defaults to 30 seconds.
123
- # @param env [Hash, nil] environment variable overrides for the subprocess.
124
- # When provided, only these variables are added/overridden; the parent environment
125
- # is still inherited. Use +nil+ as a value to unset a variable in the child process
126
- # (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
127
- # an empty string — it does NOT unset it.
128
- # @param cwd [String, nil] working directory for the subprocess.
129
- # Defaults to the current process's working directory.
130
- # @param startup_timeout [Numeric, nil] seconds to wait for the server to
131
- # emit its first line on stdout before raising {Phronomy::ToolError}.
132
- # When nil (default), no startup check is performed.
133
- # @api public
134
- def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
135
- # Split the command string into an argv array so that Open3 executes
136
- # it directly without going through the shell, preventing injection.
137
- @command = Shellwords.split(command)
138
- @read_timeout = read_timeout
139
- @env = env
140
- @cwd = cwd
141
- @startup_timeout = startup_timeout
142
- @stdin = nil
143
- @stdout = nil
144
- @stderr = nil
145
- @wait_thr = nil
146
- @stderr_thread = nil
147
- @stderr_op = nil
148
- end
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
149
185
 
150
- # Shut down the child process and close its IO streams.
151
- def close
152
- @stdin&.close
153
- @stdout&.close
154
- @stderr&.close
155
- @stdin = nil
156
- @stdout = nil
157
- @stderr = nil
158
- stderr_thread = @stderr_thread
159
- stderr_op = @stderr_op
160
- wait_thr = @wait_thr
161
- @stderr_thread = nil
162
- @stderr_op = nil
163
- @wait_thr = nil
164
- stderr_thread&.join(1)
165
- begin
166
- stderr_op&.blocking_wait(timeout: 1.0)
167
- rescue
168
- nil
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"
169
190
  end
170
- wait_thr&.join(5)
171
- end
172
191
 
173
- # Retrieve the tool definition from the server using the MCP `tools/list` method.
174
- # @param tool_name [String]
175
- # @return [Hash] { description:, parameters: }
176
- # @api public
177
- def fetch_tool(tool_name)
178
- response = rpc_call("tools/list", {})
179
- tools = response.dig("result", "tools") || []
180
- defn = tools.find { |t| t["name"] == tool_name }
181
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
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
182
197
 
183
- required_names = defn.dig("inputSchema", "required") || []
184
- {
185
- description: defn["description"],
186
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
187
- }
188
- end
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
189
204
 
190
- # Call a tool on the MCP server using the `tools/call` method.
191
- # @param tool_name [String]
192
- # @param args [Hash]
193
- # @return [Object] the tool result
194
- # @api public
195
- def call_tool(tool_name, args)
196
- response = rpc_call("tools/call", {name: tool_name, arguments: args})
197
- if response["error"]
198
- err_msg = response.dig("error", "message") || response["error"].to_s
199
- raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
205
+ properties.each do |name, schema|
206
+ validate_property_schema!(tool_name, name, schema)
200
207
  end
201
- content = response.dig("result", "content")
202
208
 
203
- # MCP content is an array of content blocks; extract text blocks.
204
- if content.is_a?(Array)
205
- texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
206
- (texts.length == 1) ? texts.first : texts
207
- else
208
- content
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
+ )
209
214
  end
210
215
  end
211
216
 
212
- private
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
213
226
 
214
- # Ensure the child process is running, spawning it if necessary.
215
- def ensure_started!
216
- return if @stdin && !@stdin.closed?
217
-
218
- popen3_opts = {}
219
- popen3_opts[:chdir] = @cwd if @cwd
220
-
221
- argv = @env ? [@env, *@command] : @command
222
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
223
- # Drain stderr asynchronously to prevent the pipe buffer from filling
224
- # and deadlocking the child process. Errors inside the drain thread are
225
- # silently ignored since stderr content is diagnostics-only.
226
- #
227
- # Prefer BlockingAdapterPool when a Runtime is configured so that this
228
- # file eventually needs no direct Thread.new (Issue #360). Fall back to
229
- # Thread.new when no pool is available (no EventLoop / bare invocation).
230
- pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
231
- if pool
232
- @stderr_op = pool.submit {
233
- begin
234
- @stderr.read
235
- rescue
236
- nil
237
- end
238
- }
239
- @stderr_thread = nil
240
- else
241
- @stderr_thread = Thread.new {
242
- begin
243
- @stderr.read
244
- rescue
245
- nil
246
- end
247
- }
248
- @stderr_op = nil
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}"
249
231
  end
250
232
 
251
- if @startup_timeout
252
- unless IO.select([@stdout], nil, nil, @startup_timeout)
253
- close
233
+ if schema.key?("enum")
234
+ enum = schema["enum"]
235
+ unless enum.is_a?(Array)
254
236
  raise Phronomy::ToolError,
255
- "MCP stdio server did not start within #{@startup_timeout} seconds"
237
+ "MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
256
238
  end
257
- # Do NOT call @stdout.gets here: gets() blocks until a newline arrives,
258
- # which hangs indefinitely when the server emits no startup line or a
259
- # partial line without '\n'. IO.select already confirmed the server is
260
- # alive and responsive; the first rpc_call will consume actual output.
239
+ validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
261
240
  end
262
- end
263
241
 
264
- def rpc_call(method, params)
265
- ensure_started!
266
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
267
- @stdin.puts(payload)
268
- unless IO.select([@stdout], nil, nil, @read_timeout)
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?
269
252
  raise Phronomy::ToolError,
270
- "MCP stdio server did not respond within #{@read_timeout} seconds"
253
+ "MCP parameter #{name.inspect} uses unsupported schema keywords: " \
254
+ "#{unknown_property.join(", ")}"
271
255
  end
272
- raw = @stdout.gets
273
- raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
274
- JSON.parse(raw)
275
256
  end
276
257
 
277
- def parse_schema_params(properties, required_names: [])
278
- properties.map do |name, schema|
279
- param = {
280
- name: name.to_s,
281
- type: schema["type"] || "string",
282
- description: schema["description"].to_s,
283
- required: required_names.include?(name.to_s)
284
- }
285
- param[:enum] = schema["enum"] if schema["enum"]
286
- param
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
287
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}"
288
272
  end
289
- end
290
273
 
291
- # HTTP/HTTPS transport implementing JSON-RPC over HTTP with SSE support.
292
- #
293
- # Sends JSON-RPC POST requests to the MCP server endpoint.
294
- # Accepts both plain JSON responses (Content-Type: application/json) and
295
- # Server-Sent Events streams (Content-Type: text/event-stream), covering
296
- # both the 2024-11-05 and 2025-03-26 MCP HTTP transport specifications.
297
- #
298
- # @example
299
- # tool = Phronomy::Tools::Mcp.from_server(
300
- # "http://localhost:8080/mcp",
301
- # tool_name: "weather_lookup"
302
- # )
303
- class HttpTransport
304
- # @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
305
- # @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
306
- # @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
307
- # @param headers [Hash] additional HTTP request headers (e.g. Authorization).
308
- # Merged on top of the default Content-Type and Accept headers; caller-supplied
309
- # values override defaults when keys collide.
310
- # @api public
311
- def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
312
- @uri = URI.parse(base_url)
313
- @open_timeout = open_timeout
314
- @read_timeout = read_timeout
315
- @extra_headers = headers
274
+ def warn_mcp(message)
275
+ if Phronomy.configuration.logger
276
+ Phronomy.configuration.logger.warn(message)
277
+ else
278
+ Kernel.warn(message)
279
+ end
316
280
  end
317
281
 
318
- # HTTP connections are stateless; close is a no-op, defined so that
319
- # both transport classes share the same interface as StdioTransport.
320
- def close
282
+ def close_transport_safely(transport)
283
+ transport&.close
284
+ rescue
285
+ nil
321
286
  end
287
+ end
322
288
 
323
- # Retrieve the tool definition from the server using MCP `tools/list`.
324
- # @param tool_name [String]
325
- # @return [Hash] { description:, parameters: }
326
- # @api public
327
- def fetch_tool(tool_name)
328
- response = rpc_call("tools/list", {})
329
- tools = response.dig("result", "tools") || []
330
- defn = tools.find { |t| t["name"] == tool_name }
331
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
289
+ # @api private
290
+ def initialize
291
+ @mcp_call_mutex = Mutex.new
292
+ @mcp_client = nil
293
+ build_and_connect_client!
294
+ end
332
295
 
333
- required_names = defn.dig("inputSchema", "required") || []
334
- {
335
- description: defn["description"],
336
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
337
- }
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)
338
304
  end
305
+ end
339
306
 
340
- # Call a tool on the MCP server using MCP `tools/call`.
341
- # @param tool_name [String]
342
- # @param args [Hash]
343
- # @return [Object] the tool result
344
- # @api public
345
- def call_tool(tool_name, args)
346
- response = rpc_call("tools/call", {name: tool_name, arguments: args})
347
- if response["error"]
348
- err_msg = response.dig("error", "message") || response["error"].to_s
349
- raise Phronomy::ToolError, "MCP HTTP server returned error: #{err_msg}"
350
- end
351
- content = response.dig("result", "content")
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
352
318
 
353
- if content.is_a?(Array)
354
- texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
355
- (texts.length == 1) ? texts.first : texts
356
- else
357
- content
358
- end
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}"
359
350
  end
360
351
 
361
- private
352
+ result = validate_call_tool_response!(response)
353
+ format_tool_result(result)
354
+ end
362
355
 
363
- def rpc_call(method, params)
364
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
356
+ def build_mcp_cancellation(cancellation_token)
357
+ return nil unless cancellation_token
365
358
 
366
- http = Net::HTTP.new(@uri.host, @uri.port)
367
- http.use_ssl = (@uri.scheme == "https")
368
- http.open_timeout = @open_timeout
369
- http.read_timeout = @read_timeout
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
370
365
 
371
- path = @uri.path.empty? ? "/" : @uri.path
372
- path = "#{path}?#{@uri.query}" if @uri.query
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
373
374
 
374
- request = Net::HTTP::Post.new(path)
375
- request["Content-Type"] = "application/json"
376
- request["Accept"] = "application/json, text/event-stream"
377
- @extra_headers.each { |k, v| request[k.to_s] = v.to_s }
378
- request.body = payload
375
+ raise Phronomy::ToolError,
376
+ "MCP session expired; the connection was restored, but the tool call was not replayed"
377
+ end
379
378
 
380
- http_response = http.request(request)
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
381
383
 
382
- unless http_response.is_a?(Net::HTTPSuccess)
383
- raise Phronomy::ToolError,
384
- "MCP HTTP server returned #{http_response.code}: #{http_response.body}"
385
- end
384
+ result = response["result"]
385
+ unless result.is_a?(Hash)
386
+ raise Phronomy::ToolError, "MCP tool response is missing a valid result"
387
+ end
386
388
 
387
- content_type = http_response["Content-Type"] || ""
388
- if content_type.include?("text/event-stream")
389
- parse_sse_response(http_response.body)
390
- else
391
- JSON.parse(http_response.body)
392
- end
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"
393
399
  end
394
400
 
395
- # Parse an SSE response body and extract the last JSON-RPC message.
396
- # SSE lines are in the format "data: <json>".
397
- def parse_sse_response(body)
398
- result = nil
399
- body.each_line do |line|
400
- line = line.strip
401
- next unless line.start_with?("data: ")
402
-
403
- data = line.delete_prefix("data: ")
404
- next if data == "[DONE]"
405
-
406
- begin
407
- parsed = JSON.parse(data)
408
- result = parsed if parsed.is_a?(Hash) && parsed["jsonrpc"]
409
- rescue JSON::ParserError
410
- next
411
- end
412
- end
413
- result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
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)
414
408
  end
415
409
 
416
- def parse_schema_params(properties, required_names: [])
417
- properties.map do |name, schema|
418
- param = {
419
- name: name.to_s,
420
- type: schema["type"] || "string",
421
- description: schema["description"].to_s,
422
- required: required_names.include?(name.to_s)
423
- }
424
- param[:enum] = schema["enum"] if schema["enum"]
425
- param
426
- end
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)
427
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)
428
468
  end
429
469
  end
430
470
  end