agent-harness 0.5.2 → 0.5.4

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: f3ca48a731020ba5b0668cee27ca19c8d7ca8e9cfc907d0f9b4f42d77b56b7a8
4
- data.tar.gz: 69b9c2ce746b4aa1a594479a7295a6510468e1f0d428a02896c218c711889b91
3
+ metadata.gz: 0ac511d4448bf777f9389cdba34c165a3b97ac607cfca1db90f0fd5c9de0a4af
4
+ data.tar.gz: 946efb4b7f13e36da7b4c3cc8c3efc3722421aa83ccaf9789efdaa4e776d4bc6
5
5
  SHA512:
6
- metadata.gz: cb329e3f9d7e441d692a7a88ce7ed8c1637f0b314875ae355d5c4c088275fbc77e0726e979069a905c458d05599ea6f90b94434e8a2edcacee7eeb6878b32afc
7
- data.tar.gz: 3b56558191606eb4431b6d9253a4dec6ada80a605af3af7422bd7db2163f1ce61b1b0b4c55c1b2f6dab4e046aa1504afbb717a172491e76f3f6f7d114b97c9f8
6
+ metadata.gz: 7c3afe5167530f2cd4f8b435b5098732a12b3630f4324cbfa002f3983d760e3a41488ae0d565a0a0694d9b08704d74f010845fb39251a8f92782c6c6d01a572e
7
+ data.tar.gz: 8a9d9706b997b2c8543a45a43cdf476a089eaded7283fe6b3dade1394242f9786c7458b68a2953bcb866611d21a74a360a35d5b1ae3ade40ebe222c819d3c7ce
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.2"
2
+ ".": "0.5.4"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.4](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.3...agent-harness/v0.5.4) (2026-03-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 44: feat(mcp): add first-class MCP server configuration to request execution ([#45](https://github.com/viamin/agent-harness/issues/45)) ([454cd9b](https://github.com/viamin/agent-harness/commit/454cd9be1c4bcd2eb92a4ca6f81cc012d4ce1f8c))
9
+
10
+ ## [0.5.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.2...agent-harness/v0.5.3) (2026-03-27)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 41: Add provider-specific health/auth checks for Gemini and Codex ([#42](https://github.com/viamin/agent-harness/issues/42)) ([be95135](https://github.com/viamin/agent-harness/commit/be9513534e55aa3df9c0885b6e3580a3b146eb93))
16
+
3
17
  ## [0.5.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.1...agent-harness/v0.5.2) (2026-03-24)
4
18
 
5
19
 
@@ -57,6 +57,20 @@ module AgentHarness
57
57
  # Configuration errors
58
58
  class ConfigurationError < Error; end
59
59
 
60
+ # MCP-specific errors
61
+ class McpConfigurationError < ConfigurationError; end
62
+
63
+ class McpUnsupportedError < ProviderError
64
+ attr_reader :provider
65
+
66
+ def initialize(message = nil, provider: nil, **kwargs)
67
+ @provider = provider
68
+ super(message, **kwargs)
69
+ end
70
+ end
71
+
72
+ class McpTransportUnsupportedError < McpUnsupportedError; end
73
+
60
74
  # Orchestration errors
61
75
  class NoProvidersAvailableError < Error
62
76
  attr_reader :attempted_providers, :errors
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ # Canonical representation of an MCP server for request-time execution.
5
+ #
6
+ # Provider-agnostic value object that can be translated by each provider
7
+ # adapter into its CLI-specific configuration.
8
+ #
9
+ # @example stdio server
10
+ # McpServer.new(
11
+ # name: "filesystem",
12
+ # transport: "stdio",
13
+ # command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
14
+ # env: { "DEBUG" => "0" }
15
+ # )
16
+ #
17
+ # @example HTTP/URL server
18
+ # McpServer.new(
19
+ # name: "playwright",
20
+ # transport: "http",
21
+ # url: "http://mcp-playwright:3000/mcp"
22
+ # )
23
+ class McpServer
24
+ VALID_TRANSPORTS = %w[stdio http sse].freeze
25
+
26
+ attr_reader :name, :transport, :command, :args, :env, :url
27
+
28
+ # @param name [String] unique name for this MCP server
29
+ # @param transport [String] one of "stdio", "http", "sse"
30
+ # @param command [Array<String>, nil] command to launch (stdio only)
31
+ # @param args [Array<String>, nil] additional args for the command
32
+ # @param env [Hash<String,String>, nil] environment variables for the server process
33
+ # @param url [String, nil] URL for HTTP/SSE transport
34
+ def initialize(name:, transport:, command: nil, args: nil, env: nil, url: nil)
35
+ @name = name
36
+ @transport = transport.to_s
37
+ @command = command
38
+ @args = args || []
39
+ @env = env || {}
40
+ @url = url
41
+
42
+ validate!
43
+ end
44
+
45
+ # Build from a plain Hash (e.g. from user input or serialized config)
46
+ #
47
+ # @param hash [Hash] server definition
48
+ # @return [McpServer]
49
+ def self.from_hash(hash)
50
+ unless hash.is_a?(Hash)
51
+ raise McpConfigurationError, "MCP server definition must be a Hash, got #{hash.class}"
52
+ end
53
+
54
+ begin
55
+ hash = hash.transform_keys(&:to_sym)
56
+ rescue NoMethodError, TypeError => e
57
+ raise McpConfigurationError, "MCP server hash contains invalid keys: #{e.message}"
58
+ end
59
+
60
+ new(
61
+ name: hash[:name],
62
+ transport: hash[:transport],
63
+ command: hash[:command],
64
+ args: hash[:args],
65
+ env: hash[:env],
66
+ url: hash[:url]
67
+ )
68
+ end
69
+
70
+ def stdio?
71
+ @transport == "stdio"
72
+ end
73
+
74
+ def http?
75
+ %w[http sse].include?(@transport)
76
+ end
77
+
78
+ def to_h
79
+ h = {name: @name, transport: @transport}
80
+ if stdio?
81
+ h[:command] = @command
82
+ h[:args] = @args unless @args.empty?
83
+ else
84
+ h[:url] = @url
85
+ end
86
+ h[:env] = @env unless @env.empty?
87
+ h
88
+ end
89
+
90
+ private
91
+
92
+ def validate!
93
+ raise McpConfigurationError, "MCP server name is required" if @name.nil? || @name.to_s.strip.empty?
94
+
95
+ unless VALID_TRANSPORTS.include?(@transport)
96
+ raise McpConfigurationError,
97
+ "Invalid MCP transport '#{@transport}' for server '#{@name}'. Valid transports: #{VALID_TRANSPORTS.join(", ")}"
98
+ end
99
+
100
+ validate_args!
101
+ validate_env!
102
+ validate_stdio! if stdio?
103
+ validate_http! if http?
104
+ validate_no_stdio_only_fields_on_http! if http?
105
+ end
106
+
107
+ def validate_args!
108
+ return if @args.is_a?(Array) && @args.all? { |a| a.is_a?(String) }
109
+
110
+ raise McpConfigurationError,
111
+ "MCP server '#{@name}' args must be an Array of Strings"
112
+ end
113
+
114
+ def validate_env!
115
+ return if @env.is_a?(Hash) && @env.keys.all? { |k| k.is_a?(String) } && @env.values.all? { |v| v.is_a?(String) }
116
+
117
+ raise McpConfigurationError,
118
+ "MCP server '#{@name}' env must be a Hash with String keys and values"
119
+ end
120
+
121
+ def validate_stdio!
122
+ if @command.nil? || !@command.is_a?(Array) || @command.empty?
123
+ raise McpConfigurationError,
124
+ "MCP server '#{@name}' with stdio transport requires a non-empty command array"
125
+ end
126
+
127
+ unless @command.all? { |c| c.is_a?(String) }
128
+ raise McpConfigurationError,
129
+ "MCP server '#{@name}' command must contain only strings"
130
+ end
131
+
132
+ return if @url.nil?
133
+
134
+ raise McpConfigurationError,
135
+ "MCP server '#{@name}' with stdio transport should not have a url"
136
+ end
137
+
138
+ def validate_http!
139
+ if @url.nil? || @url.to_s.strip.empty?
140
+ raise McpConfigurationError,
141
+ "MCP server '#{@name}' with #{@transport} transport requires a url"
142
+ end
143
+
144
+ return if @command.nil?
145
+
146
+ raise McpConfigurationError,
147
+ "MCP server '#{@name}' with #{@transport} transport should not have a command"
148
+ end
149
+
150
+ def validate_no_stdio_only_fields_on_http!
151
+ return if @args.empty?
152
+
153
+ raise McpConfigurationError,
154
+ "MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
155
+ end
156
+ end
157
+ end
@@ -124,6 +124,58 @@ module AgentHarness
124
124
  []
125
125
  end
126
126
 
127
+ # Supported MCP transport types for this provider
128
+ #
129
+ # @return [Array<String>] supported transports (e.g. ["stdio", "http"])
130
+ def supported_mcp_transports
131
+ []
132
+ end
133
+
134
+ # Build provider-specific MCP flags/arguments for CLI invocation
135
+ #
136
+ # @param mcp_servers [Array<McpServer>] MCP server definitions
137
+ # @param working_dir [String, nil] working directory for temp files
138
+ # @return [Array<String>] CLI flags to append to the command
139
+ def build_mcp_flags(mcp_servers, working_dir: nil)
140
+ []
141
+ end
142
+
143
+ # Validate that this provider can handle the given MCP servers
144
+ #
145
+ # @param mcp_servers [Array<McpServer>] MCP server definitions
146
+ # @raise [McpUnsupportedError] if MCP is not supported
147
+ # @raise [McpTransportUnsupportedError] if a transport is not supported
148
+ def validate_mcp_servers!(mcp_servers)
149
+ return if mcp_servers.nil? || mcp_servers.empty?
150
+
151
+ unless supports_mcp?
152
+ raise McpUnsupportedError.new(
153
+ "Provider '#{self.class.provider_name}' does not support MCP servers",
154
+ provider: self.class.provider_name
155
+ )
156
+ end
157
+
158
+ supported = supported_mcp_transports
159
+
160
+ if supported.empty?
161
+ raise McpUnsupportedError.new(
162
+ "Provider '#{self.class.provider_name}' does not support request-time MCP servers",
163
+ provider: self.class.provider_name
164
+ )
165
+ end
166
+
167
+ mcp_servers.each do |server|
168
+ next if supported.include?(server.transport)
169
+
170
+ raise McpTransportUnsupportedError.new(
171
+ "Provider '#{self.class.provider_name}' does not support MCP transport " \
172
+ "'#{server.transport}' (server: '#{server.name}'). " \
173
+ "Supported transports: #{supported.join(", ")}",
174
+ provider: self.class.provider_name
175
+ )
176
+ end
177
+ end
178
+
127
179
  # Check if provider supports dangerous mode
128
180
  #
129
181
  # @return [Boolean] true if dangerous mode is supported
@@ -172,10 +172,27 @@ module AgentHarness
172
172
  }
173
173
  end
174
174
 
175
+ def send_message(prompt:, **options)
176
+ super
177
+ ensure
178
+ cleanup_mcp_tempfiles!
179
+ end
180
+
175
181
  def supports_mcp?
176
182
  true
177
183
  end
178
184
 
185
+ def supported_mcp_transports
186
+ %w[stdio http sse]
187
+ end
188
+
189
+ def build_mcp_flags(mcp_servers, working_dir: nil)
190
+ return [] if mcp_servers.empty?
191
+
192
+ config_path = write_mcp_config_file(mcp_servers, working_dir: working_dir)
193
+ ["--mcp-config", config_path]
194
+ end
195
+
179
196
  def supports_dangerous_mode?
180
197
  true
181
198
  end
@@ -266,6 +283,11 @@ module AgentHarness
266
283
  cmd += dangerous_mode_flags
267
284
  end
268
285
 
286
+ # Add MCP server flags (validated/normalized by Base#send_message)
287
+ if options[:mcp_servers]&.any?
288
+ cmd += build_mcp_flags(options[:mcp_servers])
289
+ end
290
+
269
291
  # Add custom flags from config
270
292
  cmd += @config.default_flags if @config.default_flags&.any?
271
293
 
@@ -376,6 +398,87 @@ module AgentHarness
376
398
  servers
377
399
  end
378
400
 
401
+ def write_mcp_config_file(mcp_servers, working_dir: nil)
402
+ require "tempfile"
403
+ require "tmpdir"
404
+ require "securerandom"
405
+
406
+ config = build_claude_mcp_config(mcp_servers)
407
+ config_json = JSON.generate(config)
408
+
409
+ if @executor.is_a?(DockerCommandExecutor)
410
+ # When running inside a Docker container, write the config file
411
+ # inside the container so the CLI process can read it.
412
+ # Track the path so cleanup_mcp_tempfiles! can remove it after execution.
413
+ container_path = "/tmp/agent_harness_mcp_#{SecureRandom.hex(8)}.json"
414
+ result = @executor.execute(
415
+ ["sh", "-c", "cat > #{container_path}"],
416
+ stdin_data: config_json,
417
+ timeout: 5
418
+ )
419
+ unless result.success?
420
+ raise McpConfigurationError,
421
+ "Failed to write MCP config inside container: #{result.stderr}"
422
+ end
423
+
424
+ @mcp_docker_config_paths ||= []
425
+ @mcp_docker_config_paths << container_path
426
+
427
+ container_path
428
+ else
429
+ dir = working_dir || Dir.tmpdir
430
+ file = Tempfile.new(["agent_harness_mcp_", ".json"], dir)
431
+ file.write(config_json)
432
+ file.close
433
+
434
+ # Hold a reference so the Tempfile is not garbage-collected (and
435
+ # therefore deleted) before the CLI process reads it.
436
+ # Cleaned up by cleanup_mcp_tempfiles! after execution.
437
+ @mcp_config_tempfiles ||= []
438
+ @mcp_config_tempfiles << file
439
+
440
+ file.path
441
+ end
442
+ end
443
+
444
+ def build_claude_mcp_config(mcp_servers)
445
+ servers = {}
446
+ mcp_servers.each do |server|
447
+ h = if server.stdio?
448
+ entry = {command: server.command.first}
449
+ remaining_args = server.command[1..] + server.args
450
+ entry[:args] = remaining_args unless remaining_args.empty?
451
+ entry
452
+ else
453
+ {url: server.url}
454
+ end
455
+ h[:env] = server.env unless server.env.empty?
456
+ servers[server.name] = h
457
+ end
458
+ {mcpServers: servers}
459
+ end
460
+
461
+ def cleanup_mcp_tempfiles!
462
+ if @mcp_config_tempfiles
463
+ @mcp_config_tempfiles.each do |file|
464
+ file.close unless file.closed?
465
+ file.unlink
466
+ rescue
467
+ nil
468
+ end
469
+ @mcp_config_tempfiles = nil
470
+ end
471
+
472
+ if @mcp_docker_config_paths
473
+ @mcp_docker_config_paths.each do |path|
474
+ @executor.execute(["rm", "-f", path], timeout: 5)
475
+ rescue
476
+ nil
477
+ end
478
+ @mcp_docker_config_paths = nil
479
+ end
480
+ end
481
+
379
482
  def log_debug(action, **context)
380
483
  @logger&.debug("[AgentHarness::Anthropic] #{action}: #{context.inspect}")
381
484
  end
@@ -63,6 +63,10 @@ module AgentHarness
63
63
  def send_message(prompt:, **options)
64
64
  log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
65
65
 
66
+ # Normalize and validate MCP servers
67
+ options = normalize_mcp_servers(options)
68
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
69
+
66
70
  # Build command
67
71
  command = build_command(prompt, options)
68
72
 
@@ -83,6 +87,8 @@ module AgentHarness
83
87
  log_debug("send_message_complete", duration: duration, tokens: response.tokens)
84
88
 
85
89
  response
90
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
91
+ raise
86
92
  rescue => e
87
93
  handle_error(e, prompt: prompt, options: options)
88
94
  end
@@ -145,6 +151,38 @@ module AgentHarness
145
151
 
146
152
  private
147
153
 
154
+ def normalize_mcp_servers(options)
155
+ servers = options[:mcp_servers]
156
+ return options if servers.nil?
157
+
158
+ unless servers.is_a?(Array)
159
+ raise McpConfigurationError,
160
+ "mcp_servers must be an Array of Hash or McpServer, got #{servers.class}"
161
+ end
162
+
163
+ return options if servers.empty?
164
+
165
+ normalized = servers.map do |server|
166
+ if server.is_a?(McpServer)
167
+ server
168
+ elsif server.is_a?(Hash)
169
+ McpServer.from_hash(server)
170
+ else
171
+ raise McpConfigurationError, "MCP server must be a Hash or McpServer, got #{server.class}"
172
+ end
173
+ end
174
+
175
+ # Ensure MCP server names are unique to avoid silent overwrites downstream
176
+ names = normalized.map(&:name)
177
+ duplicate_names = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
178
+ unless duplicate_names.empty?
179
+ raise McpConfigurationError,
180
+ "Duplicate MCP server names detected: #{duplicate_names.join(", ")}"
181
+ end
182
+
183
+ options.merge(mcp_servers: normalized)
184
+ end
185
+
148
186
  def execute_with_timeout(command, timeout:, env:)
149
187
  @executor.execute(command, timeout: timeout, env: env)
150
188
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module AgentHarness
4
6
  module Providers
5
7
  # OpenAI Codex CLI provider
@@ -78,11 +80,104 @@ module AgentHarness
78
80
  ["--session", session_id]
79
81
  end
80
82
 
83
+ def error_patterns
84
+ {
85
+ rate_limited: [
86
+ /rate.?limit/i,
87
+ /too.?many.?requests/i,
88
+ /429/
89
+ ],
90
+ auth_expired: [
91
+ /invalid.*api.*key/i,
92
+ /unauthorized/i,
93
+ /authentication/i,
94
+ /401/,
95
+ /incorrect.*api.*key/i
96
+ ],
97
+ quota_exceeded: [
98
+ /quota.*exceeded/i,
99
+ /insufficient.*quota/i,
100
+ /billing/i
101
+ ],
102
+ transient: [
103
+ /timeout/i,
104
+ /connection.*reset/i,
105
+ /service.*unavailable/i,
106
+ /503/,
107
+ /502/
108
+ ]
109
+ }
110
+ end
111
+
112
+ def auth_status
113
+ api_key = ENV["OPENAI_API_KEY"]
114
+ if api_key && !api_key.strip.empty?
115
+ if api_key.strip.start_with?("sk-")
116
+ return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
117
+ else
118
+ return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key"}
119
+ end
120
+ end
121
+
122
+ credentials = read_codex_credentials
123
+ if credentials
124
+ key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
125
+ if key.is_a?(String) && !key.strip.empty?
126
+ if key.strip.start_with?("sk-")
127
+ return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
128
+ else
129
+ return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key"}
130
+ end
131
+ end
132
+ end
133
+
134
+ {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}"}
135
+ rescue IOError, JSON::ParserError => e
136
+ {valid: false, expires_at: nil, error: e.message}
137
+ end
138
+
139
+ def health_status
140
+ unless self.class.available?
141
+ return {healthy: false, message: "Codex CLI not found in PATH. Install from https://github.com/openai/codex"}
142
+ end
143
+
144
+ auth = auth_status
145
+ unless auth[:valid]
146
+ return {healthy: false, message: auth[:error]}
147
+ end
148
+
149
+ {healthy: true, message: "Codex CLI available and authenticated"}
150
+ end
151
+
152
+ def validate_config
153
+ errors = []
154
+
155
+ flags = @config.default_flags
156
+ unless flags.nil?
157
+ if flags.is_a?(Array)
158
+ invalid = flags.reject { |f| f.is_a?(String) }
159
+ errors << "default_flags contains non-string values" if invalid.any?
160
+ else
161
+ errors << "default_flags must be an array of strings"
162
+ end
163
+ end
164
+
165
+ {valid: errors.empty?, errors: errors}
166
+ end
167
+
81
168
  protected
82
169
 
83
170
  def build_command(prompt, options)
84
171
  cmd = [self.class.binary_name, "exec"]
85
172
 
173
+ flags = @config.default_flags
174
+ if flags
175
+ unless flags.is_a?(Array)
176
+ raise ArgumentError, "Codex configuration error: default_flags must be an array of strings"
177
+ end
178
+ cmd += flags if flags.any?
179
+ end
180
+
86
181
  if options[:session]
87
182
  cmd += session_flags(options[:session])
88
183
  end
@@ -95,6 +190,29 @@ module AgentHarness
95
190
  def default_timeout
96
191
  300
97
192
  end
193
+
194
+ private
195
+
196
+ def read_codex_credentials
197
+ path = codex_config_path
198
+ return nil unless File.exist?(path)
199
+
200
+ parsed = JSON.parse(File.read(path))
201
+ return nil unless parsed.is_a?(Hash)
202
+
203
+ parsed
204
+ rescue Errno::ENOENT
205
+ nil
206
+ rescue Errno::EACCES => e
207
+ raise IOError, "Permission denied reading Codex config at #{path}: #{e.message}"
208
+ rescue JSON::ParserError
209
+ raise JSON::ParserError, "Invalid JSON in Codex config at #{path}"
210
+ end
211
+
212
+ def codex_config_path
213
+ config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
214
+ File.join(config_dir, "config.json")
215
+ end
98
216
  end
99
217
  end
100
218
  end
@@ -109,6 +109,14 @@ module AgentHarness
109
109
  true
110
110
  end
111
111
 
112
+ # Cursor supports MCP for fetching existing server configurations (via
113
+ # fetch_mcp_servers) but does not support injecting request-time MCP
114
+ # servers into CLI invocations. Returning an empty list causes
115
+ # validate_mcp_servers! to raise McpUnsupportedError with a clear message.
116
+ def supported_mcp_transports
117
+ []
118
+ end
119
+
112
120
  def fetch_mcp_servers
113
121
  # Try CLI first, then config file
114
122
  fetch_mcp_servers_cli || fetch_mcp_servers_config
@@ -142,6 +150,10 @@ module AgentHarness
142
150
  def send_message(prompt:, **options)
143
151
  log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
144
152
 
153
+ # Normalize and validate MCP servers (same as Base#send_message)
154
+ options = normalize_mcp_servers(options)
155
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
156
+
145
157
  # Build command (without prompt in args - we send via stdin)
146
158
  command = [self.class.binary_name, "-p"]
147
159
 
@@ -162,6 +174,8 @@ module AgentHarness
162
174
  log_debug("send_message_complete", duration: duration)
163
175
 
164
176
  response
177
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
178
+ raise
165
179
  rescue => e
166
180
  handle_error(e, prompt: prompt, options: options)
167
181
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "time"
5
+
3
6
  module AgentHarness
4
7
  module Providers
5
8
  # Google Gemini CLI provider
@@ -106,7 +109,11 @@ module AgentHarness
106
109
  auth_expired: [
107
110
  /authentication/i,
108
111
  /unauthorized/i,
109
- /invalid.?credentials/i
112
+ /invalid.?credentials/i,
113
+ /login.*required/i,
114
+ /not.*logged.*in/i,
115
+ /credentials.*expired/i,
116
+ /account.*not.*verified/i
110
117
  ],
111
118
  transient: [
112
119
  /timeout/i,
@@ -116,6 +123,68 @@ module AgentHarness
116
123
  }
117
124
  end
118
125
 
126
+ def auth_status
127
+ api_key = [ENV["GEMINI_API_KEY"], ENV["GOOGLE_API_KEY"]].find { |key| key && !key.strip.empty? }
128
+ if api_key
129
+ return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
130
+ end
131
+
132
+ credentials = read_gemini_credentials
133
+ return {valid: false, expires_at: nil, error: "No Gemini credentials found. Run 'gemini auth login' or set GEMINI_API_KEY or GOOGLE_API_KEY"} unless credentials
134
+
135
+ token = credentials["access_token"] || credentials["oauth_token"]
136
+ unless token.is_a?(String) && !token.strip.empty?
137
+ return {valid: false, expires_at: nil, error: "No authentication token in Gemini credentials"}
138
+ end
139
+
140
+ expires_at = parse_gemini_expiry(credentials)
141
+ if expires_at && expires_at < Time.now
142
+ {valid: false, expires_at: expires_at, error: "Gemini session expired. Run 'gemini auth login' to re-authenticate"}
143
+ else
144
+ {valid: true, expires_at: expires_at, error: nil, auth_method: :oauth}
145
+ end
146
+ rescue IOError, JSON::ParserError => e
147
+ {valid: false, expires_at: nil, error: e.message}
148
+ end
149
+
150
+ def health_status
151
+ unless self.class.available?
152
+ return {healthy: false, message: "Gemini CLI not found in PATH. Install from https://github.com/google-gemini/gemini-cli"}
153
+ end
154
+
155
+ auth = auth_status
156
+ unless auth[:valid]
157
+ return {healthy: false, message: auth[:error]}
158
+ end
159
+
160
+ {healthy: true, message: "Gemini CLI available and authenticated"}
161
+ end
162
+
163
+ def validate_config
164
+ errors = []
165
+
166
+ model = @config.model
167
+ if !model.nil? && !model.is_a?(String)
168
+ errors << "model must be a string"
169
+ elsif model.is_a?(String) && !model.empty?
170
+ unless self.class.supports_model_family?(model)
171
+ errors << "Unrecognized model '#{model}'. Expected a Gemini model (e.g., gemini-2.0-flash, gemini-2.5-pro)"
172
+ end
173
+ end
174
+
175
+ flags = @config.default_flags
176
+ unless flags.nil?
177
+ if flags.is_a?(Array)
178
+ invalid = flags.reject { |f| f.is_a?(String) }
179
+ errors << "default_flags contains non-string values" if invalid.any?
180
+ else
181
+ errors << "default_flags must be an array of strings"
182
+ end
183
+ end
184
+
185
+ {valid: errors.empty?, errors: errors}
186
+ end
187
+
119
188
  protected
120
189
 
121
190
  def build_command(prompt, options)
@@ -125,7 +194,13 @@ module AgentHarness
125
194
  cmd += ["--model", @config.model]
126
195
  end
127
196
 
128
- cmd += @config.default_flags if @config.default_flags&.any?
197
+ flags = @config.default_flags
198
+ if flags
199
+ unless flags.is_a?(Array)
200
+ raise ArgumentError, "Gemini configuration error: default_flags must be an array of strings"
201
+ end
202
+ cmd += flags if flags.any?
203
+ end
129
204
 
130
205
  cmd += ["--prompt", prompt]
131
206
 
@@ -135,6 +210,45 @@ module AgentHarness
135
210
  def default_timeout
136
211
  300
137
212
  end
213
+
214
+ private
215
+
216
+ def read_gemini_credentials
217
+ path = gemini_credentials_path
218
+ return nil unless File.exist?(path)
219
+
220
+ parsed = JSON.parse(File.read(path))
221
+ return nil unless parsed.is_a?(Hash)
222
+
223
+ parsed
224
+ rescue Errno::ENOENT
225
+ nil
226
+ rescue Errno::EACCES => e
227
+ raise IOError, "Permission denied reading Gemini credentials at #{path}: #{e.message}"
228
+ rescue JSON::ParserError
229
+ raise JSON::ParserError, "Invalid JSON in Gemini credentials at #{path}"
230
+ end
231
+
232
+ def gemini_credentials_path
233
+ config_dir = ENV["GEMINI_CONFIG_DIR"] || File.expand_path("~/.gemini")
234
+ File.join(config_dir, "credentials.json")
235
+ end
236
+
237
+ def parse_gemini_expiry(credentials)
238
+ value = credentials["expires_at"] || credentials["expiresAt"] || credentials["expiry"]
239
+ return nil unless value
240
+
241
+ case value
242
+ when Time
243
+ value
244
+ when Integer, Float
245
+ Time.at(value)
246
+ when String
247
+ Time.parse(value)
248
+ end
249
+ rescue ArgumentError
250
+ nil
251
+ end
138
252
  end
139
253
  end
140
254
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Mistral Vibe CLI provider
6
+ #
7
+ # Provides integration with the Mistral Vibe CLI agent tool.
8
+ class MistralVibe < Base
9
+ class << self
10
+ def provider_name
11
+ :mistral_vibe
12
+ end
13
+
14
+ def binary_name
15
+ "mistral-vibe"
16
+ end
17
+
18
+ def available?
19
+ executor = AgentHarness.configuration.command_executor
20
+ !!executor.which(binary_name)
21
+ end
22
+
23
+ def firewall_requirements
24
+ {
25
+ domains: [
26
+ "api.mistral.ai"
27
+ ],
28
+ ip_ranges: []
29
+ }
30
+ end
31
+
32
+ def instruction_file_paths
33
+ []
34
+ end
35
+
36
+ def discover_models
37
+ return [] unless available?
38
+ []
39
+ end
40
+ end
41
+
42
+ def name
43
+ "mistral_vibe"
44
+ end
45
+
46
+ def display_name
47
+ "Mistral Vibe CLI"
48
+ end
49
+
50
+ def capabilities
51
+ {
52
+ streaming: false,
53
+ file_upload: false,
54
+ vision: false,
55
+ tool_use: false,
56
+ json_mode: false,
57
+ mcp: false,
58
+ dangerous_mode: false
59
+ }
60
+ end
61
+
62
+ protected
63
+
64
+ def build_command(prompt, options)
65
+ cmd = [self.class.binary_name, "run"]
66
+ cmd << prompt
67
+ cmd
68
+ end
69
+
70
+ def default_timeout
71
+ 300
72
+ end
73
+ end
74
+ end
75
+ end
@@ -123,6 +123,7 @@ module AgentHarness
123
123
  register_if_available(:opencode, "agent_harness/providers/opencode", :Opencode)
124
124
  register_if_available(:kilocode, "agent_harness/providers/kilocode", :Kilocode)
125
125
  register_if_available(:aider, "agent_harness/providers/aider", :Aider)
126
+ register_if_available(:mistral_vibe, "agent_harness/providers/mistral_vibe", :MistralVibe)
126
127
  end
127
128
 
128
129
  def register_if_available(name, require_path, class_name, aliases: [])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.2"
4
+ VERSION = "0.5.4"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -137,6 +137,7 @@ end
137
137
 
138
138
  # Core components
139
139
  require_relative "agent_harness/errors"
140
+ require_relative "agent_harness/mcp_server"
140
141
  require_relative "agent_harness/configuration"
141
142
  require_relative "agent_harness/command_executor"
142
143
  require_relative "agent_harness/docker_command_executor"
@@ -157,6 +158,7 @@ require_relative "agent_harness/providers/cursor"
157
158
  require_relative "agent_harness/providers/gemini"
158
159
  require_relative "agent_harness/providers/github_copilot"
159
160
  require_relative "agent_harness/providers/kilocode"
161
+ require_relative "agent_harness/providers/mistral_vibe"
160
162
  require_relative "agent_harness/providers/opencode"
161
163
 
162
164
  # Orchestration layer
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -84,6 +84,7 @@ files:
84
84
  - lib/agent_harness/docker_command_executor.rb
85
85
  - lib/agent_harness/error_taxonomy.rb
86
86
  - lib/agent_harness/errors.rb
87
+ - lib/agent_harness/mcp_server.rb
87
88
  - lib/agent_harness/orchestration/circuit_breaker.rb
88
89
  - lib/agent_harness/orchestration/conductor.rb
89
90
  - lib/agent_harness/orchestration/health_monitor.rb
@@ -100,6 +101,7 @@ files:
100
101
  - lib/agent_harness/providers/gemini.rb
101
102
  - lib/agent_harness/providers/github_copilot.rb
102
103
  - lib/agent_harness/providers/kilocode.rb
104
+ - lib/agent_harness/providers/mistral_vibe.rb
103
105
  - lib/agent_harness/providers/opencode.rb
104
106
  - lib/agent_harness/providers/registry.rb
105
107
  - lib/agent_harness/response.rb