activeagent 1.8.1 → 1.9.1
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 +1058 -0
- data/README.md +9 -1
- data/lib/active_agent/base.rb +22 -1
- data/lib/active_agent/concerns/delegation.rb +4 -0
- data/lib/active_agent/concerns/input_requests.rb +178 -0
- data/lib/active_agent/concerns/parameterized.rb +7 -4
- data/lib/active_agent/concerns/provider.rb +1 -0
- data/lib/active_agent/concerns/tooling.rb +7 -2
- data/lib/active_agent/delegation/runner.rb +31 -0
- data/lib/active_agent/evals/catalog.rb +305 -0
- data/lib/active_agent/evals/ruby_llm.rb +411 -0
- data/lib/active_agent/evals/scenario.rb +10 -3
- data/lib/active_agent/evals/scenario_parser.rb +38 -10
- data/lib/active_agent/evals.rb +1 -0
- data/lib/active_agent/generation.rb +62 -2
- data/lib/active_agent/generation_job.rb +14 -3
- data/lib/active_agent/input_request/resume.rb +199 -0
- data/lib/active_agent/input_request.rb +279 -0
- data/lib/active_agent/providers/_base_provider.rb +153 -31
- data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
- data/lib/active_agent/providers/anthropic_provider.rb +102 -20
- data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
- data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
- data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
- data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
- data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
- data/lib/active_agent/providers/deepseek/_types.rb +4 -0
- data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
- data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
- data/lib/active_agent/providers/deepseek/options.rb +53 -0
- data/lib/active_agent/providers/deepseek_provider.rb +46 -0
- data/lib/active_agent/providers/log_subscriber.rb +13 -0
- data/lib/active_agent/providers/mcp_bridge.rb +691 -0
- data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
- data/lib/active_agent/providers/open_ai/_base.rb +1 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
- data/lib/active_agent/providers/open_ai/options.rb +1 -1
- data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
- data/lib/active_agent/providers/open_router/options.rb +6 -2
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
- data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
- data/lib/active_agent/schema_tools.rb +12 -0
- data/lib/active_agent/telemetry/instrumentation.rb +10 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/schema_tools/USAGE +11 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
- metadata +31 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "active_support/core_ext/hash/keys"
|
|
8
|
+
require "active_support/core_ext/object/deep_dup"
|
|
9
|
+
require_relative "mcp_tool_cache"
|
|
10
|
+
|
|
11
|
+
module ActiveAgent
|
|
12
|
+
module Providers
|
|
13
|
+
# Runs MCP servers from the client side.
|
|
14
|
+
#
|
|
15
|
+
# Declaring `mcps:` is normally passthrough: the declaration is translated
|
|
16
|
+
# into the provider's own `mcp_servers` parameter and the provider runs the
|
|
17
|
+
# tool loop. That works only where the provider implements MCP. DeepSeek does
|
|
18
|
+
# not — it ignores `mcp_servers`, returns 200, and answers without the
|
|
19
|
+
# server's data, so the failure reads as a poor answer rather than as a
|
|
20
|
+
# configuration error.
|
|
21
|
+
#
|
|
22
|
+
# This bridge closes that gap without the provider's help. It connects to
|
|
23
|
+
# each declared server, lists the tools it offers, merges them with whatever
|
|
24
|
+
# tools the caller declared, and answers tool calls on the server that owns
|
|
25
|
+
# them. The provider's tool loop is untouched: it is handed tools it can call
|
|
26
|
+
# and receives results, which is all it ever needed.
|
|
27
|
+
#
|
|
28
|
+
# The `mcp` gem is loaded only when a bridge is built, so it stays an
|
|
29
|
+
# optional dependency for anyone who does not declare `mcps:` against a
|
|
30
|
+
# client-side provider.
|
|
31
|
+
#
|
|
32
|
+
# @see ActiveAgent::Providers::MCPServing#mcp_resolved_context
|
|
33
|
+
class MCPBridge
|
|
34
|
+
# Raised when a bridge is built without the `mcp` gem installed.
|
|
35
|
+
class MissingGemError < LoadError; end
|
|
36
|
+
|
|
37
|
+
# Raised when two tools would share a name. The model has no way to say
|
|
38
|
+
# which it meant, so this is refused rather than resolved by guessing.
|
|
39
|
+
class DuplicateToolError < StandardError; end
|
|
40
|
+
|
|
41
|
+
# Raised when a `command:` server does not answer within its
|
|
42
|
+
# `read_timeout:`. The server has been stopped by the time this reaches
|
|
43
|
+
# the caller.
|
|
44
|
+
#
|
|
45
|
+
# A `Timeout::Error`, as `Net::ReadTimeout` is, so a policy written for
|
|
46
|
+
# timeouts in general covers this one too.
|
|
47
|
+
class TimeoutError < ::Timeout::Error; end
|
|
48
|
+
|
|
49
|
+
# Schema handed to a tool that declares none. Providers expect an object
|
|
50
|
+
# schema, and MCP permits omitting it.
|
|
51
|
+
#
|
|
52
|
+
# Frozen, and handed out as a copy, because one constant would otherwise
|
|
53
|
+
# be a single mutable object serving every tool of every server in the
|
|
54
|
+
# process — a transform that wrote to it would corrupt it for everyone.
|
|
55
|
+
EMPTY_SCHEMA = { type: "object", properties: {}.freeze }.freeze
|
|
56
|
+
|
|
57
|
+
# Seconds to wait for a server to answer before abandoning the connection.
|
|
58
|
+
#
|
|
59
|
+
# A stdio read blocks forever without one, so a server that accepts a
|
|
60
|
+
# request and never replies holds the generation open until the worker is
|
|
61
|
+
# restarted. Generous, since a tool call can legitimately be slow; set
|
|
62
|
+
# `read_timeout:` on a declaration to override it. What it bounds depends
|
|
63
|
+
# on the transport; see {#transport_for}.
|
|
64
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
65
|
+
|
|
66
|
+
# A declared server, paired with the client connected to it.
|
|
67
|
+
Server = Struct.new(:name, :declaration, :client, keyword_init: true)
|
|
68
|
+
|
|
69
|
+
# The result of a tool call that failed on the server: a result the tool
|
|
70
|
+
# marked `isError`, or a JSON-RPC error the server answered with.
|
|
71
|
+
#
|
|
72
|
+
# A provider sends a tool's result to the model as JSON, and this one
|
|
73
|
+
# serializes as `{"error": message}`, which reads as a failure on an API
|
|
74
|
+
# that has no error flag. A provider whose API has one also sets it, as
|
|
75
|
+
# Anthropic does with `is_error`.
|
|
76
|
+
#
|
|
77
|
+
# @!attribute [r] message
|
|
78
|
+
# @return [String] the error, as the model reads it
|
|
79
|
+
ErrorResult = Data.define(:message) do
|
|
80
|
+
# @return [Hash{String => String}]
|
|
81
|
+
def as_json(*) = { "error" => message }
|
|
82
|
+
|
|
83
|
+
# The `json` gem on its own encodes an object as its `to_s`, so this
|
|
84
|
+
# routes through {#as_json} whether or not ActiveSupport's encoder is
|
|
85
|
+
# loaded.
|
|
86
|
+
#
|
|
87
|
+
# @return [String]
|
|
88
|
+
def to_json(*args) = as_json.to_json(*args)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param servers [Array<Hash>, Hash, nil] common-format `mcps:`
|
|
92
|
+
# declarations. A single Hash is accepted as a one-server list, because
|
|
93
|
+
# `Array(some_hash)` would split it into pairs.
|
|
94
|
+
def initialize(servers, cache: nil)
|
|
95
|
+
@declarations = normalize_all(servers)
|
|
96
|
+
@tools = nil
|
|
97
|
+
@ownership = {}
|
|
98
|
+
# Every connection attempted, so a failed handshake is still reaped.
|
|
99
|
+
@servers = []
|
|
100
|
+
# Only the connections that succeeded, so a failure is not reused.
|
|
101
|
+
@connections = {}
|
|
102
|
+
@cache = cache
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Boolean] whether no servers were declared
|
|
106
|
+
def empty? = @declarations.empty?
|
|
107
|
+
|
|
108
|
+
# Closes every connection this bridge opened.
|
|
109
|
+
#
|
|
110
|
+
# A stdio server is a process this process spawned, and it is reaped only
|
|
111
|
+
# by its transport's `close` — so a bridge that is simply dropped leaks a
|
|
112
|
+
# child process per generation, which accumulates in a long-lived worker.
|
|
113
|
+
# The wrapper exposes no `close` of its own; the transport is where it
|
|
114
|
+
# lives.
|
|
115
|
+
#
|
|
116
|
+
# Idempotent, and deliberately never raises: it runs while a generation is
|
|
117
|
+
# finishing, often from an `ensure`, and a teardown failure must not
|
|
118
|
+
# replace the error that generation was already carrying. Closing resets
|
|
119
|
+
# the discovered tools, so a bridge that is closed and used again
|
|
120
|
+
# reconnects rather than handing back stale clients.
|
|
121
|
+
#
|
|
122
|
+
# @return [void]
|
|
123
|
+
def close
|
|
124
|
+
servers = @servers || []
|
|
125
|
+
@servers = []
|
|
126
|
+
@connections = {}
|
|
127
|
+
@tools = nil
|
|
128
|
+
@ownership = {}
|
|
129
|
+
|
|
130
|
+
servers.each { |server| close_transport(server) }
|
|
131
|
+
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Every tool the declared servers offer, in the common format.
|
|
136
|
+
#
|
|
137
|
+
# Served from {MCPToolCache} when it has an entry, in which case no
|
|
138
|
+
# connection is opened at all.
|
|
139
|
+
#
|
|
140
|
+
# @return [Array<Hash>]
|
|
141
|
+
def tools
|
|
142
|
+
discover if @tools.nil?
|
|
143
|
+
@tools
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Drops the cached tool lists and closes anything open, so the next use
|
|
147
|
+
# asks the servers again. For a caller that has learned — from a tool the
|
|
148
|
+
# server no longer provides, say — that its view is stale.
|
|
149
|
+
#
|
|
150
|
+
# @return [void]
|
|
151
|
+
def refresh!
|
|
152
|
+
@declarations.each { |declaration| MCPToolCache.invalidate(fingerprint_for(declaration)) }
|
|
153
|
+
close
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param name [String, Symbol] tool name
|
|
157
|
+
# @return [Boolean] whether a declared server provides this tool
|
|
158
|
+
def owns?(name)
|
|
159
|
+
tools
|
|
160
|
+
|
|
161
|
+
@ownership.key?(name.to_s)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whether a call to the tool needs the user's approval, by the
|
|
165
|
+
# `require_approval` of the declaration that serves it (see
|
|
166
|
+
# {.approval_required?}).
|
|
167
|
+
#
|
|
168
|
+
# @param name [String, Symbol] tool name
|
|
169
|
+
# @return [Boolean] false for a tool no declared server provides
|
|
170
|
+
def requires_approval?(name)
|
|
171
|
+
return false unless owns?(name)
|
|
172
|
+
|
|
173
|
+
self.class.approval_required?(@ownership[name.to_s][:require_approval], name)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether a declaration's `require_approval` asks for approval of any of
|
|
177
|
+
# its tools: anything but blank, `"never"`, or a map whose only list,
|
|
178
|
+
# under `always`, is empty.
|
|
179
|
+
#
|
|
180
|
+
# @param policy [String, Symbol, Hash, nil]
|
|
181
|
+
# @return [Boolean]
|
|
182
|
+
def self.approval_policy?(policy)
|
|
183
|
+
return false if policy.blank? || policy.to_s == "never"
|
|
184
|
+
return true unless policy.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
policy = policy.to_h.transform_keys(&:to_s)
|
|
187
|
+
policy.key?("never") || !policy.key?("always") || approval_tool_names(policy["always"]).any?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether `require_approval` covers a tool. The policy takes the shapes
|
|
191
|
+
# OpenAI's hosted MCP tool does:
|
|
192
|
+
# - `"always"` covers every tool, and `"never"` none
|
|
193
|
+
# - a map covers the tools listed under `always`, and, when it lists
|
|
194
|
+
# tools under `never`, every tool not listed there. A list is an
|
|
195
|
+
# array of names or `{ tool_names: [...] }`.
|
|
196
|
+
#
|
|
197
|
+
# Any other policy covers every tool.
|
|
198
|
+
#
|
|
199
|
+
# @example
|
|
200
|
+
# approval_required?({ never: { tool_names: ["search"] } }, "delete_file") # => true
|
|
201
|
+
# approval_required?({ always: ["delete_file"] }, "search") # => false
|
|
202
|
+
#
|
|
203
|
+
# @param policy [String, Symbol, Hash, nil]
|
|
204
|
+
# @param name [String, Symbol] tool name
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def self.approval_required?(policy, name)
|
|
207
|
+
return false unless approval_policy?(policy)
|
|
208
|
+
return true unless policy.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
policy = policy.to_h.transform_keys(&:to_s)
|
|
211
|
+
return true if approval_tool_names(policy["always"]).include?(name.to_s)
|
|
212
|
+
return !approval_tool_names(policy["never"]).include?(name.to_s) if policy.key?("never")
|
|
213
|
+
|
|
214
|
+
!policy.key?("always")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @param entry [Array, Hash, nil] a list under `always` or `never`
|
|
218
|
+
# @return [Array<String>]
|
|
219
|
+
def self.approval_tool_names(entry)
|
|
220
|
+
names = entry.is_a?(Hash) ? (entry[:tool_names] || entry["tool_names"]) : entry
|
|
221
|
+
|
|
222
|
+
Array(names).map(&:to_s)
|
|
223
|
+
end
|
|
224
|
+
private_class_method :approval_tool_names
|
|
225
|
+
|
|
226
|
+
# The caller's tools followed by the servers' tools.
|
|
227
|
+
#
|
|
228
|
+
# @param declared [Array<Hash>, nil] tools the caller declared
|
|
229
|
+
# @return [Array<Hash>]
|
|
230
|
+
# @raise [DuplicateToolError] when a declared tool and a server tool share
|
|
231
|
+
# a name
|
|
232
|
+
def merge_tools(declared)
|
|
233
|
+
declared = Array(declared)
|
|
234
|
+
taken = declared.map { |tool| tool_name(tool) }
|
|
235
|
+
|
|
236
|
+
tools.each do |tool|
|
|
237
|
+
next unless taken.include?(tool[:name])
|
|
238
|
+
|
|
239
|
+
fail DuplicateToolError,
|
|
240
|
+
"A tool named #{tool[:name].inspect} is declared by the agent and also offered by an MCP " \
|
|
241
|
+
"server, so the model cannot choose between them. Rename one, or restrict the server with " \
|
|
242
|
+
"`allowed_tools:`."
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
declared + tools
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Invokes a tool on the server that owns it.
|
|
249
|
+
#
|
|
250
|
+
# @param name [String, Symbol] tool name
|
|
251
|
+
# @param kwargs [Hash] tool arguments
|
|
252
|
+
# @return [String, Hash, ErrorResult] the tool's result, or an
|
|
253
|
+
# {ErrorResult} when the call failed
|
|
254
|
+
# @raise [ArgumentError] when no server provides the tool
|
|
255
|
+
def call(name, **kwargs)
|
|
256
|
+
self.class.load_mcp!
|
|
257
|
+
|
|
258
|
+
tools # populates ownership
|
|
259
|
+
|
|
260
|
+
declaration = @ownership.fetch(name.to_s) do
|
|
261
|
+
fail ArgumentError, "No declared MCP server provides a tool named #{name.to_s.inspect}."
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
server = ensure_connected(declaration)
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
flatten_result(call_tool_on(server, name.to_s, kwargs))
|
|
268
|
+
rescue MCP::Client::ServerError => e
|
|
269
|
+
# The client raises on a JSON-RPC error envelope. The message goes back to the model as the tool's
|
|
270
|
+
# answer, since it can often recover from a bad argument, where a raise would end the generation.
|
|
271
|
+
error_message(e.message)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Loads the `mcp` gem, explaining the dependency if it is absent.
|
|
276
|
+
#
|
|
277
|
+
# @return [void]
|
|
278
|
+
# @raise [MissingGemError]
|
|
279
|
+
def self.load_mcp!
|
|
280
|
+
require "mcp"
|
|
281
|
+
rescue LoadError => e
|
|
282
|
+
raise MissingGemError,
|
|
283
|
+
"Using `mcps:` with a provider that has no server-side MCP requires the 'mcp' gem. " \
|
|
284
|
+
"Add `gem \"mcp\"` to your Gemfile and run `bundle install`. (#{e.message})"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
# Works out which tools each declared server offers.
|
|
290
|
+
#
|
|
291
|
+
# Connections are deliberately left alone: a cache hit means this needs
|
|
292
|
+
# none, and one that is opened here only to be closed again would defeat
|
|
293
|
+
# the point.
|
|
294
|
+
#
|
|
295
|
+
# @return [void]
|
|
296
|
+
def discover
|
|
297
|
+
@tools = []
|
|
298
|
+
@ownership = {}
|
|
299
|
+
|
|
300
|
+
@declarations.each do |declaration|
|
|
301
|
+
tools_for(declaration).each do |tool|
|
|
302
|
+
existing = @ownership[tool[:name]]
|
|
303
|
+
|
|
304
|
+
if existing
|
|
305
|
+
fail DuplicateToolError,
|
|
306
|
+
"Two declared MCP servers offer a tool named #{tool[:name].inspect}: " \
|
|
307
|
+
"#{existing[:name].to_s.inspect} and #{declaration[:name].to_s.inspect}. The model cannot choose " \
|
|
308
|
+
"between them, so rename one or restrict it with `allowed_tools:`."
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
@ownership[tool[:name]] = declaration
|
|
312
|
+
@tools << tool
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The tools one declaration offers, from the cache where it can be.
|
|
318
|
+
#
|
|
319
|
+
# @param declaration [Hash]
|
|
320
|
+
# @return [Array<Hash>]
|
|
321
|
+
def tools_for(declaration)
|
|
322
|
+
MCPToolCache.fetch(fingerprint_for(declaration), enabled: @cache) do
|
|
323
|
+
server_tools(ensure_connected(declaration))
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# The live connection to a declaration's server, opening it on first use.
|
|
328
|
+
#
|
|
329
|
+
# This is what the cache buys: a generation whose tools all come from the
|
|
330
|
+
# cache and never calls one opens no connection and spawns no process.
|
|
331
|
+
#
|
|
332
|
+
# @param declaration [Hash]
|
|
333
|
+
# @return [Server]
|
|
334
|
+
def ensure_connected(declaration)
|
|
335
|
+
@connections[declaration] ||= connect(declaration)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Identifies a declaration for caching purposes.
|
|
339
|
+
#
|
|
340
|
+
# Covers what can change the advertised tools: endpoint, credentials,
|
|
341
|
+
# command environment and tool filter. Credentials are hashed along with
|
|
342
|
+
# the rest of the declaration; the raw secret is never used as a cache
|
|
343
|
+
# key. The display name is excluded, so renaming a server does not throw
|
|
344
|
+
# away its cached tools.
|
|
345
|
+
#
|
|
346
|
+
# @param declaration [Hash]
|
|
347
|
+
# @return [String]
|
|
348
|
+
def fingerprint_for(declaration)
|
|
349
|
+
canonical = {
|
|
350
|
+
url: declaration[:url],
|
|
351
|
+
command: declaration[:command],
|
|
352
|
+
args: declaration[:args],
|
|
353
|
+
env: declaration[:env]&.sort&.to_h,
|
|
354
|
+
authorization: declaration[:authorization],
|
|
355
|
+
authorization_token: declaration[:authorization_token],
|
|
356
|
+
allowed_tools: declaration[:allowed_tools]&.map { |tool| tool_name(tool) }&.sort
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Connects a client to one declared server.
|
|
363
|
+
#
|
|
364
|
+
# The server is recorded before it is connected, because `connect` starts a
|
|
365
|
+
# stdio process and only then handshakes: a handshake that fails — a
|
|
366
|
+
# mistyped command, a server that never answers — has already spawned a
|
|
367
|
+
# process, and recording it afterwards would leave that process with
|
|
368
|
+
# nothing to reap it. It is only remembered for reuse once it succeeds.
|
|
369
|
+
#
|
|
370
|
+
# @param declaration [Hash]
|
|
371
|
+
# @return [Server]
|
|
372
|
+
def connect(declaration)
|
|
373
|
+
self.class.load_mcp!
|
|
374
|
+
|
|
375
|
+
client = MCP::Client.new(transport: transport_for(declaration))
|
|
376
|
+
server = Server.new(name: declaration[:name], declaration:, client:)
|
|
377
|
+
@servers << server
|
|
378
|
+
|
|
379
|
+
# A `command:` server's handshake opens with a `server/discover` probe that the gem bounds on its own, and
|
|
380
|
+
# a server that ignores the probe spends that bound before the client falls back to `initialize`. The
|
|
381
|
+
# deadline allows for it, so the server still has its whole read timeout to answer `initialize`.
|
|
382
|
+
probe = MCP::Client::Stdio.const_defined?(:DEFAULT_DISCOVER_PROBE_TIMEOUT) ? MCP::Client::Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT : 0
|
|
383
|
+
answer_within_read_timeout(server, "the handshake", allowance: probe) { client.connect }
|
|
384
|
+
|
|
385
|
+
server
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Builds the transport for one declared server.
|
|
389
|
+
#
|
|
390
|
+
# `read_timeout:` is validated for either transport, then applied in the
|
|
391
|
+
# way each one allows:
|
|
392
|
+
#
|
|
393
|
+
# - `url:` — set as the read timeout of the server's Faraday connection.
|
|
394
|
+
# Faraday's default adapter, Net::HTTP, applies it to each wait for
|
|
395
|
+
# data rather than to the whole response, so a streamed answer stays
|
|
396
|
+
# open while it keeps sending events. The gem sets a read timeout of its
|
|
397
|
+
# own on the standalone listening stream and on a resumed stream, which
|
|
398
|
+
# takes precedence there.
|
|
399
|
+
# - `command:` — not handed to the transport, whose own read timeout
|
|
400
|
+
# restarts with every frame it reads, so a server that keeps sending
|
|
401
|
+
# notifications never reaches it. Without one, the gem keeps its own
|
|
402
|
+
# short bound on the `server/discover` probe. The bridge bounds the whole
|
|
403
|
+
# answer itself; see {#answer_within_read_timeout}.
|
|
404
|
+
#
|
|
405
|
+
# @param declaration [Hash]
|
|
406
|
+
# @return [Object] an MCP transport
|
|
407
|
+
# @raise [ArgumentError] when the declaration names neither a `url:` nor
|
|
408
|
+
# a `command:`, or declares a `read_timeout:` that is not a positive,
|
|
409
|
+
# finite number
|
|
410
|
+
def transport_for(declaration)
|
|
411
|
+
read_timeout = read_timeout_for(declaration)
|
|
412
|
+
|
|
413
|
+
if declaration[:url]
|
|
414
|
+
MCP::Client::HTTP.new(
|
|
415
|
+
url: declaration[:url],
|
|
416
|
+
headers: headers_for(declaration),
|
|
417
|
+
**{ max_reconnection_wait: declaration[:max_reconnection_wait] }.compact
|
|
418
|
+
) { |faraday| faraday.options.read_timeout = read_timeout }
|
|
419
|
+
elsif declaration[:command]
|
|
420
|
+
MCP::Client::Stdio.new(
|
|
421
|
+
command: declaration[:command],
|
|
422
|
+
args: Array(declaration[:args]),
|
|
423
|
+
env: spawn_env_for(declaration)
|
|
424
|
+
)
|
|
425
|
+
else
|
|
426
|
+
# The keys only: a declaration's values carry credentials, and this message reaches logs.
|
|
427
|
+
fail ArgumentError,
|
|
428
|
+
"An entry in `mcps:` needs either a `url:` or a `command:` to connect to; " \
|
|
429
|
+
"#{declaration[:name].inspect} declares only #{declaration.keys.inspect}."
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# The environment a stdio server's process is spawned with. Spawning requires String keys and values,
|
|
434
|
+
# while a declaration's keys are symbolized on the way in and its values may be numbers or symbols.
|
|
435
|
+
# A nil value is kept, which unsets the variable.
|
|
436
|
+
#
|
|
437
|
+
# @param declaration [Hash]
|
|
438
|
+
# @return [Hash{String => String, nil}, nil]
|
|
439
|
+
def spawn_env_for(declaration)
|
|
440
|
+
declaration[:env]&.to_h { |key, value| [ key.to_s, value&.to_s ] }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The bounded wait for a server to answer.
|
|
444
|
+
#
|
|
445
|
+
# @param declaration [Hash]
|
|
446
|
+
# @return [Numeric]
|
|
447
|
+
# @raise [ArgumentError] when a declared timeout is not a positive, finite
|
|
448
|
+
# number
|
|
449
|
+
def read_timeout_for(declaration)
|
|
450
|
+
timeout = declaration[:read_timeout]
|
|
451
|
+
return DEFAULT_READ_TIMEOUT if timeout.nil?
|
|
452
|
+
|
|
453
|
+
unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
|
|
454
|
+
fail ArgumentError,
|
|
455
|
+
"`read_timeout:` on an MCP server must be a positive number of seconds, got #{timeout.inspect}."
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
timeout
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Calls a tool on its server, within the server's `read_timeout:`.
|
|
462
|
+
#
|
|
463
|
+
# @param server [Server]
|
|
464
|
+
# @param name [String] tool name
|
|
465
|
+
# @param arguments [Hash] tool arguments
|
|
466
|
+
# @return [Object] the client's response
|
|
467
|
+
def call_tool_on(server, name, arguments)
|
|
468
|
+
answer_within_read_timeout(server, "tools/call for #{name.inspect}") do
|
|
469
|
+
server.client.call_tool(name:, arguments:)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Runs one exchange with a server — the handshake, the tool listing or a
|
|
474
|
+
# tool call — within the server's `read_timeout:`, and returns the
|
|
475
|
+
# block's result.
|
|
476
|
+
#
|
|
477
|
+
# A `url:` server's exchange runs as it is, bounded by its transport; see
|
|
478
|
+
# {#transport_for}. A `command:` server's runs on a thread of its own,
|
|
479
|
+
# which this one waits on for at most the timeout, so the bound covers the
|
|
480
|
+
# whole answer: notifications and pings the server sends meanwhile do not
|
|
481
|
+
# extend it. A server that misses it is released, which ends the exchange
|
|
482
|
+
# too, since closing the transport fails the read the thread is blocked in.
|
|
483
|
+
#
|
|
484
|
+
# The deadline is kept by waiting rather than with `Timeout.timeout`,
|
|
485
|
+
# which raises into the running thread at whatever point it has reached,
|
|
486
|
+
# part-way through writing a request to the server included.
|
|
487
|
+
#
|
|
488
|
+
# @param server [Server]
|
|
489
|
+
# @param request [String] the exchange, as the error names it
|
|
490
|
+
# @param allowance [Numeric] seconds granted on top of the read timeout
|
|
491
|
+
# @return [Object] the block's result
|
|
492
|
+
# @raise [TimeoutError] when a `command:` server does not answer in time
|
|
493
|
+
def answer_within_read_timeout(server, request, allowance: 0, &exchange)
|
|
494
|
+
return exchange.call if server.declaration[:url]
|
|
495
|
+
|
|
496
|
+
seconds = read_timeout_for(server.declaration) + allowance
|
|
497
|
+
outcome = Thread::Queue.new
|
|
498
|
+
worker = Thread.new do
|
|
499
|
+
outcome << [ :answered, exchange.call ]
|
|
500
|
+
rescue Exception => e
|
|
501
|
+
# Every exception is handed to the waiting thread, which re-raises it, so one that is not a
|
|
502
|
+
# StandardError still reaches the caller at once rather than surfacing as a timeout.
|
|
503
|
+
outcome << [ :failed, e ]
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
status, result = outcome.pop(timeout: seconds)
|
|
507
|
+
raise result if status == :failed
|
|
508
|
+
return result if status == :answered
|
|
509
|
+
|
|
510
|
+
fail TimeoutError,
|
|
511
|
+
"The MCP server #{server.name.to_s.inspect} did not answer #{request} within #{seconds} seconds, so it " \
|
|
512
|
+
"was stopped. Increase `read_timeout:` on its `mcps:` entry if it needs longer."
|
|
513
|
+
ensure
|
|
514
|
+
# With no outcome, the exchange was abandoned part-way — the deadline passed, or this thread was
|
|
515
|
+
# interrupted while it waited — and the connection cannot be trusted with another request.
|
|
516
|
+
release(server) if worker && status.nil?
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Gives up on a server whose exchange was abandoned. Its connection is
|
|
520
|
+
# forgotten, so the next use opens a new one, and its transport is closed,
|
|
521
|
+
# which stops a `command:` server's process.
|
|
522
|
+
#
|
|
523
|
+
# @param server [Server]
|
|
524
|
+
# @return [void]
|
|
525
|
+
def release(server)
|
|
526
|
+
@connections.delete(server.declaration)
|
|
527
|
+
close_transport(server)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Closes a server's transport, which for a `command:` server stops its
|
|
531
|
+
# process. Never raises, for the reasons {#close} gives.
|
|
532
|
+
#
|
|
533
|
+
# @param server [Server]
|
|
534
|
+
# @return [void]
|
|
535
|
+
def close_transport(server)
|
|
536
|
+
transport = server.client.transport if server.client.respond_to?(:transport)
|
|
537
|
+
transport.close if transport.respond_to?(:close)
|
|
538
|
+
|
|
539
|
+
nil
|
|
540
|
+
rescue StandardError
|
|
541
|
+
# Best effort. A server that will not shut down cleanly is not worth an
|
|
542
|
+
# error that would replace the one its caller is already handling.
|
|
543
|
+
nil
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# @return [Hash] a fresh copy of {EMPTY_SCHEMA}, safe for a caller to mutate
|
|
547
|
+
def empty_schema = EMPTY_SCHEMA.deep_dup
|
|
548
|
+
|
|
549
|
+
# @param declaration [Hash]
|
|
550
|
+
# @return [Hash] request headers for the server
|
|
551
|
+
def headers_for(declaration)
|
|
552
|
+
token = declaration[:authorization] || declaration[:authorization_token]
|
|
553
|
+
|
|
554
|
+
token.present? ? { "Authorization" => "Bearer #{token}" } : {}
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# One server's tools, in the common format.
|
|
558
|
+
#
|
|
559
|
+
# @param server [Server]
|
|
560
|
+
# @return [Array<Hash>]
|
|
561
|
+
def server_tools(server)
|
|
562
|
+
allowed = Array(server.declaration[:allowed_tools]).map { |tool| tool_name(tool) }
|
|
563
|
+
allowed = nil if allowed.empty?
|
|
564
|
+
listed = answer_within_read_timeout(server, "tools/list") { server.client.tools }
|
|
565
|
+
|
|
566
|
+
listed.filter_map do |tool|
|
|
567
|
+
name = tool.name.to_s
|
|
568
|
+
next if allowed && !allowed.include?(name)
|
|
569
|
+
|
|
570
|
+
{
|
|
571
|
+
name: name,
|
|
572
|
+
description: tool.description,
|
|
573
|
+
parameters: tool.input_schema || empty_schema
|
|
574
|
+
}.compact
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Reduces an MCP tool result to something a provider can hand back to the
|
|
579
|
+
# model. Structured content is preferred when the server sends it, since
|
|
580
|
+
# it is the machine-readable form; otherwise the text blocks are joined.
|
|
581
|
+
# A result the tool marked `isError` becomes an {ErrorResult} carrying
|
|
582
|
+
# its text, which is where MCP describes a tool's failure.
|
|
583
|
+
#
|
|
584
|
+
# `MCP::Client#call_tool` returns the whole JSON-RPC envelope, so the tool
|
|
585
|
+
# result sits one level down. That is unwrapped first — keyed off the
|
|
586
|
+
# envelope's own marker rather than the presence of `result`, which a tool
|
|
587
|
+
# is free to use as a field name.
|
|
588
|
+
#
|
|
589
|
+
# @param result [Object]
|
|
590
|
+
# @return [String, Hash, ErrorResult]
|
|
591
|
+
def flatten_result(result)
|
|
592
|
+
result = result.to_h if result.respond_to?(:to_h) && !result.is_a?(Hash)
|
|
593
|
+
return result unless result.is_a?(Hash)
|
|
594
|
+
|
|
595
|
+
if result.key?(:jsonrpc) || result.key?("jsonrpc")
|
|
596
|
+
error = result[:error] || result["error"]
|
|
597
|
+
return error_message(error) if error
|
|
598
|
+
|
|
599
|
+
result = result[:result] || result["result"] || {}
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
return error_message(text_content(result)) if result[:isError] || result["isError"]
|
|
603
|
+
|
|
604
|
+
structured = result[:structuredContent] || result["structuredContent"]
|
|
605
|
+
return structured if structured
|
|
606
|
+
|
|
607
|
+
text_content(result)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Joins the text blocks of a tool result. Blocks without text, such as
|
|
611
|
+
# images, are left out.
|
|
612
|
+
#
|
|
613
|
+
# @param result [Hash]
|
|
614
|
+
# @return [String]
|
|
615
|
+
def text_content(result)
|
|
616
|
+
Array(result[:content] || result["content"]).filter_map do |block|
|
|
617
|
+
block = block.to_h if block.respond_to?(:to_h) && !block.is_a?(Hash)
|
|
618
|
+
|
|
619
|
+
block[:text] || block["text"] if block.is_a?(Hash)
|
|
620
|
+
end.join("\n")
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Wraps the error a failed call reports in an {ErrorResult} for the
|
|
624
|
+
# model, with a placeholder when the server gave no message. It is
|
|
625
|
+
# returned as the tool's result rather than raised: the model can often
|
|
626
|
+
# recover from a bad argument, and a raise here would surface as a failed
|
|
627
|
+
# generation instead.
|
|
628
|
+
#
|
|
629
|
+
# @param error [Hash, String, nil] a JSON-RPC error object, or the text
|
|
630
|
+
# describing the failure
|
|
631
|
+
# @return [ErrorResult]
|
|
632
|
+
def error_message(error)
|
|
633
|
+
message = error.is_a?(Hash) ? (error[:message] || error["message"] || error.inspect) : error
|
|
634
|
+
|
|
635
|
+
ErrorResult.new(message: message.to_s.presence || "The MCP server returned an empty error.")
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# @param servers [Array<Hash>, Hash, nil]
|
|
639
|
+
# @return [Array<Hash>]
|
|
640
|
+
def normalize_all(servers)
|
|
641
|
+
return [] if servers.nil?
|
|
642
|
+
return [ normalize(servers) ] if servers.is_a?(Hash)
|
|
643
|
+
|
|
644
|
+
Array(servers).map { |server| normalize(server) }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# @param server [Hash]
|
|
648
|
+
# @return [Hash] declaration with symbolized keys and a display name
|
|
649
|
+
def normalize(server)
|
|
650
|
+
unless server.is_a?(Hash)
|
|
651
|
+
fail ArgumentError, "Each entry in `mcps:` must be a Hash, got #{server.class}."
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
declaration = server.deep_symbolize_keys
|
|
655
|
+
declaration[:name] ||= default_name(declaration)
|
|
656
|
+
|
|
657
|
+
declaration
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# A display name for a declaration that gave none.
|
|
661
|
+
#
|
|
662
|
+
# Deliberately the host rather than the whole URL. An MCP endpoint usually
|
|
663
|
+
# carries its key in the path (`https://mcp.example.com/<key>/v2/mcp`),
|
|
664
|
+
# and this name reaches error messages, which reach log aggregators and
|
|
665
|
+
# error trackers — so the URL is the one part of the declaration that must
|
|
666
|
+
# not be copied into them.
|
|
667
|
+
#
|
|
668
|
+
# @param declaration [Hash]
|
|
669
|
+
# @return [String]
|
|
670
|
+
def default_name(declaration)
|
|
671
|
+
return "command: #{File.basename(declaration[:command].to_s)}" if declaration[:command].present?
|
|
672
|
+
|
|
673
|
+
url = declaration[:url]
|
|
674
|
+
return "unnamed server" if url.blank?
|
|
675
|
+
|
|
676
|
+
URI.parse(url).host || "unnamed server"
|
|
677
|
+
rescue URI::InvalidURIError
|
|
678
|
+
"unnamed server"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# @param tool [Hash, Object, String, Symbol]
|
|
682
|
+
# @return [String]
|
|
683
|
+
def tool_name(tool)
|
|
684
|
+
return (tool[:name] || tool["name"]).to_s if tool.is_a?(Hash)
|
|
685
|
+
return tool.name.to_s if tool.respond_to?(:name)
|
|
686
|
+
|
|
687
|
+
tool.to_s
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|