agent-harness 0.9.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8162991fcf80a1cfe21b522abe909f615e87e56942b8261f5ee3cbb9a86f18c2
4
- data.tar.gz: 22f7b57522dc3f38dd73a5200cbf878ca72d0b22d889c29bfa58ca878725a705
3
+ metadata.gz: 635aae919a5bbbf99af4a24199b45507370c01ccbf637bfd8d9d0fa18bdb3c22
4
+ data.tar.gz: 30548d834ae0195030e98565007ced6ebf140f12f9a489ae10a6d423c40e087f
5
5
  SHA512:
6
- metadata.gz: 1a9f60dc02f229765786bfe38c5cc84f489f52efb8d146f1a0595f9bc03d930b6e71f2194cfb4c86d24e191e1c5d184e7030addda5f1ab09a4a9098ff3a90a7f
7
- data.tar.gz: 7b4a8d87ceb151d28d867522afe69872f5a983df961b7c2a1dcccf07324c1ca8c6cc2e7ed1eb7b4dd129d7315917084a0e0da7d29c2ad4e032b6be29eda71fa2
6
+ metadata.gz: ac200425094b482ad90fd6492ba0bf4d612ed08560bacde517c988375d1d452b12aca7c34f8ed9519cf907daa87a07a96a4c044952126ce3cd9e37f2d6b9a788
7
+ data.tar.gz: a871de9fcc11224506f4220025016b3b7201ef97bc1e1aca918562c1f983b9dd175a93dbd6315a71a3a270234d3b9cd019f7deaa82030b984e11434ca86328f8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.9.0"
2
+ ".": "0.11.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.10.0...agent-harness/v0.11.0) (2026-04-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * add conversation manager for multi-turn chat ([#159](https://github.com/viamin/agent-harness/issues/159)) ([14f1d55](https://github.com/viamin/agent-harness/commit/14f1d551008c2d52a0aee7c2a7e2e0273f254578))
9
+ * add MCP HTTP transport support for servers ([#153](https://github.com/viamin/agent-harness/issues/153)) ([#155](https://github.com/viamin/agent-harness/issues/155)) ([8ea631a](https://github.com/viamin/agent-harness/commit/8ea631a3274ca4331ce42e8d63fc972cd48fbb12))
10
+ * add OpenAI-compatible chat transport ([#154](https://github.com/viamin/agent-harness/issues/154)) ([6005702](https://github.com/viamin/agent-harness/commit/60057029ba6eaaf81f65d42e487e6f0ca8cd159f))
11
+ * add provider chat capability with GitHub Models and Anthropic support ([#158](https://github.com/viamin/agent-harness/issues/158)) ([4188fa5](https://github.com/viamin/agent-harness/commit/4188fa542e6c4d330e5b230e54b1c1a5a55f4e8a))
12
+ * add structured streaming response observer for chat ([#157](https://github.com/viamin/agent-harness/issues/157)) ([225f4d9](https://github.com/viamin/agent-harness/commit/225f4d99b2b89d8eb030018236050672d3e47ba2))
13
+
14
+ ## [0.10.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.9.0...agent-harness/v0.10.0) (2026-04-21)
15
+
16
+
17
+ ### Features
18
+
19
+ * **codex:** expose JSONL transcript parser ([#148](https://github.com/viamin/agent-harness/issues/148)) ([05312ea](https://github.com/viamin/agent-harness/commit/05312eaf9c11fff50931e511ee6e534838eb8746))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **copilot:** github-copilot-cli does not support the -p flag used by build_command ([#141](https://github.com/viamin/agent-harness/issues/141)) ([d06fbc4](https://github.com/viamin/agent-harness/commit/d06fbc414489d6c3bc93a122d0eb2a5771ddbb26))
25
+
3
26
  ## [0.9.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.8.0...agent-harness/v0.9.0) (2026-04-19)
4
27
 
5
28
 
data/README.md CHANGED
@@ -501,6 +501,29 @@ AgentHarness.auth_status(:claude)
501
501
 
502
502
  For providers without a built-in auth check (including `:api_key` providers), `auth_valid?` returns `false` and `auth_status` returns an error indicating the check is not implemented. Custom providers can implement an `auth_status` instance method to provide their own check.
503
503
 
504
+ ### Auth Flow Capabilities
505
+
506
+ Before rendering provider-specific auth controls, check whether the flow is supported:
507
+
508
+ ```ruby
509
+ AgentHarness.auth_url_supported?(:claude)
510
+ # => true
511
+
512
+ AgentHarness.auth_url_supported?(:codex)
513
+ # => false
514
+
515
+ AgentHarness.refresh_auth_supported?(:claude)
516
+ # => true
517
+
518
+ AgentHarness.refresh_auth_supported?(:codex)
519
+ # => false
520
+
521
+ AgentHarness.auth_capabilities(:codex)
522
+ # => { auth_type: :api_key, auth_url: false, refresh: false }
523
+ ```
524
+
525
+ Provider aliases are resolved the same way as other auth APIs, so `:anthropic` reports the same capabilities as `:claude`. Unknown providers raise `AgentHarness::ProviderNotFoundError`, matching `auth_url` and `refresh_auth` provider lookup behavior.
526
+
504
527
  ### Auth Error Detection
505
528
 
506
529
  When a CLI agent fails due to expired or invalid authentication, `send_message` raises `AuthenticationError` with the provider name. Authentication errors are always surfaced directly to the caller (never auto-switched to another provider) so your application can trigger the appropriate re-auth flow:
@@ -509,9 +532,15 @@ When a CLI agent fails due to expired or invalid authentication, `send_message`
509
532
  begin
510
533
  AgentHarness.send_message("Hello", provider: :claude)
511
534
  rescue AgentHarness::AuthenticationError => e
512
- puts e.provider # => :claude
513
- puts e.message # => "oauth token expired"
514
- # Trigger re-authentication flow for the specific provider
535
+ provider = e.provider
536
+
537
+ if AgentHarness.auth_url_supported?(provider)
538
+ redirect_to AgentHarness.auth_url(provider)
539
+ elsif AgentHarness.refresh_auth_supported?(provider)
540
+ render :reauth_token_form, locals: { provider: provider }
541
+ else
542
+ render :auth_expired_without_refresh, locals: { provider: provider, message: e.message }
543
+ end
515
544
  end
516
545
  ```
517
546
 
@@ -524,7 +553,7 @@ AgentHarness.auth_url(:claude)
524
553
  # => "https://claude.ai/oauth/authorize"
525
554
  ```
526
555
 
527
- This raises `NotImplementedError` for `:api_key` providers.
556
+ This raises `AgentHarness::UnsupportedAuthFlowError` for `:api_key` providers or providers whose OAuth URL flow is not implemented. The exception inherits from `AgentHarness::Error` and `StandardError`, so host applications can rescue it with their normal app-level error handling.
528
557
 
529
558
  ### Credential Refresh
530
559
 
@@ -537,7 +566,9 @@ AgentHarness.refresh_auth(:claude, token: "new-oauth-token")
537
566
 
538
567
  Any existing expiry metadata in the credentials file is cleared on refresh so that `auth_valid?` returns `true` immediately after a successful refresh.
539
568
 
540
- This raises `NotImplementedError` for `:api_key` providers. Credential file paths respect the `CLAUDE_CONFIG_DIR` environment variable.
569
+ This raises `AgentHarness::UnsupportedAuthFlowError` for `:api_key` providers or providers whose credential refresh flow is not implemented. Credential file paths respect the `CLAUDE_CONFIG_DIR` environment variable.
570
+
571
+ If you currently rescue `NotImplementedError` for unsupported auth URL generation or credential refresh, update that code to rescue `AgentHarness::UnsupportedAuthFlowError` or the broader `AgentHarness::Error` instead.
541
572
 
542
573
  ## Provider Health Checks
543
574
 
@@ -35,19 +35,46 @@ module AgentHarness
35
35
  end
36
36
  end
37
37
 
38
+ # Get authentication flow capabilities for a provider.
39
+ #
40
+ # @param provider_name [Symbol] the provider name
41
+ # @return [Hash] capabilities with :auth_type, :auth_url, :refresh keys
42
+ # @raise [ProviderNotFoundError] if provider is unknown
43
+ def auth_capabilities(provider_name)
44
+ provider_name = provider_name.to_sym
45
+ provider = resolve_provider(provider_name)
46
+ canonical_name = Providers::Registry.instance.canonical_name(provider_name)
47
+ flow_supported = claude_oauth_flow_provider?(provider_name, canonical_name)
48
+
49
+ {
50
+ auth_type: provider.auth_type,
51
+ auth_url: flow_supported,
52
+ refresh: flow_supported
53
+ }
54
+ end
55
+
56
+ # Check whether OAuth URL generation is supported for a provider.
57
+ #
58
+ # @param provider_name [Symbol] the provider name
59
+ # @return [Boolean] true if auth_url can be called for the provider
60
+ # @raise [ProviderNotFoundError] if provider is unknown
61
+ def auth_url_supported?(provider_name)
62
+ auth_capabilities(provider_name)[:auth_url]
63
+ end
64
+
38
65
  # Generate an OAuth URL for a provider
39
66
  #
40
67
  # Only supported for :oauth auth type providers.
41
68
  #
42
69
  # @param provider_name [Symbol] the provider name
43
70
  # @return [String] the OAuth authorization URL
44
- # @raise [NotImplementedError] if provider doesn't support OAuth
71
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support OAuth
45
72
  def auth_url(provider_name)
46
73
  provider_name = provider_name.to_sym
47
74
  provider = resolve_provider(provider_name)
48
75
 
49
76
  unless provider.auth_type == :oauth
50
- raise NotImplementedError,
77
+ raise UnsupportedAuthFlowError,
51
78
  "Provider #{provider_name} uses #{provider.auth_type} auth and does not support OAuth URL generation"
52
79
  end
53
80
 
@@ -55,29 +82,38 @@ module AgentHarness
55
82
  when :claude, :anthropic
56
83
  claude_auth_url
57
84
  else
58
- raise NotImplementedError,
85
+ raise UnsupportedAuthFlowError,
59
86
  "OAuth URL generation is not yet implemented for provider #{provider_name}"
60
87
  end
61
88
  end
62
89
 
90
+ # Check whether credential refresh is supported for a provider.
91
+ #
92
+ # @param provider_name [Symbol] the provider name
93
+ # @return [Boolean] true if refresh_auth can be called for the provider
94
+ # @raise [ProviderNotFoundError] if provider is unknown
95
+ def refresh_auth_supported?(provider_name)
96
+ auth_capabilities(provider_name)[:refresh]
97
+ end
98
+
63
99
  # Refresh authentication credentials for a provider
64
100
  #
65
101
  # For OAuth providers, stores a pre-exchanged token directly.
66
102
  # This method accepts a token (not an authorization code) because
67
103
  # the OAuth code-exchange flow is provider-specific and should be
68
104
  # handled by the caller or a CLI login command before calling this.
69
- # For API key providers, raises NotImplementedError.
105
+ # For API key providers, raises UnsupportedAuthFlowError.
70
106
  #
71
107
  # @param provider_name [Symbol] the provider name
72
108
  # @param token [String] OAuth token to store (must be non-blank)
73
109
  # @return [Hash] result with :success key
74
- # @raise [NotImplementedError] if provider doesn't support credential refresh
110
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support credential refresh
75
111
  def refresh_auth(provider_name, token: nil)
76
112
  provider_name = provider_name.to_sym
77
113
  provider = resolve_provider(provider_name)
78
114
 
79
115
  unless provider.auth_type == :oauth
80
- raise NotImplementedError,
116
+ raise UnsupportedAuthFlowError,
81
117
  "Provider #{provider_name} uses #{provider.auth_type} auth and does not support credential refresh"
82
118
  end
83
119
 
@@ -85,13 +121,17 @@ module AgentHarness
85
121
  when :claude, :anthropic
86
122
  refresh_claude_auth(token: token)
87
123
  else
88
- raise NotImplementedError,
124
+ raise UnsupportedAuthFlowError,
89
125
  "Credential refresh is not yet implemented for provider #{provider_name}"
90
126
  end
91
127
  end
92
128
 
93
129
  private
94
130
 
131
+ def claude_oauth_flow_provider?(requested_name, canonical_name)
132
+ [:claude, :anthropic].include?(requested_name) || canonical_name == :claude
133
+ end
134
+
95
135
  def resolve_provider(provider_name)
96
136
  klass = Providers::Registry.instance.get(provider_name)
97
137
  canonical_name = Providers::Registry.instance.canonical_name(provider_name)
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentHarness
6
+ # Manages multi-turn conversation history with token tracking and
7
+ # transport-specific message formatting.
8
+ #
9
+ # Encapsulates message storage, token budget awareness, context window
10
+ # truncation, and serialisation to OpenAI and Anthropic API formats.
11
+ #
12
+ # @example Basic usage
13
+ # convo = AgentHarness::Conversation.new(system_prompt: "You are helpful.")
14
+ # convo.add_message(:user, "Hello")
15
+ # convo.add_message(:assistant, "Hi there!", tokens: { input: 10, output: 5 })
16
+ # convo.to_openai_messages
17
+ #
18
+ # @example Token-aware truncation
19
+ # convo = AgentHarness::Conversation.new(system_prompt: "...", token_limit: 8000)
20
+ # # ... add many messages ...
21
+ # convo.truncate(keep_recent: 4) if convo.approaching_limit?
22
+ class Conversation
23
+ VALID_ROLES = %i[system user assistant tool].freeze
24
+
25
+ # @return [Integer, nil] the token budget for this conversation
26
+ attr_reader :token_limit
27
+
28
+ # @param system_prompt [String, nil] optional system prompt prepended to messages
29
+ # @param token_limit [Integer, nil] optional context-window token budget
30
+ def initialize(system_prompt: nil, token_limit: nil)
31
+ @messages = []
32
+ @token_limit = token_limit
33
+
34
+ if system_prompt
35
+ add_message(:system, system_prompt)
36
+ end
37
+ end
38
+
39
+ # Append a message to the conversation.
40
+ #
41
+ # @param role [Symbol] one of :system, :user, :assistant, :tool
42
+ # @param content [String, nil] message text
43
+ # @param metadata [Hash] optional fields — :tool_calls, :tool_call_id,
44
+ # :tool_name, :tool_arguments, :tool_result, :model, :tokens
45
+ # @return [Hash] the message that was added
46
+ # @raise [ArgumentError] if role is invalid
47
+ def add_message(role, content = nil, **metadata)
48
+ role = role.to_sym
49
+ unless VALID_ROLES.include?(role)
50
+ raise ArgumentError, "Invalid role: #{role}. Must be one of #{VALID_ROLES.join(", ")}"
51
+ end
52
+ if role == :system && !@messages.empty?
53
+ raise ArgumentError, "System messages are only allowed as the first message"
54
+ end
55
+
56
+ message = {
57
+ role: role,
58
+ content: content,
59
+ created_at: Time.now
60
+ }
61
+
62
+ message[:tool_calls] = metadata[:tool_calls] if metadata[:tool_calls]
63
+ message[:tool_call_id] = metadata[:tool_call_id] if metadata[:tool_call_id]
64
+ message[:tool_name] = metadata[:tool_name] if metadata[:tool_name]
65
+ message[:tool_arguments] = metadata[:tool_arguments] if metadata[:tool_arguments]
66
+ message[:tool_result] = metadata[:tool_result] if metadata[:tool_result]
67
+ message[:model] = metadata[:model] if metadata[:model]
68
+ message[:tokens] = metadata[:tokens] if metadata[:tokens]
69
+
70
+ @messages << message
71
+ deep_copy(message)
72
+ end
73
+
74
+ # Returns the full message history.
75
+ #
76
+ # @return [Array<Hash>] all messages in chronological order
77
+ def messages
78
+ deep_copy(@messages)
79
+ end
80
+
81
+ # @return [Integer] the number of messages in the conversation
82
+ def message_count
83
+ @messages.size
84
+ end
85
+
86
+ # Sum of all tracked tokens (input + output) across messages.
87
+ #
88
+ # @return [Integer] total tokens consumed
89
+ def token_count
90
+ @messages.sum do |msg|
91
+ tokens = msg[:tokens]
92
+ next 0 unless tokens
93
+
94
+ (tokens[:input] || 0) + (tokens[:output] || 0)
95
+ end
96
+ end
97
+
98
+ # Tokens remaining before hitting the limit.
99
+ #
100
+ # @return [Integer, nil] remaining tokens, or nil when no limit is set
101
+ def token_remaining
102
+ return nil unless @token_limit
103
+
104
+ @token_limit - token_count
105
+ end
106
+
107
+ # Whether token usage has reached or exceeded the given threshold of the limit.
108
+ #
109
+ # @param threshold [Float] fraction of token_limit (0.0–1.0) at which to warn
110
+ # @return [Boolean] true when usage >= threshold * limit; false when no limit set
111
+ def approaching_limit?(threshold: 0.8)
112
+ return false unless @token_limit
113
+
114
+ token_count >= (threshold * @token_limit)
115
+ end
116
+
117
+ # Remove oldest non-system messages to free context window.
118
+ #
119
+ # keep_recent counts conversational turns, not individual messages. A turn is
120
+ # anchored by a user message and includes any following assistant/tool
121
+ # messages up to the next user message.
122
+ #
123
+ # @param keep_recent [Integer, nil] minimum number of recent turns to preserve
124
+ # @param keep_system_prompt [Boolean] whether to preserve the system prompt
125
+ # @return [Integer] number of messages removed
126
+ def truncate(keep_recent: nil, keep_system_prompt: true)
127
+ original_size = @messages.size
128
+ system_message = initial_system_message
129
+ system_messages = (keep_system_prompt && system_message) ? [system_message] : []
130
+ non_system = system_message ? @messages.drop(1) : @messages
131
+
132
+ kept = if keep_recent
133
+ recent_turns(non_system, keep_recent).flatten
134
+ else
135
+ non_system
136
+ end
137
+
138
+ @messages = system_messages + kept
139
+ original_size - @messages.size
140
+ end
141
+
142
+ # Format messages for OpenAI-compatible chat completions APIs.
143
+ #
144
+ # @return [Array<Hash>] messages with string roles and content
145
+ def to_openai_messages
146
+ @messages.map { |msg| openai_format(msg) }
147
+ end
148
+
149
+ # Format messages for the Anthropic Messages API.
150
+ #
151
+ # The system prompt is returned separately; tool results are wrapped as
152
+ # content blocks inside user messages per Anthropic's schema.
153
+ #
154
+ # @return [Hash] :system [String, nil] and :messages [Array<Hash>]
155
+ def to_anthropic_messages
156
+ system_prompt = initial_system_message&.dig(:content)
157
+ result_messages = []
158
+
159
+ start_index = system_prompt ? 1 : 0
160
+ @messages.drop(start_index).each do |msg|
161
+ case msg[:role]
162
+ when :user
163
+ result_messages << {
164
+ role: "user",
165
+ content: [{type: "text", text: msg[:content]}]
166
+ }
167
+ when :assistant
168
+ content_blocks = []
169
+ content_blocks << {type: "text", text: msg[:content]} if msg[:content]
170
+
171
+ msg[:tool_calls]&.each do |tc|
172
+ arguments = tool_call_arguments(tc)
173
+ parsed_arguments = if arguments.is_a?(String)
174
+ begin
175
+ JSON.parse(arguments)
176
+ rescue JSON::ParserError
177
+ arguments
178
+ end
179
+ else
180
+ arguments
181
+ end
182
+
183
+ content_blocks << {
184
+ type: "tool_use",
185
+ id: tool_call_value(tc, :id),
186
+ name: tool_call_name(tc),
187
+ input: parsed_arguments
188
+ }
189
+ end
190
+
191
+ result_messages << {role: "assistant", content: content_blocks}
192
+ when :tool
193
+ tool_result_block = {
194
+ type: "tool_result",
195
+ tool_use_id: msg[:tool_call_id],
196
+ content: msg[:content]
197
+ }
198
+ prev = result_messages.last
199
+ if prev && prev[:role] == "user" && prev[:content]&.first&.dig(:type) == "tool_result"
200
+ prev[:content] << tool_result_block
201
+ else
202
+ result_messages << {
203
+ role: "user",
204
+ content: [tool_result_block]
205
+ }
206
+ end
207
+ end
208
+ end
209
+
210
+ {system: system_prompt, messages: result_messages}
211
+ end
212
+
213
+ # Returns the most recent assistant message, or nil.
214
+ #
215
+ # @return [Hash, nil]
216
+ def last_assistant_message
217
+ @messages.reverse_each do |msg|
218
+ return deep_copy(msg) if msg[:role] == :assistant
219
+ end
220
+ nil
221
+ end
222
+
223
+ # Remove all messages except the system prompt.
224
+ #
225
+ # @return [void]
226
+ def clear!
227
+ system_message = initial_system_message
228
+ @messages = system_message ? [system_message] : []
229
+ end
230
+
231
+ private
232
+
233
+ def initial_system_message
234
+ @messages.first if @messages.first&.dig(:role) == :system
235
+ end
236
+
237
+ def recent_turns(non_system_messages, keep_recent)
238
+ turns = non_system_messages.each_with_object([]) do |msg, grouped_turns|
239
+ if msg[:role] == :user || grouped_turns.empty?
240
+ grouped_turns << [msg]
241
+ else
242
+ grouped_turns.last << msg
243
+ end
244
+ end
245
+
246
+ (keep_recent < turns.size) ? turns.last(keep_recent) : turns
247
+ end
248
+
249
+ def openai_format(msg)
250
+ case msg[:role]
251
+ when :tool
252
+ {
253
+ role: "tool",
254
+ content: msg[:content],
255
+ tool_call_id: msg[:tool_call_id]
256
+ }
257
+ when :assistant
258
+ formatted = {role: "assistant", content: msg[:content]}
259
+ if msg[:tool_calls]
260
+ formatted[:tool_calls] = msg[:tool_calls].map do |tc|
261
+ {
262
+ id: tool_call_value(tc, :id),
263
+ type: "function",
264
+ function: {
265
+ name: tool_call_name(tc),
266
+ arguments: serialize_tool_call_arguments(tc)
267
+ }
268
+ }
269
+ end
270
+ end
271
+ formatted
272
+ else
273
+ {role: msg[:role].to_s, content: msg[:content]}
274
+ end
275
+ end
276
+
277
+ def deep_copy(value)
278
+ case value
279
+ when Array
280
+ value.map { |item| deep_copy(item) }
281
+ when Hash
282
+ value.each_with_object({}) do |(key, nested_value), copy|
283
+ copy[key] = deep_copy(nested_value)
284
+ end
285
+ else
286
+ begin
287
+ value.dup
288
+ rescue TypeError
289
+ value
290
+ end
291
+ end
292
+ end
293
+
294
+ def serialize_tool_call_arguments(tool_call)
295
+ arguments = tool_call_arguments(tool_call)
296
+ arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments
297
+ end
298
+
299
+ def tool_call_name(tool_call)
300
+ tool_call_value(tool_call, :name) || nested_tool_call_value(tool_call, :function, :name)
301
+ end
302
+
303
+ def tool_call_arguments(tool_call)
304
+ tool_call_value(tool_call, :arguments) || nested_tool_call_value(tool_call, :function, :arguments)
305
+ end
306
+
307
+ def nested_tool_call_value(tool_call, *keys)
308
+ value = tool_call
309
+ keys.each do |key|
310
+ value = hash_value(value, key)
311
+ return nil if value.nil?
312
+ end
313
+ value
314
+ end
315
+
316
+ def tool_call_value(tool_call, key)
317
+ hash_value(tool_call, key)
318
+ end
319
+
320
+ def hash_value(hash, key)
321
+ return nil unless hash.is_a?(Hash)
322
+
323
+ hash[key] || hash[key.to_s]
324
+ end
325
+ end
326
+ end
@@ -66,6 +66,9 @@ module AgentHarness
66
66
  # subscription to API-metered usage.
67
67
  class AuthMismatchError < AuthenticationError; end
68
68
 
69
+ # Raised when a provider does not support the requested authentication flow.
70
+ class UnsupportedAuthFlowError < Error; end
71
+
69
72
  # Configuration errors
70
73
  class ConfigurationError < Error; end
71
74
 
@@ -75,6 +75,25 @@ module AgentHarness
75
75
  %w[http sse].include?(@transport)
76
76
  end
77
77
 
78
+ # Check if the MCP server is reachable based on its transport type.
79
+ #
80
+ # For stdio servers, checks that a command is present.
81
+ # For HTTP/SSE servers, checks that a URL is present and the server
82
+ # responds to an HTTP HEAD request.
83
+ #
84
+ # @param timeout [Integer] HTTP request timeout in seconds (default: 5)
85
+ # @return [Boolean]
86
+ def reachable?(timeout: 5)
87
+ case transport
88
+ when "stdio"
89
+ !command.nil? && !command.empty?
90
+ when "http", "sse"
91
+ !url.nil? && !url.to_s.strip.empty? && http_ping_ok?(timeout: timeout)
92
+ else
93
+ false
94
+ end
95
+ end
96
+
78
97
  def to_h
79
98
  h = {name: @name, transport: @transport}
80
99
  if stdio?
@@ -153,5 +172,18 @@ module AgentHarness
153
172
  raise McpConfigurationError,
154
173
  "MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
155
174
  end
175
+
176
+ def http_ping_ok?(timeout: 5)
177
+ require "net/http"
178
+ uri = URI.parse(@url)
179
+ http = Net::HTTP.new(uri.host, uri.port)
180
+ http.use_ssl = (uri.scheme == "https")
181
+ http.open_timeout = timeout
182
+ http.read_timeout = timeout
183
+ response = http.head(uri.request_uri)
184
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
185
+ rescue
186
+ false
187
+ end
156
188
  end
157
189
  end