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 +4 -4
- data/CHANGELOG.md +26 -1
- data/README.md +3 -3
- data/lib/phronomy/agent/context/capability/base.rb +3 -2
- data/lib/phronomy/agent/invocation_session.rb +1 -1
- data/lib/phronomy/agent/tool_executor.rb +9 -2
- data/lib/phronomy/tools/mcp.rb +70 -334
- data/lib/phronomy/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd9fb679db0e4d4c582a82dad543ca0d93806878e0936404cd4255d2a48a741b
|
|
4
|
+
data.tar.gz: 511a6bfb90ec0524aafb201996bc251c9b1923a2d3ff4d1b389aa7d62f2d3395
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
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
|
data/lib/phronomy/tools/mcp.rb
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
|
17
|
-
#
|
|
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
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
argv = Shellwords.split(path)
|
|
67
|
+
MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
|
|
63
68
|
when "http", "https"
|
|
64
|
-
|
|
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
|
|
86
|
-
# never share IO streams
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
@
|
|
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|
|
data/lib/phronomy/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|