agent-harness 0.5.3 → 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: 3dfde368468f5c0c037ec1cb2fefd705e813d192e3723fd70126757cd851cf14
4
- data.tar.gz: f68f11de99ea61807ab1bb7a94a28aca4aa37e3346469b195a77019f6ceeceec
3
+ metadata.gz: 0ac511d4448bf777f9389cdba34c165a3b97ac607cfca1db90f0fd5c9de0a4af
4
+ data.tar.gz: 946efb4b7f13e36da7b4c3cc8c3efc3722421aa83ccaf9789efdaa4e776d4bc6
5
5
  SHA512:
6
- metadata.gz: 4cf0d1807fee47eb2ef1aecc63ab8c10ce71a681d6c98ca2cb860ae7eb9ba7b1a8d88e4382e9ff1f5f2f25acd9380a6cf4c3d8e61f5cf008d920b4d2bbf7b4bc
7
- data.tar.gz: cd6ac3ae08acf302369a28cdda0a3e332994f679ad37258c0f4dd869f99f18396ccb1cb52d9a6d5bcbb3c6ac3c776ba87a5338edb8280b46785edce254eb8258
6
+ metadata.gz: 7c3afe5167530f2cd4f8b435b5098732a12b3630f4324cbfa002f3983d760e3a41488ae0d565a0a0694d9b08704d74f010845fb39251a8f92782c6c6d01a572e
7
+ data.tar.gz: 8a9d9706b997b2c8543a45a43cdf476a089eaded7283fe6b3dade1394242f9786c7458b68a2953bcb866611d21a74a360a35d5b1ae3ade40ebe222c819d3c7ce
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.3"
2
+ ".": "0.5.4"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.5.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.2...agent-harness/v0.5.3) (2026-03-27)
4
11
 
5
12
 
@@ -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
@@ -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,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.3"
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"
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.3
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