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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1058 -0
  3. data/README.md +9 -1
  4. data/lib/active_agent/base.rb +22 -1
  5. data/lib/active_agent/concerns/delegation.rb +4 -0
  6. data/lib/active_agent/concerns/input_requests.rb +178 -0
  7. data/lib/active_agent/concerns/parameterized.rb +7 -4
  8. data/lib/active_agent/concerns/provider.rb +1 -0
  9. data/lib/active_agent/concerns/tooling.rb +7 -2
  10. data/lib/active_agent/delegation/runner.rb +31 -0
  11. data/lib/active_agent/evals/catalog.rb +305 -0
  12. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  13. data/lib/active_agent/evals/scenario.rb +10 -3
  14. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  15. data/lib/active_agent/evals.rb +1 -0
  16. data/lib/active_agent/generation.rb +62 -2
  17. data/lib/active_agent/generation_job.rb +14 -3
  18. data/lib/active_agent/input_request/resume.rb +199 -0
  19. data/lib/active_agent/input_request.rb +279 -0
  20. data/lib/active_agent/providers/_base_provider.rb +153 -31
  21. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  22. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  23. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  24. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  25. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  26. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  27. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  28. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  29. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  30. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  31. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  32. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  33. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  34. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  35. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  36. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  37. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  38. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  39. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  40. data/lib/active_agent/providers/open_router/options.rb +6 -2
  41. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  42. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  43. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  44. data/lib/active_agent/schema_tools.rb +12 -0
  45. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  46. data/lib/active_agent/version.rb +1 -1
  47. data/lib/active_agent.rb +2 -0
  48. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  49. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  50. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  51. 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