phronomy 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +115 -0
- data/README.md +54 -5
- data/docs/mcp-client.md +75 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/base.rb +17 -17
- data/lib/phronomy/agent/context/capability/base.rb +9 -2
- data/lib/phronomy/agent/invocation_session.rb +11 -5
- data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +9 -2
- data/lib/phronomy/configuration.rb +15 -46
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +319 -272
- data/lib/phronomy/engine/fsm_session.rb +17 -14
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/metrics.rb +4 -3
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +361 -321
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
- data/lib/phronomy/workflow_context.rb +1 -2
- data/lib/phronomy/workflow_runner.rb +22 -12
- data/lib/phronomy.rb +24 -19
- metadata +59 -4
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
data/lib/phronomy/tools/mcp.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "
|
|
5
|
-
require "open3"
|
|
6
|
-
require "securerandom"
|
|
4
|
+
require "mcp"
|
|
7
5
|
require "shellwords"
|
|
8
6
|
require "uri"
|
|
9
7
|
|
|
@@ -12,44 +10,73 @@ module Phronomy
|
|
|
12
10
|
# A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
|
|
13
11
|
# MCP (Model Context Protocol) server.
|
|
14
12
|
#
|
|
13
|
+
# Uses the official MCP Ruby SDK v1.x for transport handling, which provides
|
|
14
|
+
# the MCP initialize handshake, HTTP/SSE parsing, and request cancellation.
|
|
15
|
+
#
|
|
15
16
|
# Supports two transport schemes:
|
|
16
|
-
# - <b>"stdio://\<command\>"</b> — spawns a child process
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
|
|
18
|
+
# - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
|
|
19
|
+
#
|
|
20
|
+
# Each generated tool instance owns one MCP client. Calls, reconnects, and
|
|
21
|
+
# explicit close operations are serialized because the SDK's stdio transport
|
|
22
|
+
# does not support concurrent response readers.
|
|
20
23
|
#
|
|
21
24
|
# @example
|
|
22
25
|
# web_search = Phronomy::Tools::Mcp.from_server(
|
|
23
26
|
# "stdio://./mcp-server",
|
|
24
27
|
# tool_name: "search_web"
|
|
25
28
|
# )
|
|
26
|
-
# agent = MyAgent.new
|
|
27
29
|
# agent_class.tools(web_search)
|
|
28
30
|
class Mcp < Phronomy::Agent::Context::Capability::Base
|
|
31
|
+
SUPPORTED_SCHEMA_DIALECTS = [
|
|
32
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
33
|
+
"https://json-schema.org/draft/2020-12/schema#"
|
|
34
|
+
].freeze
|
|
35
|
+
SUPPORTED_ROOT_KEYS = %w[$schema type properties required title description additionalProperties].freeze
|
|
36
|
+
SUPPORTED_PROPERTY_KEYS = %w[type description enum title].freeze
|
|
37
|
+
IGNORED_PROPERTY_KEYS = %w[
|
|
38
|
+
minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
|
|
39
|
+
minLength maxLength pattern format default examples
|
|
40
|
+
].freeze
|
|
41
|
+
SUPPORTED_TYPES = %w[string integer number boolean].freeze
|
|
42
|
+
MCP_CLEANUP_POOL_SIZE = 2
|
|
43
|
+
MCP_CLEANUP_QUEUE_SIZE = 100
|
|
44
|
+
|
|
45
|
+
private_constant :SUPPORTED_SCHEMA_DIALECTS,
|
|
46
|
+
:SUPPORTED_ROOT_KEYS,
|
|
47
|
+
:SUPPORTED_PROPERTY_KEYS,
|
|
48
|
+
:IGNORED_PROPERTY_KEYS,
|
|
49
|
+
:SUPPORTED_TYPES,
|
|
50
|
+
:MCP_CLEANUP_POOL_SIZE,
|
|
51
|
+
:MCP_CLEANUP_QUEUE_SIZE
|
|
52
|
+
|
|
29
53
|
class << self
|
|
30
54
|
# Build a Mcp instance by querying a running MCP server for the
|
|
31
55
|
# tool definition identified by +tool_name+.
|
|
32
56
|
#
|
|
57
|
+
# +additionalProperties+ omitted from the remote schema is accepted, but
|
|
58
|
+
# Phronomy still exposes and accepts only parameters declared in +properties+.
|
|
59
|
+
#
|
|
33
60
|
# @param server_uri [String] URI of the MCP server.
|
|
34
|
-
# Supported schemes:
|
|
35
|
-
# - "stdio://<command>" — spawn a child process
|
|
36
|
-
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
37
61
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
38
|
-
# @param headers [Hash] additional HTTP
|
|
39
|
-
#
|
|
40
|
-
# Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
|
|
41
|
-
# @return [Mcp] a configured subclass instance ready for use with an Agent
|
|
62
|
+
# @param headers [Hash] additional HTTP headers forwarded to discovery and execution
|
|
63
|
+
# @return [Mcp] configured tool instance
|
|
42
64
|
# @api public
|
|
43
65
|
def from_server(server_uri, tool_name:, headers: {})
|
|
44
|
-
|
|
45
|
-
# then close it. Each Mcp instance creates its own transport
|
|
46
|
-
# so that concurrent callers never share IO streams.
|
|
47
|
-
transport = build_transport(server_uri, headers: headers)
|
|
66
|
+
transport = nil
|
|
48
67
|
begin
|
|
49
|
-
|
|
68
|
+
transport = build_transport(server_uri, headers: headers)
|
|
69
|
+
client = MCP::Client.new(transport: transport)
|
|
70
|
+
client.connect
|
|
71
|
+
tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
|
|
72
|
+
rescue ArgumentError, Phronomy::ToolError
|
|
73
|
+
raise
|
|
74
|
+
rescue => e
|
|
75
|
+
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
50
76
|
ensure
|
|
51
|
-
transport
|
|
77
|
+
close_transport_safely(transport)
|
|
52
78
|
end
|
|
79
|
+
|
|
53
80
|
build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
|
|
54
81
|
end
|
|
55
82
|
|
|
@@ -59,372 +86,385 @@ module Phronomy
|
|
|
59
86
|
scheme, path = uri.split("://", 2)
|
|
60
87
|
case scheme
|
|
61
88
|
when "stdio"
|
|
62
|
-
|
|
89
|
+
argv = Shellwords.split(path.to_s)
|
|
90
|
+
if argv.empty? || argv[0].to_s.empty?
|
|
91
|
+
raise ArgumentError, "MCP stdio URI must include a command"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
|
|
63
95
|
when "http", "https"
|
|
64
|
-
|
|
96
|
+
MCP::Client::HTTP.new(url: uri, headers: headers)
|
|
65
97
|
else
|
|
66
|
-
raise ArgumentError,
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"Unsupported MCP transport scheme: #{scheme.inspect}. " \
|
|
100
|
+
"Supported: 'stdio://', 'http://', 'https://'."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_tool_def(client, tool_name, server_uri)
|
|
105
|
+
mcp_tool = client.tools.find { |tool| tool.name == tool_name }
|
|
106
|
+
unless mcp_tool
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"Tool #{tool_name.inspect} not found on MCP server #{server_uri.inspect}"
|
|
67
109
|
end
|
|
110
|
+
|
|
111
|
+
validate_supported_schema!(
|
|
112
|
+
mcp_tool.input_schema,
|
|
113
|
+
output_schema: mcp_tool.output_schema,
|
|
114
|
+
tool_name: mcp_tool.name
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
input_schema = mcp_tool.input_schema
|
|
118
|
+
properties = input_schema.fetch("properties", {})
|
|
119
|
+
required_names = input_schema.fetch("required", [])
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
description: mcp_tool.description || tool_name,
|
|
123
|
+
parameters: parse_schema_params(properties, required_names: required_names)
|
|
124
|
+
}
|
|
68
125
|
end
|
|
69
126
|
|
|
70
127
|
def build_tool_class(tool_name, server_uri, tool_def, headers: {})
|
|
71
128
|
klass = Class.new(Mcp)
|
|
72
129
|
klass.tool_name(tool_name)
|
|
73
130
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
74
|
-
klass.instance_variable_set(:@mcp_headers, headers)
|
|
131
|
+
klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
|
|
75
132
|
|
|
76
|
-
# Register description and params from the MCP tool definition.
|
|
77
133
|
klass.description(tool_def[:description] || tool_name)
|
|
78
|
-
(tool_def[:parameters] || []).each do |
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
(tool_def[:parameters] || []).each do |parameter|
|
|
135
|
+
options = {
|
|
136
|
+
type: parameter.fetch(:type).to_sym,
|
|
137
|
+
desc: parameter[:description].to_s,
|
|
138
|
+
required: parameter.fetch(:required, false)
|
|
139
|
+
}
|
|
140
|
+
options[:enum] = parameter[:enum] if parameter.key?(:enum)
|
|
141
|
+
klass.param(parameter.fetch(:name).to_sym, **options)
|
|
83
142
|
end
|
|
84
143
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
144
|
+
klass
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_schema_params(properties, required_names: [])
|
|
148
|
+
properties.map do |name, schema|
|
|
149
|
+
parameter = {
|
|
150
|
+
name: name.to_s,
|
|
151
|
+
type: schema.fetch("type"),
|
|
152
|
+
description: schema["description"].to_s,
|
|
153
|
+
required: required_names.include?(name.to_s)
|
|
154
|
+
}
|
|
155
|
+
parameter[:enum] = schema["enum"] if schema.key?("enum")
|
|
156
|
+
parameter
|
|
91
157
|
end
|
|
158
|
+
end
|
|
92
159
|
|
|
93
|
-
|
|
94
|
-
|
|
160
|
+
def validate_supported_schema!(input_schema, output_schema:, tool_name:)
|
|
161
|
+
unless input_schema.is_a?(Hash) && input_schema["type"] == "object"
|
|
162
|
+
raise Phronomy::ToolError,
|
|
163
|
+
"MCP tool #{tool_name.inspect} must use an object input schema"
|
|
95
164
|
end
|
|
96
165
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# opens a fresh connection per call).
|
|
102
|
-
klass.define_method(:close) do
|
|
103
|
-
@mcp_transport.close
|
|
166
|
+
dialect = input_schema["$schema"]
|
|
167
|
+
if dialect && !SUPPORTED_SCHEMA_DIALECTS.include?(dialect)
|
|
168
|
+
raise Phronomy::ToolError,
|
|
169
|
+
"MCP tool #{tool_name.inspect} uses unsupported JSON Schema dialect #{dialect.inspect}"
|
|
104
170
|
end
|
|
105
171
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
172
|
+
unknown_root = input_schema.keys - SUPPORTED_ROOT_KEYS
|
|
173
|
+
if unknown_root.any?
|
|
174
|
+
raise Phronomy::ToolError,
|
|
175
|
+
"MCP tool #{tool_name.inspect} uses unsupported root schema keywords: " \
|
|
176
|
+
"#{unknown_root.join(", ")}"
|
|
177
|
+
end
|
|
109
178
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# so that session state (registered resources, tool context, etc.) is preserved
|
|
117
|
-
# across multiple calls.
|
|
118
|
-
class StdioTransport
|
|
119
|
-
# @param command [String] shell command to spawn the MCP server process
|
|
120
|
-
# @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
|
|
121
|
-
# before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
|
|
122
|
-
# {HttpTransport}. Defaults to 30 seconds.
|
|
123
|
-
# @param env [Hash, nil] environment variable overrides for the subprocess.
|
|
124
|
-
# When provided, only these variables are added/overridden; the parent environment
|
|
125
|
-
# is still inherited. Use +nil+ as a value to unset a variable in the child process
|
|
126
|
-
# (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
|
|
127
|
-
# an empty string — it does NOT unset it.
|
|
128
|
-
# @param cwd [String, nil] working directory for the subprocess.
|
|
129
|
-
# Defaults to the current process's working directory.
|
|
130
|
-
# @param startup_timeout [Numeric, nil] seconds to wait for the server to
|
|
131
|
-
# emit its first line on stdout before raising {Phronomy::ToolError}.
|
|
132
|
-
# When nil (default), no startup check is performed.
|
|
133
|
-
# @api public
|
|
134
|
-
def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
|
|
135
|
-
# Split the command string into an argv array so that Open3 executes
|
|
136
|
-
# it directly without going through the shell, preventing injection.
|
|
137
|
-
@command = Shellwords.split(command)
|
|
138
|
-
@read_timeout = read_timeout
|
|
139
|
-
@env = env
|
|
140
|
-
@cwd = cwd
|
|
141
|
-
@startup_timeout = startup_timeout
|
|
142
|
-
@stdin = nil
|
|
143
|
-
@stdout = nil
|
|
144
|
-
@stderr = nil
|
|
145
|
-
@wait_thr = nil
|
|
146
|
-
@stderr_thread = nil
|
|
147
|
-
@stderr_op = nil
|
|
148
|
-
end
|
|
179
|
+
additional_properties = input_schema["additionalProperties"]
|
|
180
|
+
unless additional_properties.nil? || additional_properties == false
|
|
181
|
+
raise Phronomy::ToolError,
|
|
182
|
+
"MCP tool #{tool_name.inspect} uses additionalProperties: " \
|
|
183
|
+
"#{additional_properties.inspect} (only false or omission is supported)"
|
|
184
|
+
end
|
|
149
185
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@stderr&.close
|
|
155
|
-
@stdin = nil
|
|
156
|
-
@stdout = nil
|
|
157
|
-
@stderr = nil
|
|
158
|
-
stderr_thread = @stderr_thread
|
|
159
|
-
stderr_op = @stderr_op
|
|
160
|
-
wait_thr = @wait_thr
|
|
161
|
-
@stderr_thread = nil
|
|
162
|
-
@stderr_op = nil
|
|
163
|
-
@wait_thr = nil
|
|
164
|
-
stderr_thread&.join(1)
|
|
165
|
-
begin
|
|
166
|
-
stderr_op&.blocking_wait(timeout: 1.0)
|
|
167
|
-
rescue
|
|
168
|
-
nil
|
|
186
|
+
properties = input_schema["properties"] || {}
|
|
187
|
+
unless properties.is_a?(Hash)
|
|
188
|
+
raise Phronomy::ToolError,
|
|
189
|
+
"MCP tool #{tool_name.inspect} has an invalid properties schema"
|
|
169
190
|
end
|
|
170
|
-
wait_thr&.join(5)
|
|
171
|
-
end
|
|
172
191
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
response = rpc_call("tools/list", {})
|
|
179
|
-
tools = response.dig("result", "tools") || []
|
|
180
|
-
defn = tools.find { |t| t["name"] == tool_name }
|
|
181
|
-
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
|
|
192
|
+
required_names = input_schema["required"] || []
|
|
193
|
+
unless required_names.is_a?(Array) && required_names.all? { |name| name.is_a?(String) }
|
|
194
|
+
raise Phronomy::ToolError,
|
|
195
|
+
"MCP tool #{tool_name.inspect} has an invalid required list"
|
|
196
|
+
end
|
|
182
197
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
unknown_required = required_names - properties.keys
|
|
199
|
+
if unknown_required.any?
|
|
200
|
+
raise Phronomy::ToolError,
|
|
201
|
+
"MCP tool #{tool_name.inspect} requires undefined parameters: " \
|
|
202
|
+
"#{unknown_required.inspect}"
|
|
203
|
+
end
|
|
189
204
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
# @param args [Hash]
|
|
193
|
-
# @return [Object] the tool result
|
|
194
|
-
# @api public
|
|
195
|
-
def call_tool(tool_name, args)
|
|
196
|
-
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
197
|
-
if response["error"]
|
|
198
|
-
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
199
|
-
raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
|
|
205
|
+
properties.each do |name, schema|
|
|
206
|
+
validate_property_schema!(tool_name, name, schema)
|
|
200
207
|
end
|
|
201
|
-
content = response.dig("result", "content")
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
content
|
|
209
|
+
if output_schema
|
|
210
|
+
warn_mcp(
|
|
211
|
+
"[Phronomy] MCP tool '#{tool_name}' has an output schema; " \
|
|
212
|
+
"Phronomy does not yet use it for validation"
|
|
213
|
+
)
|
|
209
214
|
end
|
|
210
215
|
end
|
|
211
216
|
|
|
212
|
-
|
|
217
|
+
def validate_property_schema!(tool_name, name, schema)
|
|
218
|
+
unless name.is_a?(String)
|
|
219
|
+
raise Phronomy::ToolError,
|
|
220
|
+
"MCP tool #{tool_name.inspect} has a non-string property key: #{name.inspect}"
|
|
221
|
+
end
|
|
222
|
+
unless schema.is_a?(Hash)
|
|
223
|
+
raise Phronomy::ToolError,
|
|
224
|
+
"MCP parameter #{name.inspect} must have an object schema"
|
|
225
|
+
end
|
|
213
226
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
popen3_opts = {}
|
|
219
|
-
popen3_opts[:chdir] = @cwd if @cwd
|
|
220
|
-
|
|
221
|
-
argv = @env ? [@env, *@command] : @command
|
|
222
|
-
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
|
|
223
|
-
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
224
|
-
# and deadlocking the child process. Errors inside the drain thread are
|
|
225
|
-
# silently ignored since stderr content is diagnostics-only.
|
|
226
|
-
#
|
|
227
|
-
# Prefer BlockingAdapterPool when a Runtime is configured so that this
|
|
228
|
-
# file eventually needs no direct Thread.new (Issue #360). Fall back to
|
|
229
|
-
# Thread.new when no pool is available (no EventLoop / bare invocation).
|
|
230
|
-
pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
|
|
231
|
-
if pool
|
|
232
|
-
@stderr_op = pool.submit {
|
|
233
|
-
begin
|
|
234
|
-
@stderr.read
|
|
235
|
-
rescue
|
|
236
|
-
nil
|
|
237
|
-
end
|
|
238
|
-
}
|
|
239
|
-
@stderr_thread = nil
|
|
240
|
-
else
|
|
241
|
-
@stderr_thread = Thread.new {
|
|
242
|
-
begin
|
|
243
|
-
@stderr.read
|
|
244
|
-
rescue
|
|
245
|
-
nil
|
|
246
|
-
end
|
|
247
|
-
}
|
|
248
|
-
@stderr_op = nil
|
|
227
|
+
type = schema["type"]
|
|
228
|
+
unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
|
|
229
|
+
raise Phronomy::ToolError,
|
|
230
|
+
"MCP parameter #{name.inspect} uses unsupported type #{type.inspect}"
|
|
249
231
|
end
|
|
250
232
|
|
|
251
|
-
if
|
|
252
|
-
|
|
253
|
-
|
|
233
|
+
if schema.key?("enum")
|
|
234
|
+
enum = schema["enum"]
|
|
235
|
+
unless enum.is_a?(Array)
|
|
254
236
|
raise Phronomy::ToolError,
|
|
255
|
-
"MCP
|
|
237
|
+
"MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
|
|
256
238
|
end
|
|
257
|
-
|
|
258
|
-
# which hangs indefinitely when the server emits no startup line or a
|
|
259
|
-
# partial line without '\n'. IO.select already confirmed the server is
|
|
260
|
-
# alive and responsive; the first rpc_call will consume actual output.
|
|
239
|
+
validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
|
|
261
240
|
end
|
|
262
|
-
end
|
|
263
241
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
242
|
+
ignored = IGNORED_PROPERTY_KEYS.select { |key| schema.key?(key) }
|
|
243
|
+
if ignored.any?
|
|
244
|
+
warn_mcp(
|
|
245
|
+
"[Phronomy] MCP tool '#{tool_name}' parameter '#{name}' has " \
|
|
246
|
+
"constraint keywords #{ignored.inspect}; they will be ignored"
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
unknown_property = schema.keys - SUPPORTED_PROPERTY_KEYS - IGNORED_PROPERTY_KEYS
|
|
251
|
+
if unknown_property.any?
|
|
269
252
|
raise Phronomy::ToolError,
|
|
270
|
-
"MCP
|
|
253
|
+
"MCP parameter #{name.inspect} uses unsupported schema keywords: " \
|
|
254
|
+
"#{unknown_property.join(", ")}"
|
|
271
255
|
end
|
|
272
|
-
raw = @stdout.gets
|
|
273
|
-
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
274
|
-
JSON.parse(raw)
|
|
275
256
|
end
|
|
276
257
|
|
|
277
|
-
def
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
param[:enum] = schema["enum"] if schema["enum"]
|
|
286
|
-
param
|
|
258
|
+
def validate_enum_values!(type, values, tool_name:, parameter_name:)
|
|
259
|
+
valid = values.all? do |value|
|
|
260
|
+
case type
|
|
261
|
+
when "string" then value.is_a?(String)
|
|
262
|
+
when "integer" then value.is_a?(Integer)
|
|
263
|
+
when "number" then value.is_a?(Numeric)
|
|
264
|
+
when "boolean" then value == true || value == false
|
|
265
|
+
end
|
|
287
266
|
end
|
|
267
|
+
return if valid
|
|
268
|
+
|
|
269
|
+
raise Phronomy::ToolError,
|
|
270
|
+
"MCP tool #{tool_name.inspect} parameter #{parameter_name.inspect} " \
|
|
271
|
+
"has enum values incompatible with #{type}"
|
|
288
272
|
end
|
|
289
|
-
end
|
|
290
273
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
#
|
|
298
|
-
# @example
|
|
299
|
-
# tool = Phronomy::Tools::Mcp.from_server(
|
|
300
|
-
# "http://localhost:8080/mcp",
|
|
301
|
-
# tool_name: "weather_lookup"
|
|
302
|
-
# )
|
|
303
|
-
class HttpTransport
|
|
304
|
-
# @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
|
|
305
|
-
# @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
|
|
306
|
-
# @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
|
|
307
|
-
# @param headers [Hash] additional HTTP request headers (e.g. Authorization).
|
|
308
|
-
# Merged on top of the default Content-Type and Accept headers; caller-supplied
|
|
309
|
-
# values override defaults when keys collide.
|
|
310
|
-
# @api public
|
|
311
|
-
def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
|
|
312
|
-
@uri = URI.parse(base_url)
|
|
313
|
-
@open_timeout = open_timeout
|
|
314
|
-
@read_timeout = read_timeout
|
|
315
|
-
@extra_headers = headers
|
|
274
|
+
def warn_mcp(message)
|
|
275
|
+
if Phronomy.configuration.logger
|
|
276
|
+
Phronomy.configuration.logger.warn(message)
|
|
277
|
+
else
|
|
278
|
+
Kernel.warn(message)
|
|
279
|
+
end
|
|
316
280
|
end
|
|
317
281
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
282
|
+
def close_transport_safely(transport)
|
|
283
|
+
transport&.close
|
|
284
|
+
rescue
|
|
285
|
+
nil
|
|
321
286
|
end
|
|
287
|
+
end
|
|
322
288
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
tools = response.dig("result", "tools") || []
|
|
330
|
-
defn = tools.find { |t| t["name"] == tool_name }
|
|
331
|
-
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
|
|
289
|
+
# @api private
|
|
290
|
+
def initialize
|
|
291
|
+
@mcp_call_mutex = Mutex.new
|
|
292
|
+
@mcp_client = nil
|
|
293
|
+
build_and_connect_client!
|
|
294
|
+
end
|
|
332
295
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
296
|
+
# Executes the remote MCP tool.
|
|
297
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
298
|
+
# @return [String, Array, Hash]
|
|
299
|
+
# @api public
|
|
300
|
+
def execute(cancellation_token: nil, **args)
|
|
301
|
+
@mcp_call_mutex.synchronize do
|
|
302
|
+
ensure_mcp_client!
|
|
303
|
+
perform_mcp_call(cancellation_token: cancellation_token, args: args)
|
|
338
304
|
end
|
|
305
|
+
end
|
|
339
306
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
content = response.dig("result", "content")
|
|
307
|
+
# Closes the currently connected client synchronously. A transport already
|
|
308
|
+
# detached after cancellation is owned by the Runtime cleanup pool and is
|
|
309
|
+
# drained during Runtime shutdown; this method does not wait for that older
|
|
310
|
+
# cleanup operation.
|
|
311
|
+
#
|
|
312
|
+
# The instance can be used again after close; the next call reconnects.
|
|
313
|
+
# @return [void]
|
|
314
|
+
# @api public
|
|
315
|
+
def close
|
|
316
|
+
@mcp_call_mutex.synchronize { invalidate_mcp_client! }
|
|
317
|
+
end
|
|
352
318
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def perform_mcp_call(cancellation_token:, args:)
|
|
322
|
+
mcp_cancellation = build_mcp_cancellation(cancellation_token)
|
|
323
|
+
response = begin
|
|
324
|
+
@mcp_client.call_tool(
|
|
325
|
+
name: self.class.tool_name,
|
|
326
|
+
arguments: args.transform_keys(&:to_s),
|
|
327
|
+
cancellation: mcp_cancellation
|
|
328
|
+
)
|
|
329
|
+
rescue MCP::CancelledError => e
|
|
330
|
+
invalidate_mcp_client_after_cancellation!
|
|
331
|
+
message = "MCP tool call was cancelled"
|
|
332
|
+
message += ": #{e.reason}" if e.respond_to?(:reason) && e.reason
|
|
333
|
+
raise Phronomy::CancellationError, message
|
|
334
|
+
rescue MCP::Client::SessionExpiredError
|
|
335
|
+
recover_expired_session!
|
|
336
|
+
rescue MCP::Client::ServerError => e
|
|
337
|
+
raise Phronomy::ToolError,
|
|
338
|
+
"MCP server returned error (#{e.code}): #{e.message}"
|
|
339
|
+
rescue MCP::Client::InputRequiredError => e
|
|
340
|
+
raise Phronomy::ToolError,
|
|
341
|
+
"MCP tool requires unsupported multi-round-trip input: #{e.message}"
|
|
342
|
+
rescue MCP::Client::ValidationError => e
|
|
343
|
+
raise Phronomy::ToolError,
|
|
344
|
+
"MCP response validation failed: #{e.message}"
|
|
345
|
+
rescue MCP::Client::RequestHandlerError => e
|
|
346
|
+
raise Phronomy::ToolError,
|
|
347
|
+
"MCP request handler failed: #{e.message}"
|
|
348
|
+
rescue => e
|
|
349
|
+
raise Phronomy::ToolError, "MCP call failed: #{e.message}"
|
|
359
350
|
end
|
|
360
351
|
|
|
361
|
-
|
|
352
|
+
result = validate_call_tool_response!(response)
|
|
353
|
+
format_tool_result(result)
|
|
354
|
+
end
|
|
362
355
|
|
|
363
|
-
|
|
364
|
-
|
|
356
|
+
def build_mcp_cancellation(cancellation_token)
|
|
357
|
+
return nil unless cancellation_token
|
|
365
358
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
359
|
+
mcp_cancellation = MCP::Cancellation.new
|
|
360
|
+
cancellation_token.on_cancel do
|
|
361
|
+
mcp_cancellation.cancel(reason: "phronomy_cancelled")
|
|
362
|
+
end
|
|
363
|
+
mcp_cancellation
|
|
364
|
+
end
|
|
370
365
|
|
|
371
|
-
|
|
372
|
-
|
|
366
|
+
def recover_expired_session!
|
|
367
|
+
begin
|
|
368
|
+
@mcp_client.connect
|
|
369
|
+
rescue => reconnect_error
|
|
370
|
+
invalidate_mcp_client!
|
|
371
|
+
raise Phronomy::ToolError,
|
|
372
|
+
"MCP session expired and reconnection failed: #{reconnect_error.message}"
|
|
373
|
+
end
|
|
373
374
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
@extra_headers.each { |k, v| request[k.to_s] = v.to_s }
|
|
378
|
-
request.body = payload
|
|
375
|
+
raise Phronomy::ToolError,
|
|
376
|
+
"MCP session expired; the connection was restored, but the tool call was not replayed"
|
|
377
|
+
end
|
|
379
378
|
|
|
380
|
-
|
|
379
|
+
def validate_call_tool_response!(response)
|
|
380
|
+
unless response.is_a?(Hash)
|
|
381
|
+
raise Phronomy::ToolError, "MCP tool returned a non-object response"
|
|
382
|
+
end
|
|
381
383
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
384
|
+
result = response["result"]
|
|
385
|
+
unless result.is_a?(Hash)
|
|
386
|
+
raise Phronomy::ToolError, "MCP tool response is missing a valid result"
|
|
387
|
+
end
|
|
386
388
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
content = result["content"]
|
|
390
|
+
unless content.is_a?(Array)
|
|
391
|
+
raise Phronomy::ToolError, "MCP tool result is missing valid content"
|
|
392
|
+
end
|
|
393
|
+
unless content.all? { |item| item.is_a?(Hash) }
|
|
394
|
+
raise Phronomy::ToolError,
|
|
395
|
+
"MCP tool result contains an invalid content item"
|
|
396
|
+
end
|
|
397
|
+
if result.key?("isError") && result["isError"] != true && result["isError"] != false
|
|
398
|
+
raise Phronomy::ToolError, "MCP tool result has an invalid isError value"
|
|
393
399
|
end
|
|
394
400
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
data = line.delete_prefix("data: ")
|
|
404
|
-
next if data == "[DONE]"
|
|
405
|
-
|
|
406
|
-
begin
|
|
407
|
-
parsed = JSON.parse(data)
|
|
408
|
-
result = parsed if parsed.is_a?(Hash) && parsed["jsonrpc"]
|
|
409
|
-
rescue JSON::ParserError
|
|
410
|
-
next
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
|
|
401
|
+
result
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def format_tool_result(result)
|
|
405
|
+
content = result.fetch("content")
|
|
406
|
+
texts = content.filter_map do |item|
|
|
407
|
+
item["text"] if item["type"] == "text" && item["text"].is_a?(String)
|
|
414
408
|
end
|
|
415
409
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
410
|
+
if result["isError"] == true
|
|
411
|
+
message = texts.join("\n")
|
|
412
|
+
message = JSON.generate(result["structuredContent"] || content) if message.empty?
|
|
413
|
+
return "MCP tool execution error: #{message}"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
return texts.first if texts.length == 1
|
|
417
|
+
return texts if texts.any?
|
|
418
|
+
|
|
419
|
+
result["structuredContent"] || content
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def ensure_mcp_client!
|
|
423
|
+
build_and_connect_client! unless @mcp_client
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def build_and_connect_client!
|
|
427
|
+
transport = nil
|
|
428
|
+
begin
|
|
429
|
+
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
430
|
+
headers = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
431
|
+
transport = self.class.send(:build_transport, uri, headers: headers)
|
|
432
|
+
client = MCP::Client.new(transport: transport)
|
|
433
|
+
client.connect
|
|
434
|
+
@mcp_client = client
|
|
435
|
+
rescue => e
|
|
436
|
+
self.class.send(:close_transport_safely, transport)
|
|
437
|
+
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def invalidate_mcp_client!
|
|
442
|
+
old_client = @mcp_client
|
|
443
|
+
@mcp_client = nil
|
|
444
|
+
self.class.send(:close_transport_safely, old_client&.transport)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def invalidate_mcp_client_after_cancellation!
|
|
448
|
+
old_client = @mcp_client
|
|
449
|
+
@mcp_client = nil
|
|
450
|
+
return unless old_client
|
|
451
|
+
|
|
452
|
+
schedule_transport_cleanup(old_client.transport)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def schedule_transport_cleanup(transport)
|
|
456
|
+
cleanup_pool = Phronomy::Runtime.instance.pool(
|
|
457
|
+
:mcp_cleanup,
|
|
458
|
+
size: MCP_CLEANUP_POOL_SIZE,
|
|
459
|
+
queue_size: MCP_CLEANUP_QUEUE_SIZE
|
|
460
|
+
)
|
|
461
|
+
cleanup_pool.submit(on_full: :raise) do
|
|
462
|
+
self.class.send(:close_transport_safely, transport)
|
|
427
463
|
end
|
|
464
|
+
rescue Phronomy::BackpressureError, Phronomy::PoolShutdownError
|
|
465
|
+
# During shutdown or an exceptional cleanup burst, prefer a bounded
|
|
466
|
+
# synchronous fallback over leaking the child process/socket.
|
|
467
|
+
self.class.send(:close_transport_safely, transport)
|
|
428
468
|
end
|
|
429
469
|
end
|
|
430
470
|
end
|