phronomy 0.12.0 → 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: 9b5c5fc2f42107629541d051e1330e90e48298ccf19192440e7be77a30b6ca68
4
- data.tar.gz: 78e4440ec72afb39b7021810c8cdfc8de44f94f15d97442f91000eff50aa967c
3
+ metadata.gz: dd9fb679db0e4d4c582a82dad543ca0d93806878e0936404cd4255d2a48a741b
4
+ data.tar.gz: 511a6bfb90ec0524aafb201996bc251c9b1923a2d3ff4d1b389aa7d62f2d3395
5
5
  SHA512:
6
- metadata.gz: 79c98ed427ae1c55d245f871ba9248279199496b27535dfaca2b94d89998bfa95583c8b87a7c192f00f5143c755f6234a4f513ec37c86172ea2c286579e9b72a
7
- data.tar.gz: 30d8c27a6a0a0d003da85438ee903fc73af648079c83ab42618ce53d98570b9cfaa39144a71c49e8cce77d94b0f679bb82b87b484e06e654eb8298379e8dc093
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 |
@@ -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
 
@@ -308,7 +308,7 @@ module Phronomy
308
308
  tc_args = tc.arguments
309
309
  tc_name = tc.name
310
310
  ct = ctx.config[:cancellation_token]
311
- awaitable = tool_instance.call_async(tc_args, cancellation_token: ct)
311
+ awaitable = tool_instance.call_async(tc_args, cancellation_token: ct, config: ctx.config)
312
312
  result_task = Phronomy::Task.deferred(name: "tool-exec:#{tc_name}")
313
313
  awaitable.on_complete do |result, error|
314
314
  if error
@@ -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
@@ -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(
@@ -41,12 +39,18 @@ module Phronomy
41
39
  # @return [Mcp] a configured subclass instance ready for use with an Agent
42
40
  # @api public
43
41
  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.
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.
47
45
  transport = build_transport(server_uri, headers: headers)
46
+ client = MCP::Client.new(transport: transport)
48
47
  begin
49
- 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}"
50
54
  ensure
51
55
  transport.close
52
56
  end
@@ -59,14 +63,27 @@ module Phronomy
59
63
  scheme, path = uri.split("://", 2)
60
64
  case scheme
61
65
  when "stdio"
62
- StdioTransport.new(path)
66
+ argv = Shellwords.split(path)
67
+ MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
63
68
  when "http", "https"
64
- HttpTransport.new(uri, headers: headers)
69
+ MCP::Client::HTTP.new(url: uri, headers: headers)
65
70
  else
66
71
  raise ArgumentError, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
67
72
  end
68
73
  end
69
74
 
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
+
70
87
  def build_tool_class(tool_name, server_uri, tool_def, headers: {})
71
88
  klass = Class.new(Mcp)
72
89
  klass.tool_name(tool_name)
@@ -82,336 +99,55 @@ module Phronomy
82
99
  klass.param(p[:name].to_sym, **opts)
83
100
  end
84
101
 
85
- # Each instance creates its own transport so concurrent agent threads
86
- # 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.
87
104
  klass.define_method(:initialize) do
88
105
  uri = self.class.instance_variable_get(:@mcp_server_uri)
89
106
  hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
90
- @mcp_transport = self.class.send(:build_transport, uri, headers: hdrs)
91
- end
92
-
93
- klass.define_method(:execute) do |**args|
94
- @mcp_transport.call_tool(tool_name, args)
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
95
142
  end
96
143
 
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).
144
+ # Allow callers to deterministically shut down the underlying transport.
102
145
  klass.define_method(:close) do
103
- @mcp_transport.close
146
+ @mcp_client.transport.close
104
147
  end
105
148
 
106
149
  klass
107
150
  end
108
- end
109
-
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
149
-
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
169
- end
170
- wait_thr&.join(5)
171
- end
172
-
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
182
-
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
189
-
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}"
200
- end
201
- content = response.dig("result", "content")
202
-
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
- end
210
- end
211
-
212
- private
213
-
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
249
- end
250
-
251
- if @startup_timeout
252
- unless IO.select([@stdout], nil, nil, @startup_timeout)
253
- close
254
- raise Phronomy::ToolError,
255
- "MCP stdio server did not start within #{@startup_timeout} seconds"
256
- 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.
261
- end
262
- end
263
-
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)
269
- raise Phronomy::ToolError,
270
- "MCP stdio server did not respond within #{@read_timeout} seconds"
271
- end
272
- raw = @stdout.gets
273
- raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
274
- JSON.parse(raw)
275
- end
276
-
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
287
- end
288
- end
289
- end
290
-
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
316
- end
317
-
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
321
- end
322
-
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
332
-
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
- }
338
- end
339
-
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")
352
-
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
359
- end
360
-
361
- private
362
-
363
- def rpc_call(method, params)
364
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
365
-
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
370
-
371
- path = @uri.path.empty? ? "/" : @uri.path
372
- path = "#{path}?#{@uri.query}" if @uri.query
373
-
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
379
-
380
- http_response = http.request(request)
381
-
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
386
-
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
393
- end
394
-
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")
414
- end
415
151
 
416
152
  def parse_schema_params(properties, required_names: [])
417
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.12.0"
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.12.0
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.