phronomy 0.11.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a6262c12bc063819c0e1656699f47666b06b182747d6ce85843d2a74988c358
4
- data.tar.gz: 6529858071913e61a6a840fe03e36620322bfddd50e415b054314124f9b7fcd1
3
+ metadata.gz: dd9fb679db0e4d4c582a82dad543ca0d93806878e0936404cd4255d2a48a741b
4
+ data.tar.gz: 511a6bfb90ec0524aafb201996bc251c9b1923a2d3ff4d1b389aa7d62f2d3395
5
5
  SHA512:
6
- metadata.gz: 6b1441aab8f7197c23bd84fea750388280ee00036e729f47de13b0113249261f72fb10f01b5e9a78fb2464fcb0ebac5fd07d91eef7d4b5e83fb973b463c0630e
7
- data.tar.gz: d9c0401177c9d59900543c7ff5bbae698401fa0dc5ca7e33272458f41eddff2fa61caa501a5735b273f59b44de96e1fc70b0c5a648bb159066f7facd18b5aa82
6
+ metadata.gz: 9efc33585c40e3d5b4559121cd5654c9687608ff05ab684818e27ac5d4672264352fb68fbf4efa6436ed8666a0b194acfdcea7511fce67a5eda50e690cd80803
7
+ data.tar.gz: 0142742c7875411622ad37ae2918164130bd84fe237da2e100b69578f67380a08c61ae6ca77cfdcac5892ea7bd56140c39b374b8d0543d11cd359bda587f12df
data/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [0.13.0] - 2026-07-23
11
11
 
12
12
  ### Added
13
13
 
@@ -49,6 +49,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
49
49
  `_stream_impl`, and `Suspendable#resume` have been removed. Behaviour is unchanged
50
50
  — guardrails still run before any filters and `GuardrailError` still propagates.
51
51
 
52
+ ### Changed
53
+
54
+ - **MCP transport replaced by official `mcp` gem** (closes #280, #365):
55
+ The hand-rolled `StdioTransport` and `HttpTransport` (~260 lines) have been
56
+ replaced by `MCP::Client::Stdio` and `MCP::Client::HTTP` from the official
57
+ `mcp` gem (v0.25.0+). Adds the `mcp >= 0.3` runtime dependency.
58
+ - Built-in 4 MiB size limits for stdout, stderr, and HTTP responses
59
+ - Automatic MCP `initialize` handshake on every connection
60
+ - SSE response parsing handled by the SDK
61
+ - `from_server` and `execute` convert SDK errors into `Phronomy::ToolError`
62
+ - Removes all `Thread.new` usage from `mcp.rb`; entry removed from
63
+ `THREAD_NEW_ALLOWLIST` in `thread_invariants_spec.rb`
64
+
65
+ - **`CancellationToken` bridged into MCP `call_tool`** (Issue #390):
66
+ `Mcp#execute` now accepts a `cancellation_token:` keyword argument.
67
+ When provided, the token is bridged to `MCP::Cancellation` via `on_cancel`,
68
+ so an explicit `cancel!` propagates into the in-flight `call_tool` request
69
+ as a MCP `notifications/cancelled` message.
70
+
71
+ - **`config[:tool_timeout]` forwarded to `BlockingAdapterPool`** (Issue #390):
72
+ `ToolExecutor.call_async` and `Capability::Base#call_async` now accept a
73
+ `config:` keyword. `config[:tool_timeout]` is passed as the `timeout:` to
74
+ `pool.submit`, enabling the same abandoned-operation tracking that LLM calls
75
+ already use via `config[:llm_timeout]`.
76
+
52
77
 
53
78
 
54
79
  ## [0.10.0] - 2026-06-08
data/README.md CHANGED
@@ -46,7 +46,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
46
46
  | **Knowledge** — Static context injection with pluggable loaders, splitters, and vector stores; `static_knowledge_refresh!` for runtime cache invalidation | Beta |
47
47
  | **`VectorStore#size`** — Returns document count for all three backends (InMemory, RedisSearch, Pgvector) | Beta |
48
48
  | **`VectorStore::AsyncBackend` mixin** — Pluggable async interface for `VectorStore`; default pool-backed implementations for `search_async`, `add_async`, `remove_async`, `clear_async`; backends with native async drivers override individual methods to bypass `BlockingAdapterPool` entirely; all existing backends remain unchanged | Beta |
49
- | **MCP Tool** — `Phronomy::Tools::Mcp`: Model Context Protocol server integration; `Phronomy::Tools::Agent`: wraps an agent class as a callable tool via `from_agent` | Beta |
49
+ | **MCP Tool** — `Phronomy::Tools::Mcp`: Model Context Protocol server integration via the official `mcp` gem; `Phronomy::Tools::Agent`: wraps an agent class as a callable tool via `from_agent` | Beta |
50
50
  | **Vector Search Tool** — `Phronomy::Tools::VectorSearch`: wraps a `VectorStore` and `Embeddings` adapter as a callable agent tool via `from_store` | Beta |
51
51
 
52
52
  **Execution and reliability**
@@ -57,9 +57,9 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
57
57
  | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#invoke_async` + `Task#map` (child-agent pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
58
58
  | **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Experimental |
59
59
  | **`Task#map`** — transforms a `Task`'s completed value via a block; returns a new `Task` whose value is the block's return value; if the source task fails or is cancelled the mapped task propagates the error without calling the block; primary use-case: `invoke_async.map { \|r\| ctx.merge(answer: r[:output]) }` to wire agent results into a `WorkflowContext` | Experimental |
60
- | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword | Experimental |
60
+ | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword; bridged to `MCP::Cancellation` in `Phronomy::Tools::Mcp#execute` | Experimental |
61
61
  | **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
62
- | **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process` | Experimental |
62
+ | **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process`; `config[:tool_timeout]` sets the per-submit timeout forwarded to `BlockingAdapterPool` for abandoned-operation tracking | Experimental |
63
63
  | **`invocation_context:` keyword on `Agent#invoke` / `Workflow#invoke`** — Pass a `Phronomy::InvocationContext` directly; `thread_id`, `cancellation_token`, and `deadline`-based timeout are derived from it; `task_id` / `parent_task_id` appear in trace spans automatically; `config:` keys remain supported as backward-compat aliases | Beta |
64
64
  | **`ConcurrencyGate` — unified backpressure** — Counting semaphore that enforces per-resource concurrency caps (`max_concurrent_agent_tasks`, `max_concurrent_tool_tasks`, `max_concurrent_workflow_tasks`, `max_concurrent_llm_calls`, `max_concurrent_vector_searches`); configured via `Phronomy.configure`; backpressure behaviour follows the global `backpressure` setting (`:wait`, `:raise`/`:reject`, `:timeout`); `nil` cap = unlimited (default) | Beta |
65
65
  | **Cooperative scheduler yield points** — `Runtime#yield` (cooperative yield; yields the current task's time slice); `Runtime#yield_if_needed(every: N)` (thread-local counter, yields every N calls); CPU-bound detection when `blocking_detect_threshold_ms` is set (warns and increments `non_yield_threshold_violation_count` when a task runs longer than the threshold without yielding); `starvation_threshold_ms` configuration field (default: 50ms) | Beta |
@@ -1173,7 +1173,7 @@ module Phronomy
1173
1173
  effective_name = resolved.new.name
1174
1174
  rejected_class = Class.new(resolved) do
1175
1175
  tool_name effective_name
1176
- define_method(:call) do |_args|
1176
+ define_method(:call) do |_args, **_kwargs|
1177
1177
  "Tool execution denied: scope :#{scope} is not permitted."
1178
1178
  end
1179
1179
  end
@@ -1199,9 +1199,9 @@ module Phronomy
1199
1199
  effective_name = resolved.new.name
1200
1200
  resolved = Class.new(resolved) do
1201
1201
  tool_name effective_name
1202
- define_method(:call) do |args|
1202
+ define_method(:call) do |args, **kwargs|
1203
1203
  if handler.call(name, args)
1204
- super(args)
1204
+ super(args, **kwargs)
1205
1205
  else
1206
1206
  "Tool execution denied."
1207
1207
  end
@@ -1216,8 +1216,8 @@ module Phronomy
1216
1216
  effective_name4 = resolved.new.name
1217
1217
  Class.new(resolved) do
1218
1218
  tool_name effective_name4
1219
- define_method(:call) do |args|
1220
- result = super(args)
1219
+ define_method(:call) do |args, **kwargs|
1220
+ result = super(args, **kwargs)
1221
1221
  result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
1222
1222
  end
1223
1223
  end
@@ -369,11 +369,12 @@ module Phronomy
369
369
  # @return [#await]
370
370
  # @api public
371
371
  # mutant:disable
372
- def call_async(args, cancellation_token: nil)
372
+ def call_async(args, cancellation_token: nil, config: {})
373
373
  Phronomy::Agent::ToolExecutor.call_async(
374
374
  tool: self,
375
375
  args: args,
376
- cancellation_token: cancellation_token
376
+ cancellation_token: cancellation_token,
377
+ config: config
377
378
  )
378
379
  end
379
380
 
@@ -298,27 +298,36 @@ module Phronomy
298
298
  end
299
299
  end
300
300
 
301
- # Execute the tool off the EventLoop thread via Runtime.instance.spawn.
302
- # Tool implementations (e.g. Orchestrator sub-agent dispatch) may call
303
- # agent.invoke_async().wait_result which would deadlock if run directly on the
304
- # EventLoop dispatch thread. Returning a Task causes
305
- # PhaseMachineBuilder#dispatch_task to await the result off the EventLoop
306
- # and post :action_completed back when done.
301
+ # Dispatch the tool off the EventLoop thread via ToolExecutor, which
302
+ # routes based on the tool's execution_mode class attribute:
303
+ # :blocking_io (default) BlockingAdapterPool (bounded thread pool)
304
+ # :cooperative → Runtime.instance.spawn (scheduler task)
305
+ # Wrap the awaitable in Task.deferred so FSMSession recognises it as
306
+ # an async action and sets async_pending = true.
307
307
  tc_id = tc.id
308
308
  tc_args = tc.arguments
309
309
  tc_name = tc.name
310
- Phronomy::Runtime.instance.spawn(name: "tool-exec:#{tc_name}") do
311
- result = tool_instance.call(tc_args)
312
- ctx.chat.add_message(
313
- role: :tool,
314
- content: result.to_s,
315
- tool_call_id: tc_id
316
- )
317
- ctx.pending_tool_call = nil
318
- ctx.tool_call_pending = false
319
- ctx.approval_required = false
320
- ctx
310
+ ct = ctx.config[:cancellation_token]
311
+ awaitable = tool_instance.call_async(tc_args, cancellation_token: ct, config: ctx.config)
312
+ result_task = Phronomy::Task.deferred(name: "tool-exec:#{tc_name}")
313
+ awaitable.on_complete do |result, error|
314
+ if error
315
+ result_task.backend.unblock(nil, error)
316
+ result_task.transition!(:failed, error: error)
317
+ else
318
+ ctx.chat.add_message(
319
+ role: :tool,
320
+ content: result.to_s,
321
+ tool_call_id: tc_id
322
+ )
323
+ ctx.pending_tool_call = nil
324
+ ctx.tool_call_pending = false
325
+ ctx.approval_required = false
326
+ result_task.backend.unblock(ctx, nil)
327
+ result_task.transition!(:completed, value: ctx)
328
+ end
321
329
  end
330
+ result_task
322
331
  end
323
332
  private_class_method :executing_tool_action
324
333
 
@@ -54,11 +54,15 @@ module Phronomy
54
54
  # @param tool [Phronomy::Agent::Context::Capability::Base] the tool instance to invoke
55
55
  # @param args [Hash] argument hash to pass to {Tool::Base#call}
56
56
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
57
+ # @param config [Hash] invocation config forwarded from the agent pipeline.
58
+ # Recognised keys: +:tool_timeout+ (seconds; passed as the
59
+ # +BlockingAdapterPool#submit+ timeout so that timed-out
60
+ # operations are tracked as abandoned rather than silently dropped).
57
61
  # @param runtime [Phronomy::Runtime] runtime to use for spawning
58
62
  # (defaults to {Runtime.instance}; injectable for tests)
59
63
  # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
60
64
  # @api private
61
- def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
65
+ def self.call_async(tool:, args:, cancellation_token: nil, config: {}, runtime: Phronomy::Runtime.instance)
62
66
  ct = cancellation_token
63
67
  mode = tool.class.execution_mode
64
68
 
@@ -100,7 +104,10 @@ module Phronomy
100
104
  end
101
105
  else
102
106
  # Submit directly to pool — no wrapping Task thread required.
103
- pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
107
+ # Pass tool_timeout so the pool can track timed-out operations as
108
+ # abandoned, consistent with how LLM calls use config[:llm_timeout].
109
+ timeout = config[:tool_timeout]
110
+ pool.submit(cancellation_token: ct, timeout: timeout) { tool.call(args, cancellation_token: ct) }
104
111
  end
105
112
  end
106
113
  end
@@ -252,9 +252,9 @@ module Phronomy
252
252
  effective_name = prepared.new.name
253
253
  Class.new(prepared) do
254
254
  tool_name effective_name
255
- define_method(:call) do |args|
255
+ define_method(:call) do |args, **kwargs|
256
256
  self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
257
- super(args)
257
+ super(args, **kwargs)
258
258
  end
259
259
  end
260
260
  end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "open3"
6
- require "securerandom"
3
+ require "mcp"
7
4
  require "shellwords"
8
5
  require "uri"
9
6
 
@@ -12,11 +9,12 @@ module Phronomy
12
9
  # A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
13
10
  # MCP (Model Context Protocol) server.
14
11
  #
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.
14
+ #
15
15
  # 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+.
16
+ # - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
17
+ # - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
20
18
  #
21
19
  # @example
22
20
  # web_search = Phronomy::Tools::Mcp.from_server(
@@ -35,39 +33,62 @@ module Phronomy
35
33
  # - "stdio://<command>" — spawn a child process
36
34
  # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
35
  # @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>
38
39
  # @return [Mcp] a configured subclass instance ready for use with an Agent
39
40
  # @api public
40
- def from_server(server_uri, tool_name:)
41
- # Use a short-lived transport only to query the tool definition,
42
- # then close it. Each Mcp instance creates its own transport
43
- # so that concurrent callers never share IO streams.
44
- transport = build_transport(server_uri)
41
+ 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)
45
47
  begin
46
- tool_def = transport.fetch_tool(tool_name)
48
+ client.connect
49
+ tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
50
+ rescue ArgumentError
51
+ raise
52
+ rescue => e
53
+ raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
47
54
  ensure
48
55
  transport.close
49
56
  end
50
- build_tool_class(tool_name, server_uri, tool_def).new
57
+ build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
51
58
  end
52
59
 
53
60
  private
54
61
 
55
- def build_transport(uri)
62
+ def build_transport(uri, headers: {})
56
63
  scheme, path = uri.split("://", 2)
57
64
  case scheme
58
65
  when "stdio"
59
- StdioTransport.new(path)
66
+ argv = Shellwords.split(path)
67
+ MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
60
68
  when "http", "https"
61
- HttpTransport.new(uri)
69
+ MCP::Client::HTTP.new(url: uri, headers: headers)
62
70
  else
63
71
  raise ArgumentError, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
64
72
  end
65
73
  end
66
74
 
67
- def build_tool_class(tool_name, server_uri, tool_def)
75
+ 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
78
+
79
+ properties = mcp_tool.input_schema&.dig("properties") || {}
80
+ required_names = mcp_tool.input_schema&.dig("required") || []
81
+ {
82
+ description: mcp_tool.description || tool_name,
83
+ parameters: parse_schema_params(properties, required_names: required_names)
84
+ }
85
+ end
86
+
87
+ def build_tool_class(tool_name, server_uri, tool_def, headers: {})
68
88
  klass = Class.new(Mcp)
69
89
  klass.tool_name(tool_name)
70
90
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
91
+ klass.instance_variable_set(:@mcp_headers, headers)
71
92
 
72
93
  # Register description and params from the MCP tool definition.
73
94
  klass.description(tool_def[:description] || tool_name)
@@ -78,333 +99,55 @@ module Phronomy
78
99
  klass.param(p[:name].to_sym, **opts)
79
100
  end
80
101
 
81
- # Each instance creates its own transport so concurrent agent threads
82
- # never share IO streams, eliminating the need for synchronisation.
102
+ # Each instance creates its own MCP client so concurrent agent threads
103
+ # never share IO streams.
83
104
  klass.define_method(:initialize) do
84
105
  uri = self.class.instance_variable_get(:@mcp_server_uri)
85
- @mcp_transport = self.class.send(:build_transport, uri)
86
- end
87
-
88
- klass.define_method(:execute) do |**args|
89
- @mcp_transport.call_tool(tool_name, args)
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
90
142
  end
91
143
 
92
- # Allow callers to deterministically shut down the underlying child
93
- # process (stdio) or release the HTTP connection. For HttpTransport
94
- # this is a no-op. After close, calling execute will reopen the
95
- # transport automatically (stdio restarts the child process; HTTP
96
- # opens a fresh connection per call).
144
+ # Allow callers to deterministically shut down the underlying transport.
97
145
  klass.define_method(:close) do
98
- @mcp_transport.close
146
+ @mcp_client.transport.close
99
147
  end
100
148
 
101
149
  klass
102
150
  end
103
- end
104
-
105
- # -----------------------------------------------------------------------
106
- # Transports
107
- # -----------------------------------------------------------------------
108
-
109
- # Minimal stdio transport implementing a subset of the MCP JSON-RPC protocol.
110
- # Keeps the child process alive for the lifetime of this transport instance
111
- # so that session state (registered resources, tool context, etc.) is preserved
112
- # across multiple calls.
113
- class StdioTransport
114
- # @param command [String] shell command to spawn the MCP server process
115
- # @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
116
- # before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
117
- # {HttpTransport}. Defaults to 30 seconds.
118
- # @param env [Hash, nil] environment variable overrides for the subprocess.
119
- # When provided, only these variables are added/overridden; the parent environment
120
- # is still inherited. Use +nil+ as a value to unset a variable in the child process
121
- # (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
122
- # an empty string — it does NOT unset it.
123
- # @param cwd [String, nil] working directory for the subprocess.
124
- # Defaults to the current process's working directory.
125
- # @param startup_timeout [Numeric, nil] seconds to wait for the server to
126
- # emit its first line on stdout before raising {Phronomy::ToolError}.
127
- # When nil (default), no startup check is performed.
128
- # @api public
129
- def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
130
- # Split the command string into an argv array so that Open3 executes
131
- # it directly without going through the shell, preventing injection.
132
- @command = Shellwords.split(command)
133
- @read_timeout = read_timeout
134
- @env = env
135
- @cwd = cwd
136
- @startup_timeout = startup_timeout
137
- @stdin = nil
138
- @stdout = nil
139
- @stderr = nil
140
- @wait_thr = nil
141
- @stderr_thread = nil
142
- @stderr_op = nil
143
- end
144
-
145
- # Shut down the child process and close its IO streams.
146
- def close
147
- @stdin&.close
148
- @stdout&.close
149
- @stderr&.close
150
- @stdin = nil
151
- @stdout = nil
152
- @stderr = nil
153
- stderr_thread = @stderr_thread
154
- stderr_op = @stderr_op
155
- wait_thr = @wait_thr
156
- @stderr_thread = nil
157
- @stderr_op = nil
158
- @wait_thr = nil
159
- stderr_thread&.join(1)
160
- begin
161
- stderr_op&.blocking_wait(timeout: 1.0)
162
- rescue
163
- nil
164
- end
165
- wait_thr&.join(5)
166
- end
167
-
168
- # Retrieve the tool definition from the server using the MCP `tools/list` method.
169
- # @param tool_name [String]
170
- # @return [Hash] { description:, parameters: }
171
- # @api public
172
- def fetch_tool(tool_name)
173
- response = rpc_call("tools/list", {})
174
- tools = response.dig("result", "tools") || []
175
- defn = tools.find { |t| t["name"] == tool_name }
176
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
177
-
178
- required_names = defn.dig("inputSchema", "required") || []
179
- {
180
- description: defn["description"],
181
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
182
- }
183
- end
184
-
185
- # Call a tool on the MCP server using the `tools/call` method.
186
- # @param tool_name [String]
187
- # @param args [Hash]
188
- # @return [Object] the tool result
189
- # @api public
190
- def call_tool(tool_name, args)
191
- response = rpc_call("tools/call", {name: tool_name, arguments: args})
192
- if response["error"]
193
- err_msg = response.dig("error", "message") || response["error"].to_s
194
- raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
195
- end
196
- content = response.dig("result", "content")
197
-
198
- # MCP content is an array of content blocks; extract text blocks.
199
- if content.is_a?(Array)
200
- texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
201
- (texts.length == 1) ? texts.first : texts
202
- else
203
- content
204
- end
205
- end
206
-
207
- private
208
-
209
- # Ensure the child process is running, spawning it if necessary.
210
- def ensure_started!
211
- return if @stdin && !@stdin.closed?
212
-
213
- popen3_opts = {}
214
- popen3_opts[:chdir] = @cwd if @cwd
215
-
216
- argv = @env ? [@env, *@command] : @command
217
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
218
- # Drain stderr asynchronously to prevent the pipe buffer from filling
219
- # and deadlocking the child process. Errors inside the drain thread are
220
- # silently ignored since stderr content is diagnostics-only.
221
- #
222
- # Prefer BlockingAdapterPool when a Runtime is configured so that this
223
- # file eventually needs no direct Thread.new (Issue #360). Fall back to
224
- # Thread.new when no pool is available (no EventLoop / bare invocation).
225
- pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
226
- if pool
227
- @stderr_op = pool.submit {
228
- begin
229
- @stderr.read
230
- rescue
231
- nil
232
- end
233
- }
234
- @stderr_thread = nil
235
- else
236
- @stderr_thread = Thread.new {
237
- begin
238
- @stderr.read
239
- rescue
240
- nil
241
- end
242
- }
243
- @stderr_op = nil
244
- end
245
-
246
- if @startup_timeout
247
- unless IO.select([@stdout], nil, nil, @startup_timeout)
248
- close
249
- raise Phronomy::ToolError,
250
- "MCP stdio server did not start within #{@startup_timeout} seconds"
251
- end
252
- line = @stdout.gets
253
- @stdout.ungetbyte(line) if line
254
- end
255
- end
256
-
257
- def rpc_call(method, params)
258
- ensure_started!
259
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
260
- @stdin.puts(payload)
261
- unless IO.select([@stdout], nil, nil, @read_timeout)
262
- raise Phronomy::ToolError,
263
- "MCP stdio server did not respond within #{@read_timeout} seconds"
264
- end
265
- raw = @stdout.gets
266
- raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
267
- JSON.parse(raw)
268
- end
269
-
270
- def parse_schema_params(properties, required_names: [])
271
- properties.map do |name, schema|
272
- param = {
273
- name: name.to_s,
274
- type: schema["type"] || "string",
275
- description: schema["description"].to_s,
276
- required: required_names.include?(name.to_s)
277
- }
278
- param[:enum] = schema["enum"] if schema["enum"]
279
- param
280
- end
281
- end
282
- end
283
-
284
- # HTTP/HTTPS transport implementing JSON-RPC over HTTP with SSE support.
285
- #
286
- # Sends JSON-RPC POST requests to the MCP server endpoint.
287
- # Accepts both plain JSON responses (Content-Type: application/json) and
288
- # Server-Sent Events streams (Content-Type: text/event-stream), covering
289
- # both the 2024-11-05 and 2025-03-26 MCP HTTP transport specifications.
290
- #
291
- # @example
292
- # tool = Phronomy::Tools::Mcp.from_server(
293
- # "http://localhost:8080/mcp",
294
- # tool_name: "weather_lookup"
295
- # )
296
- class HttpTransport
297
- # @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
298
- # @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
299
- # @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
300
- # @param headers [Hash] additional HTTP request headers (e.g. Authorization).
301
- # Merged on top of the default Content-Type and Accept headers; caller-supplied
302
- # values override defaults when keys collide.
303
- # @api public
304
- def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
305
- @uri = URI.parse(base_url)
306
- @open_timeout = open_timeout
307
- @read_timeout = read_timeout
308
- @extra_headers = headers
309
- end
310
-
311
- # HTTP connections are stateless; close is a no-op, defined so that
312
- # both transport classes share the same interface as StdioTransport.
313
- def close
314
- end
315
-
316
- # Retrieve the tool definition from the server using MCP `tools/list`.
317
- # @param tool_name [String]
318
- # @return [Hash] { description:, parameters: }
319
- # @api public
320
- def fetch_tool(tool_name)
321
- response = rpc_call("tools/list", {})
322
- tools = response.dig("result", "tools") || []
323
- defn = tools.find { |t| t["name"] == tool_name }
324
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
325
-
326
- required_names = defn.dig("inputSchema", "required") || []
327
- {
328
- description: defn["description"],
329
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
330
- }
331
- end
332
-
333
- # Call a tool on the MCP server using MCP `tools/call`.
334
- # @param tool_name [String]
335
- # @param args [Hash]
336
- # @return [Object] the tool result
337
- # @api public
338
- def call_tool(tool_name, args)
339
- response = rpc_call("tools/call", {name: tool_name, arguments: args})
340
- if response["error"]
341
- err_msg = response.dig("error", "message") || response["error"].to_s
342
- raise Phronomy::ToolError, "MCP HTTP server returned error: #{err_msg}"
343
- end
344
- content = response.dig("result", "content")
345
-
346
- if content.is_a?(Array)
347
- texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
348
- (texts.length == 1) ? texts.first : texts
349
- else
350
- content
351
- end
352
- end
353
-
354
- private
355
-
356
- def rpc_call(method, params)
357
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
358
-
359
- http = Net::HTTP.new(@uri.host, @uri.port)
360
- http.use_ssl = (@uri.scheme == "https")
361
- http.open_timeout = @open_timeout
362
- http.read_timeout = @read_timeout
363
-
364
- path = @uri.path.empty? ? "/" : @uri.path
365
- path = "#{path}?#{@uri.query}" if @uri.query
366
-
367
- request = Net::HTTP::Post.new(path)
368
- request["Content-Type"] = "application/json"
369
- request["Accept"] = "application/json, text/event-stream"
370
- @extra_headers.each { |k, v| request[k.to_s] = v.to_s }
371
- request.body = payload
372
-
373
- http_response = http.request(request)
374
-
375
- unless http_response.is_a?(Net::HTTPSuccess)
376
- raise Phronomy::ToolError,
377
- "MCP HTTP server returned #{http_response.code}: #{http_response.body}"
378
- end
379
-
380
- content_type = http_response["Content-Type"] || ""
381
- if content_type.include?("text/event-stream")
382
- parse_sse_response(http_response.body)
383
- else
384
- JSON.parse(http_response.body)
385
- end
386
- end
387
-
388
- # Parse an SSE response body and extract the last JSON-RPC message.
389
- # SSE lines are in the format "data: <json>".
390
- def parse_sse_response(body)
391
- result = nil
392
- body.each_line do |line|
393
- line = line.strip
394
- next unless line.start_with?("data: ")
395
-
396
- data = line.delete_prefix("data: ")
397
- next if data == "[DONE]"
398
-
399
- begin
400
- parsed = JSON.parse(data)
401
- result = parsed if parsed.is_a?(Hash) && parsed["jsonrpc"]
402
- rescue JSON::ParserError
403
- next
404
- end
405
- end
406
- result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
407
- end
408
151
 
409
152
  def parse_schema_params(properties, required_names: [])
410
153
  properties.map do |name, schema|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.11.1"
4
+ VERSION = "0.13.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-19 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -64,6 +64,20 @@ dependencies:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
66
  version: '0.6'
67
+ - !ruby/object:Gem::Dependency
68
+ name: mcp
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0.3'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0.3'
67
81
  description: Phronomy is a Ruby AI agent framework that provides composable building
68
82
  blocks — Agents, Workflows, Tools, Filters, and Tracing — for building AI agents
69
83
  in Ruby. Powered by RubyLLM for LLM abstraction.