phronomy 0.13.0 → 0.15.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 +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- 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 +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- 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/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- 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,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "mcp"
|
|
4
5
|
require "shellwords"
|
|
5
6
|
require "uri"
|
|
@@ -9,51 +10,73 @@ module Phronomy
|
|
|
9
10
|
# A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
|
|
10
11
|
# MCP (Model Context Protocol) server.
|
|
11
12
|
#
|
|
12
|
-
# Uses the official MCP Ruby SDK
|
|
13
|
-
#
|
|
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.
|
|
14
15
|
#
|
|
15
16
|
# Supports two transport schemes:
|
|
16
17
|
# - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
|
|
17
18
|
# - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
|
|
18
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.
|
|
23
|
+
#
|
|
19
24
|
# @example
|
|
20
25
|
# web_search = Phronomy::Tools::Mcp.from_server(
|
|
21
26
|
# "stdio://./mcp-server",
|
|
22
27
|
# tool_name: "search_web"
|
|
23
28
|
# )
|
|
24
|
-
# agent = MyAgent.new
|
|
25
29
|
# agent_class.tools(web_search)
|
|
26
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
|
+
|
|
27
53
|
class << self
|
|
28
54
|
# Build a Mcp instance by querying a running MCP server for the
|
|
29
55
|
# tool definition identified by +tool_name+.
|
|
30
56
|
#
|
|
57
|
+
# +additionalProperties+ omitted from the remote schema is accepted, but
|
|
58
|
+
# Phronomy still exposes and accepts only parameters declared in +properties+.
|
|
59
|
+
#
|
|
31
60
|
# @param server_uri [String] URI of the MCP server.
|
|
32
|
-
# Supported schemes:
|
|
33
|
-
# - "stdio://<command>" — spawn a child process
|
|
34
|
-
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
35
61
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
36
|
-
# @param headers [Hash] additional HTTP
|
|
37
|
-
#
|
|
38
|
-
# Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
|
|
39
|
-
# @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
|
|
40
64
|
# @api public
|
|
41
65
|
def from_server(server_uri, tool_name:, headers: {})
|
|
42
|
-
|
|
43
|
-
# Each Mcp instance creates its own client so concurrent agent threads
|
|
44
|
-
# never share IO streams, eliminating the need for synchronisation.
|
|
45
|
-
transport = build_transport(server_uri, headers: headers)
|
|
46
|
-
client = MCP::Client.new(transport: transport)
|
|
66
|
+
transport = nil
|
|
47
67
|
begin
|
|
68
|
+
transport = build_transport(server_uri, headers: headers)
|
|
69
|
+
client = MCP::Client.new(transport: transport)
|
|
48
70
|
client.connect
|
|
49
71
|
tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
|
|
50
|
-
rescue ArgumentError
|
|
72
|
+
rescue ArgumentError, Phronomy::ToolError
|
|
51
73
|
raise
|
|
52
74
|
rescue => e
|
|
53
75
|
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
54
76
|
ensure
|
|
55
|
-
transport
|
|
77
|
+
close_transport_safely(transport)
|
|
56
78
|
end
|
|
79
|
+
|
|
57
80
|
build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
|
|
58
81
|
end
|
|
59
82
|
|
|
@@ -63,21 +86,38 @@ module Phronomy
|
|
|
63
86
|
scheme, path = uri.split("://", 2)
|
|
64
87
|
case scheme
|
|
65
88
|
when "stdio"
|
|
66
|
-
argv = Shellwords.split(path)
|
|
89
|
+
argv = Shellwords.split(path.to_s)
|
|
90
|
+
if argv.empty? || argv[0].to_s.empty?
|
|
91
|
+
raise ArgumentError, "MCP stdio URI must include a command"
|
|
92
|
+
end
|
|
93
|
+
|
|
67
94
|
MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
|
|
68
95
|
when "http", "https"
|
|
69
96
|
MCP::Client::HTTP.new(url: uri, headers: headers)
|
|
70
97
|
else
|
|
71
|
-
raise ArgumentError,
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"Unsupported MCP transport scheme: #{scheme.inspect}. " \
|
|
100
|
+
"Supported: 'stdio://', 'http://', 'https://'."
|
|
72
101
|
end
|
|
73
102
|
end
|
|
74
103
|
|
|
75
104
|
def extract_tool_def(client, tool_name, server_uri)
|
|
76
|
-
mcp_tool = client.tools.find { |
|
|
77
|
-
|
|
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}"
|
|
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", [])
|
|
78
120
|
|
|
79
|
-
properties = mcp_tool.input_schema&.dig("properties") || {}
|
|
80
|
-
required_names = mcp_tool.input_schema&.dig("required") || []
|
|
81
121
|
{
|
|
82
122
|
description: mcp_tool.description || tool_name,
|
|
83
123
|
parameters: parse_schema_params(properties, required_names: required_names)
|
|
@@ -87,63 +127,19 @@ module Phronomy
|
|
|
87
127
|
def build_tool_class(tool_name, server_uri, tool_def, headers: {})
|
|
88
128
|
klass = Class.new(Mcp)
|
|
89
129
|
klass.tool_name(tool_name)
|
|
130
|
+
klass.requires_approval true
|
|
90
131
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
91
|
-
klass.instance_variable_set(:@mcp_headers, headers)
|
|
132
|
+
klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
|
|
92
133
|
|
|
93
|
-
# Register description and params from the MCP tool definition.
|
|
94
134
|
klass.description(tool_def[:description] || tool_name)
|
|
95
|
-
(tool_def[:parameters] || []).each do |
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# never share IO streams.
|
|
104
|
-
klass.define_method(:initialize) do
|
|
105
|
-
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
106
|
-
hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
107
|
-
transport = self.class.send(:build_transport, uri, headers: hdrs)
|
|
108
|
-
@mcp_client = MCP::Client.new(transport: transport)
|
|
109
|
-
@mcp_client.connect
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
klass.define_method(:execute) do |cancellation_token: nil, **args|
|
|
113
|
-
# Bridge Phronomy::CancellationToken to MCP::Cancellation so that
|
|
114
|
-
# explicit cancel! calls propagate into the in-flight MCP request.
|
|
115
|
-
# Deadline-based expiry is handled cooperatively by BlockingAdapterPool
|
|
116
|
-
# (the worker slot is marked abandoned); no extra handling is needed here.
|
|
117
|
-
mcp_cancel = nil
|
|
118
|
-
if cancellation_token
|
|
119
|
-
mcp_cancel = MCP::Cancellation.new
|
|
120
|
-
cancellation_token.on_cancel { mcp_cancel.cancel(reason: "phronomy_cancelled") }
|
|
121
|
-
end
|
|
122
|
-
begin
|
|
123
|
-
response = @mcp_client.call_tool(
|
|
124
|
-
name: tool_name,
|
|
125
|
-
arguments: args.transform_keys(&:to_s),
|
|
126
|
-
cancellation: mcp_cancel
|
|
127
|
-
)
|
|
128
|
-
rescue => e
|
|
129
|
-
raise Phronomy::ToolError, "MCP call failed: #{e.message}"
|
|
130
|
-
end
|
|
131
|
-
if response["error"]
|
|
132
|
-
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
133
|
-
raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
|
|
134
|
-
end
|
|
135
|
-
content = response.dig("result", "content")
|
|
136
|
-
if content.is_a?(Array)
|
|
137
|
-
texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
|
|
138
|
-
(texts.length == 1) ? texts.first : texts
|
|
139
|
-
else
|
|
140
|
-
content
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Allow callers to deterministically shut down the underlying transport.
|
|
145
|
-
klass.define_method(:close) do
|
|
146
|
-
@mcp_client.transport.close
|
|
135
|
+
(tool_def[:parameters] || []).each do |parameter|
|
|
136
|
+
options = {
|
|
137
|
+
type: parameter.fetch(:type).to_sym,
|
|
138
|
+
desc: parameter[:description].to_s,
|
|
139
|
+
required: parameter.fetch(:required, false)
|
|
140
|
+
}
|
|
141
|
+
options[:enum] = parameter[:enum] if parameter.key?(:enum)
|
|
142
|
+
klass.param(parameter.fetch(:name).to_sym, **options)
|
|
147
143
|
end
|
|
148
144
|
|
|
149
145
|
klass
|
|
@@ -151,16 +147,349 @@ module Phronomy
|
|
|
151
147
|
|
|
152
148
|
def parse_schema_params(properties, required_names: [])
|
|
153
149
|
properties.map do |name, schema|
|
|
154
|
-
|
|
150
|
+
parameter = {
|
|
155
151
|
name: name.to_s,
|
|
156
|
-
type: schema
|
|
152
|
+
type: schema.fetch("type"),
|
|
157
153
|
description: schema["description"].to_s,
|
|
158
154
|
required: required_names.include?(name.to_s)
|
|
159
155
|
}
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
parameter[:enum] = schema["enum"] if schema.key?("enum")
|
|
157
|
+
parameter
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_supported_schema!(input_schema, output_schema:, tool_name:)
|
|
162
|
+
unless input_schema.is_a?(Hash) && input_schema["type"] == "object"
|
|
163
|
+
raise Phronomy::ToolError,
|
|
164
|
+
"MCP tool #{tool_name.inspect} must use an object input schema"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
dialect = input_schema["$schema"]
|
|
168
|
+
if dialect && !SUPPORTED_SCHEMA_DIALECTS.include?(dialect)
|
|
169
|
+
raise Phronomy::ToolError,
|
|
170
|
+
"MCP tool #{tool_name.inspect} uses unsupported JSON Schema dialect #{dialect.inspect}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
unknown_root = input_schema.keys - SUPPORTED_ROOT_KEYS
|
|
174
|
+
if unknown_root.any?
|
|
175
|
+
raise Phronomy::ToolError,
|
|
176
|
+
"MCP tool #{tool_name.inspect} uses unsupported root schema keywords: " \
|
|
177
|
+
"#{unknown_root.join(", ")}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
additional_properties = input_schema["additionalProperties"]
|
|
181
|
+
unless additional_properties.nil? || additional_properties == false
|
|
182
|
+
raise Phronomy::ToolError,
|
|
183
|
+
"MCP tool #{tool_name.inspect} uses additionalProperties: " \
|
|
184
|
+
"#{additional_properties.inspect} (only false or omission is supported)"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
properties = input_schema["properties"] || {}
|
|
188
|
+
unless properties.is_a?(Hash)
|
|
189
|
+
raise Phronomy::ToolError,
|
|
190
|
+
"MCP tool #{tool_name.inspect} has an invalid properties schema"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
required_names = input_schema["required"] || []
|
|
194
|
+
unless required_names.is_a?(Array) && required_names.all? { |name| name.is_a?(String) }
|
|
195
|
+
raise Phronomy::ToolError,
|
|
196
|
+
"MCP tool #{tool_name.inspect} has an invalid required list"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
unknown_required = required_names - properties.keys
|
|
200
|
+
if unknown_required.any?
|
|
201
|
+
raise Phronomy::ToolError,
|
|
202
|
+
"MCP tool #{tool_name.inspect} requires undefined parameters: " \
|
|
203
|
+
"#{unknown_required.inspect}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
properties.each do |name, schema|
|
|
207
|
+
validate_property_schema!(tool_name, name, schema)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if output_schema
|
|
211
|
+
warn_mcp(
|
|
212
|
+
"[Phronomy] MCP tool '#{tool_name}' has an output schema; " \
|
|
213
|
+
"Phronomy does not yet use it for validation"
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_property_schema!(tool_name, name, schema)
|
|
219
|
+
unless name.is_a?(String)
|
|
220
|
+
raise Phronomy::ToolError,
|
|
221
|
+
"MCP tool #{tool_name.inspect} has a non-string property key: #{name.inspect}"
|
|
222
|
+
end
|
|
223
|
+
unless schema.is_a?(Hash)
|
|
224
|
+
raise Phronomy::ToolError,
|
|
225
|
+
"MCP parameter #{name.inspect} must have an object schema"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
type = schema["type"]
|
|
229
|
+
unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
|
|
230
|
+
raise Phronomy::ToolError,
|
|
231
|
+
"MCP parameter #{name.inspect} uses unsupported type #{type.inspect}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
if schema.key?("enum")
|
|
235
|
+
enum = schema["enum"]
|
|
236
|
+
unless enum.is_a?(Array)
|
|
237
|
+
raise Phronomy::ToolError,
|
|
238
|
+
"MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
|
|
239
|
+
end
|
|
240
|
+
validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
ignored = IGNORED_PROPERTY_KEYS.select { |key| schema.key?(key) }
|
|
244
|
+
if ignored.any?
|
|
245
|
+
warn_mcp(
|
|
246
|
+
"[Phronomy] MCP tool '#{tool_name}' parameter '#{name}' has " \
|
|
247
|
+
"constraint keywords #{ignored.inspect}; they will be ignored"
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
unknown_property = schema.keys - SUPPORTED_PROPERTY_KEYS - IGNORED_PROPERTY_KEYS
|
|
252
|
+
if unknown_property.any?
|
|
253
|
+
raise Phronomy::ToolError,
|
|
254
|
+
"MCP parameter #{name.inspect} uses unsupported schema keywords: " \
|
|
255
|
+
"#{unknown_property.join(", ")}"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_enum_values!(type, values, tool_name:, parameter_name:)
|
|
260
|
+
valid = values.all? do |value|
|
|
261
|
+
case type
|
|
262
|
+
when "string" then value.is_a?(String)
|
|
263
|
+
when "integer" then value.is_a?(Integer)
|
|
264
|
+
when "number" then value.is_a?(Numeric)
|
|
265
|
+
when "boolean" then value == true || value == false
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
return if valid
|
|
269
|
+
|
|
270
|
+
raise Phronomy::ToolError,
|
|
271
|
+
"MCP tool #{tool_name.inspect} parameter #{parameter_name.inspect} " \
|
|
272
|
+
"has enum values incompatible with #{type}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def warn_mcp(message)
|
|
276
|
+
if Phronomy.configuration.logger
|
|
277
|
+
Phronomy.configuration.logger.warn(message)
|
|
278
|
+
else
|
|
279
|
+
Kernel.warn(message)
|
|
162
280
|
end
|
|
163
281
|
end
|
|
282
|
+
|
|
283
|
+
def close_transport_safely(transport)
|
|
284
|
+
transport&.close
|
|
285
|
+
rescue
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# MCP Tools fail closed by default and identify their remote origin.
|
|
291
|
+
def tool_origin
|
|
292
|
+
:mcp
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def approval_metadata
|
|
296
|
+
scheme, rest = self.class.instance_variable_get(:@mcp_server_uri).to_s.split("://", 2)
|
|
297
|
+
server_origin = case scheme
|
|
298
|
+
when "http", "https"
|
|
299
|
+
uri = URI.parse("#{scheme}://#{rest}")
|
|
300
|
+
default_port = (scheme == "https") ? 443 : 80
|
|
301
|
+
suffix = (uri.port == default_port) ? "" : ":#{uri.port}"
|
|
302
|
+
"#{scheme}://#{uri.host}#{suffix}"
|
|
303
|
+
when "stdio"
|
|
304
|
+
command = Shellwords.split(rest.to_s).first
|
|
305
|
+
"stdio://#{command}"
|
|
306
|
+
else
|
|
307
|
+
scheme.to_s
|
|
308
|
+
end
|
|
309
|
+
{transport: scheme&.to_sym, server_origin: server_origin}
|
|
310
|
+
rescue URI::InvalidURIError
|
|
311
|
+
{transport: :unknown, server_origin: "unknown"}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# @api private
|
|
315
|
+
def initialize
|
|
316
|
+
@mcp_call_mutex = Mutex.new
|
|
317
|
+
@mcp_client = nil
|
|
318
|
+
build_and_connect_client!
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Executes the remote MCP tool.
|
|
322
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
323
|
+
# @return [String, Array, Hash]
|
|
324
|
+
# @api public
|
|
325
|
+
def execute(cancellation_token: nil, **args)
|
|
326
|
+
@mcp_call_mutex.synchronize do
|
|
327
|
+
ensure_mcp_client!
|
|
328
|
+
perform_mcp_call(cancellation_token: cancellation_token, args: args)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Closes the currently connected client synchronously. A transport already
|
|
333
|
+
# detached after cancellation is owned by the Runtime cleanup pool and is
|
|
334
|
+
# drained during Runtime shutdown; this method does not wait for that older
|
|
335
|
+
# cleanup operation.
|
|
336
|
+
#
|
|
337
|
+
# The instance can be used again after close; the next call reconnects.
|
|
338
|
+
# @return [void]
|
|
339
|
+
# @api public
|
|
340
|
+
def close
|
|
341
|
+
@mcp_call_mutex.synchronize { invalidate_mcp_client! }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
private
|
|
345
|
+
|
|
346
|
+
def perform_mcp_call(cancellation_token:, args:)
|
|
347
|
+
mcp_cancellation = build_mcp_cancellation(cancellation_token)
|
|
348
|
+
response = begin
|
|
349
|
+
@mcp_client.call_tool(
|
|
350
|
+
name: self.class.tool_name,
|
|
351
|
+
arguments: args.transform_keys(&:to_s),
|
|
352
|
+
cancellation: mcp_cancellation
|
|
353
|
+
)
|
|
354
|
+
rescue MCP::CancelledError => e
|
|
355
|
+
invalidate_mcp_client_after_cancellation!
|
|
356
|
+
message = "MCP tool call was cancelled"
|
|
357
|
+
message += ": #{e.reason}" if e.respond_to?(:reason) && e.reason
|
|
358
|
+
raise Phronomy::CancellationError, message
|
|
359
|
+
rescue MCP::Client::SessionExpiredError
|
|
360
|
+
recover_expired_session!
|
|
361
|
+
rescue MCP::Client::ServerError => e
|
|
362
|
+
raise Phronomy::ToolError,
|
|
363
|
+
"MCP server returned error (#{e.code}): #{e.message}"
|
|
364
|
+
rescue MCP::Client::InputRequiredError => e
|
|
365
|
+
raise Phronomy::ToolError,
|
|
366
|
+
"MCP tool requires unsupported multi-round-trip input: #{e.message}"
|
|
367
|
+
rescue MCP::Client::ValidationError => e
|
|
368
|
+
raise Phronomy::ToolError,
|
|
369
|
+
"MCP response validation failed: #{e.message}"
|
|
370
|
+
rescue MCP::Client::RequestHandlerError => e
|
|
371
|
+
raise Phronomy::ToolError,
|
|
372
|
+
"MCP request handler failed: #{e.message}"
|
|
373
|
+
rescue => e
|
|
374
|
+
raise Phronomy::ToolError, "MCP call failed: #{e.message}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
result = validate_call_tool_response!(response)
|
|
378
|
+
format_tool_result(result)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def build_mcp_cancellation(cancellation_token)
|
|
382
|
+
return nil unless cancellation_token
|
|
383
|
+
|
|
384
|
+
mcp_cancellation = MCP::Cancellation.new
|
|
385
|
+
cancellation_token.on_cancel do
|
|
386
|
+
mcp_cancellation.cancel(reason: "phronomy_cancelled")
|
|
387
|
+
end
|
|
388
|
+
mcp_cancellation
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def recover_expired_session!
|
|
392
|
+
begin
|
|
393
|
+
@mcp_client.connect
|
|
394
|
+
rescue => reconnect_error
|
|
395
|
+
invalidate_mcp_client!
|
|
396
|
+
raise Phronomy::ToolError,
|
|
397
|
+
"MCP session expired and reconnection failed: #{reconnect_error.message}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
raise Phronomy::ToolError,
|
|
401
|
+
"MCP session expired; the connection was restored, but the tool call was not replayed"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def validate_call_tool_response!(response)
|
|
405
|
+
unless response.is_a?(Hash)
|
|
406
|
+
raise Phronomy::ToolError, "MCP tool returned a non-object response"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
result = response["result"]
|
|
410
|
+
unless result.is_a?(Hash)
|
|
411
|
+
raise Phronomy::ToolError, "MCP tool response is missing a valid result"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
content = result["content"]
|
|
415
|
+
unless content.is_a?(Array)
|
|
416
|
+
raise Phronomy::ToolError, "MCP tool result is missing valid content"
|
|
417
|
+
end
|
|
418
|
+
unless content.all? { |item| item.is_a?(Hash) }
|
|
419
|
+
raise Phronomy::ToolError,
|
|
420
|
+
"MCP tool result contains an invalid content item"
|
|
421
|
+
end
|
|
422
|
+
if result.key?("isError") && result["isError"] != true && result["isError"] != false
|
|
423
|
+
raise Phronomy::ToolError, "MCP tool result has an invalid isError value"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
result
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def format_tool_result(result)
|
|
430
|
+
content = result.fetch("content")
|
|
431
|
+
texts = content.filter_map do |item|
|
|
432
|
+
item["text"] if item["type"] == "text" && item["text"].is_a?(String)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
if result["isError"] == true
|
|
436
|
+
message = texts.join("\n")
|
|
437
|
+
message = JSON.generate(result["structuredContent"] || content) if message.empty?
|
|
438
|
+
return "MCP tool execution error: #{message}"
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
return texts.first if texts.length == 1
|
|
442
|
+
return texts if texts.any?
|
|
443
|
+
|
|
444
|
+
result["structuredContent"] || content
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def ensure_mcp_client!
|
|
448
|
+
build_and_connect_client! unless @mcp_client
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def build_and_connect_client!
|
|
452
|
+
transport = nil
|
|
453
|
+
begin
|
|
454
|
+
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
455
|
+
headers = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
456
|
+
transport = self.class.send(:build_transport, uri, headers: headers)
|
|
457
|
+
client = MCP::Client.new(transport: transport)
|
|
458
|
+
client.connect
|
|
459
|
+
@mcp_client = client
|
|
460
|
+
rescue => e
|
|
461
|
+
self.class.send(:close_transport_safely, transport)
|
|
462
|
+
raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def invalidate_mcp_client!
|
|
467
|
+
old_client = @mcp_client
|
|
468
|
+
@mcp_client = nil
|
|
469
|
+
self.class.send(:close_transport_safely, old_client&.transport)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def invalidate_mcp_client_after_cancellation!
|
|
473
|
+
old_client = @mcp_client
|
|
474
|
+
@mcp_client = nil
|
|
475
|
+
return unless old_client
|
|
476
|
+
|
|
477
|
+
schedule_transport_cleanup(old_client.transport)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def schedule_transport_cleanup(transport)
|
|
481
|
+
cleanup_pool = Phronomy::Runtime.instance.pool(
|
|
482
|
+
:mcp_cleanup,
|
|
483
|
+
size: MCP_CLEANUP_POOL_SIZE,
|
|
484
|
+
queue_size: MCP_CLEANUP_QUEUE_SIZE
|
|
485
|
+
)
|
|
486
|
+
cleanup_pool.submit(on_full: :raise) do
|
|
487
|
+
self.class.send(:close_transport_safely, transport)
|
|
488
|
+
end
|
|
489
|
+
rescue Phronomy::BackpressureError, Phronomy::PoolShutdownError
|
|
490
|
+
# During shutdown or an exceptional cleanup burst, prefer a bounded
|
|
491
|
+
# synchronous fallback over leaking the child process/socket.
|
|
492
|
+
self.class.send(:close_transport_safely, transport)
|
|
164
493
|
end
|
|
165
494
|
end
|
|
166
495
|
end
|
data/lib/phronomy/version.rb
CHANGED