agent-harness 0.11.1 → 0.11.3

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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module McpConfigTranslator
5
+ module_function
6
+
7
+ def for_provider(provider, mcp_servers)
8
+ servers = normalize_servers(mcp_servers)
9
+
10
+ case provider.to_sym
11
+ when :anthropic, :claude, :claude_code
12
+ translate_for_claude(servers)
13
+ when :codex
14
+ translate_for_codex(servers)
15
+ when :openai
16
+ translate_for_openai(servers)
17
+ else
18
+ servers.map(&:to_h)
19
+ end
20
+ end
21
+
22
+ def normalize_servers(mcp_servers)
23
+ Array(mcp_servers).map do |server|
24
+ case server
25
+ when McpServer
26
+ server
27
+ when Hash
28
+ McpServer.from_hash(server)
29
+ else
30
+ raise McpConfigurationError, "MCP server must be a Hash or McpServer, got #{server.class}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def translate_for_claude(mcp_servers)
36
+ {
37
+ mcpServers: mcp_servers.each_with_object({}) do |server, memo|
38
+ entry = server.stdio? ? {command: server.command} : {url: server.url}
39
+ entry[:args] = server.args if server.stdio? && !server.args.empty?
40
+ entry[:env] = server.env unless server.env.empty?
41
+ entry[:headers] = server.headers if server.http? && !server.headers.empty?
42
+ memo[server.name] = entry
43
+ end
44
+ }
45
+ end
46
+
47
+ def translate_for_codex(mcp_servers)
48
+ {
49
+ mcp_servers: mcp_servers.each_with_object({}) do |server, memo|
50
+ entry = server.stdio? ? {command: server.command, transport: server.transport} : {url: server.url, transport: server.transport}
51
+ entry[:args] = server.args if server.stdio? && !server.args.empty?
52
+ entry[:env] = server.env unless server.env.empty?
53
+ entry[:headers] = server.headers if server.http? && !server.headers.empty?
54
+ memo[server.name] = entry
55
+ end
56
+ }
57
+ end
58
+
59
+ def translate_for_openai(mcp_servers)
60
+ mcp_servers.map do |server|
61
+ unless server.http?
62
+ raise McpTransportUnsupportedError.new(
63
+ "Provider 'openai' only supports remote MCP servers over HTTP/SSE (server: '#{server.name}')",
64
+ provider: :openai
65
+ )
66
+ end
67
+
68
+ unsupported_headers = server.headers.keys - ["Authorization"]
69
+ unless unsupported_headers.empty?
70
+ raise McpConfigurationError,
71
+ "OpenAI MCP translation only supports the Authorization header (server: '#{server.name}')"
72
+ end
73
+
74
+ tool = {
75
+ type: "mcp",
76
+ server_label: server.name,
77
+ server_url: server.url,
78
+ require_approval: "never"
79
+ }
80
+ tool[:authorization] = server.headers["Authorization"] if server.headers.key?("Authorization")
81
+ tool
82
+ end
83
+ end
84
+ end
85
+ end
@@ -10,7 +10,8 @@ module AgentHarness
10
10
  # McpServer.new(
11
11
  # name: "filesystem",
12
12
  # transport: "stdio",
13
- # command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
13
+ # command: "npx",
14
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
14
15
  # env: { "DEBUG" => "0" }
15
16
  # )
16
17
  #
@@ -23,21 +24,24 @@ module AgentHarness
23
24
  class McpServer
24
25
  VALID_TRANSPORTS = %w[stdio http sse].freeze
25
26
 
26
- attr_reader :name, :transport, :command, :args, :env, :url
27
+ attr_reader :name, :transport, :command, :args, :env, :url, :headers
27
28
 
28
29
  # @param name [String] unique name for this MCP server
29
30
  # @param transport [String] one of "stdio", "http", "sse"
30
- # @param command [Array<String>, nil] command to launch (stdio only)
31
+ # @param command [String, Array<String>, nil] executable to launch (stdio only).
32
+ # Arrays are accepted for backwards compatibility and normalized into
33
+ # +command+ plus +args+.
31
34
  # @param args [Array<String>, nil] additional args for the command
32
35
  # @param env [Hash<String,String>, nil] environment variables for the server process
33
36
  # @param url [String, nil] URL for HTTP/SSE transport
34
- def initialize(name:, transport:, command: nil, args: nil, env: nil, url: nil)
37
+ # @param headers [Hash<String,String>, nil] HTTP headers for remote MCP servers
38
+ def initialize(name:, transport:, command: nil, args: nil, env: nil, url: nil, headers: nil)
35
39
  @name = name
36
40
  @transport = transport.to_s
37
- @command = command
38
- @args = args || []
41
+ @command, @args = normalize_command_and_args(command, args)
39
42
  @env = env || {}
40
43
  @url = url
44
+ @headers = headers || {}
41
45
 
42
46
  validate!
43
47
  end
@@ -63,7 +67,8 @@ module AgentHarness
63
67
  command: hash[:command],
64
68
  args: hash[:args],
65
69
  env: hash[:env],
66
- url: hash[:url]
70
+ url: hash[:url],
71
+ headers: hash[:headers]
67
72
  )
68
73
  end
69
74
 
@@ -75,6 +80,12 @@ module AgentHarness
75
80
  %w[http sse].include?(@transport)
76
81
  end
77
82
 
83
+ def command_argv
84
+ return [] unless stdio?
85
+
86
+ [@command, *@args]
87
+ end
88
+
78
89
  # Check if the MCP server is reachable based on its transport type.
79
90
  #
80
91
  # For stdio servers, checks that a command is present.
@@ -101,6 +112,7 @@ module AgentHarness
101
112
  h[:args] = @args unless @args.empty?
102
113
  else
103
114
  h[:url] = @url
115
+ h[:headers] = @headers unless @headers.empty?
104
116
  end
105
117
  h[:env] = @env unless @env.empty?
106
118
  h
@@ -118,6 +130,7 @@ module AgentHarness
118
130
 
119
131
  validate_args!
120
132
  validate_env!
133
+ validate_headers!
121
134
  validate_stdio! if stdio?
122
135
  validate_http! if http?
123
136
  validate_no_stdio_only_fields_on_http! if http?
@@ -137,15 +150,17 @@ module AgentHarness
137
150
  "MCP server '#{@name}' env must be a Hash with String keys and values"
138
151
  end
139
152
 
140
- def validate_stdio!
141
- if @command.nil? || !@command.is_a?(Array) || @command.empty?
142
- raise McpConfigurationError,
143
- "MCP server '#{@name}' with stdio transport requires a non-empty command array"
144
- end
153
+ def validate_headers!
154
+ return if @headers.is_a?(Hash) && @headers.keys.all? { |k| k.is_a?(String) } && @headers.values.all? { |v| v.is_a?(String) }
145
155
 
146
- unless @command.all? { |c| c.is_a?(String) }
156
+ raise McpConfigurationError,
157
+ "MCP server '#{@name}' headers must be a Hash with String keys and values"
158
+ end
159
+
160
+ def validate_stdio!
161
+ if @command.nil? || !@command.is_a?(String) || @command.strip.empty?
147
162
  raise McpConfigurationError,
148
- "MCP server '#{@name}' command must contain only strings"
163
+ "MCP server '#{@name}' with stdio transport requires a non-empty command string"
149
164
  end
150
165
 
151
166
  return if @url.nil?
@@ -173,6 +188,17 @@ module AgentHarness
173
188
  "MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
174
189
  end
175
190
 
191
+ def normalize_command_and_args(command, args)
192
+ normalized_args = args || []
193
+ return [command, normalized_args] unless command.is_a?(Array)
194
+
195
+ unless normalized_args.is_a?(Array)
196
+ return [command.first, normalized_args]
197
+ end
198
+
199
+ [command.first, command[1..] + normalized_args]
200
+ end
201
+
176
202
  def http_ping_ok?(timeout: 5)
177
203
  require "net/http"
178
204
  uri = URI.parse(@url)
@@ -15,6 +15,7 @@ module AgentHarness
15
15
  # response = provider.send_message(prompt: "Hello!")
16
16
  class Anthropic < Base
17
17
  include RateLimitResetParsing
18
+ include McpConfigFileSupport
18
19
 
19
20
  # Model name pattern for Anthropic Claude models
20
21
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
@@ -756,85 +757,8 @@ module AgentHarness
756
757
  servers
757
758
  end
758
759
 
759
- def write_mcp_config_file(mcp_servers, working_dir: nil)
760
- require "tempfile"
761
- require "tmpdir"
762
- require "securerandom"
763
-
764
- config = build_claude_mcp_config(mcp_servers)
765
- config_json = JSON.generate(config)
766
-
767
- if @executor.is_a?(DockerCommandExecutor)
768
- # When running inside a Docker container, write the config file
769
- # inside the container so the CLI process can read it.
770
- # Track the path so cleanup_mcp_tempfiles! can remove it after execution.
771
- container_path = "/tmp/agent_harness_mcp_#{SecureRandom.hex(8)}.json"
772
- result = @executor.execute(
773
- ["sh", "-c", "cat > #{container_path}"],
774
- stdin_data: config_json,
775
- timeout: 5
776
- )
777
- unless result.success?
778
- raise McpConfigurationError,
779
- "Failed to write MCP config inside container: #{result.stderr}"
780
- end
781
-
782
- @mcp_docker_config_paths ||= []
783
- @mcp_docker_config_paths << container_path
784
-
785
- container_path
786
- else
787
- dir = working_dir || Dir.tmpdir
788
- file = Tempfile.new(["agent_harness_mcp_", ".json"], dir)
789
- file.write(config_json)
790
- file.close
791
-
792
- # Hold a reference so the Tempfile is not garbage-collected (and
793
- # therefore deleted) before the CLI process reads it.
794
- # Cleaned up by cleanup_mcp_tempfiles! after execution.
795
- @mcp_config_tempfiles ||= []
796
- @mcp_config_tempfiles << file
797
-
798
- file.path
799
- end
800
- end
801
-
802
- def build_claude_mcp_config(mcp_servers)
803
- servers = {}
804
- mcp_servers.each do |server|
805
- h = if server.stdio?
806
- entry = {command: server.command.first}
807
- remaining_args = server.command[1..] + server.args
808
- entry[:args] = remaining_args unless remaining_args.empty?
809
- entry
810
- else
811
- {url: server.url}
812
- end
813
- h[:env] = server.env unless server.env.empty?
814
- servers[server.name] = h
815
- end
816
- {mcpServers: servers}
817
- end
818
-
819
- def cleanup_mcp_tempfiles!
820
- if @mcp_config_tempfiles
821
- @mcp_config_tempfiles.each do |file|
822
- file.close unless file.closed?
823
- file.unlink
824
- rescue
825
- nil
826
- end
827
- @mcp_config_tempfiles = nil
828
- end
829
-
830
- if @mcp_docker_config_paths
831
- @mcp_docker_config_paths.each do |path|
832
- @executor.execute(["rm", "-f", path], timeout: 5)
833
- rescue
834
- nil
835
- end
836
- @mcp_docker_config_paths = nil
837
- end
760
+ def mcp_provider_key
761
+ :anthropic
838
762
  end
839
763
 
840
764
  def build_tool_control_flags(tools_option, skip_permission_mode: false)
@@ -25,6 +25,7 @@ module AgentHarness
25
25
  # end
26
26
  class Base
27
27
  include Adapter
28
+ include Extensions::DeepDupable
28
29
 
29
30
  DEFAULT_SMOKE_TEST_CONTRACT = {
30
31
  prompt: "Reply with exactly OK.",
@@ -76,9 +77,11 @@ module AgentHarness
76
77
  # @param config [ProviderConfig, nil] provider configuration
77
78
  # @param executor [CommandExecutor, nil] command executor
78
79
  # @param logger [Logger, nil] logger instance
79
- def initialize(config: nil, executor: nil, logger: nil)
80
+ # @param configuration [Configuration, nil] parent configuration for extension/sub-agent resolution
81
+ def initialize(config: nil, executor: nil, logger: nil, configuration: nil)
80
82
  @config = config || ProviderConfig.new(self.class.provider_name)
81
- @executor = executor || AgentHarness.configuration.command_executor
83
+ @configuration = configuration || AgentHarness.configuration
84
+ @executor = executor || @configuration.command_executor
82
85
  @logger = logger || AgentHarness.logger
83
86
  end
84
87
 
@@ -126,6 +129,15 @@ module AgentHarness
126
129
 
127
130
  # Coerce provider_runtime from Hash if needed
128
131
  options = normalize_provider_runtime(options)
132
+
133
+ # Capture execution options (callbacks, observer) before extensions
134
+ # processing deep-dups the options hash, which would replace identity-
135
+ # sensitive references (observers, procs) with clones.
136
+ exec_opts = command_execution_options(options)
137
+
138
+ extension_context = apply_extensions_to_prompt(prompt, options)
139
+ prompt = extension_context.prompt
140
+ options = extension_context.options
129
141
  options = normalize_sub_agent(options)
130
142
  prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
131
143
 
@@ -147,7 +159,7 @@ module AgentHarness
147
159
  timeout: timeout,
148
160
  env: build_env(options),
149
161
  preparation: preparation,
150
- **command_execution_options(options)
162
+ **exec_opts
151
163
  )
152
164
  duration = Time.now - start_time
153
165
 
@@ -171,13 +183,15 @@ module AgentHarness
171
183
  )
172
184
  end
173
185
 
186
+ response = apply_extensions_after_response(extension_context, response)
187
+
174
188
  # Track tokens
175
189
  track_tokens(response) if response.tokens
176
190
 
177
191
  log_debug("send_message_complete", duration: duration, tokens: response.tokens)
178
192
 
179
193
  response
180
- rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
194
+ rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
181
195
  raise
182
196
  rescue => e
183
197
  handle_error(e, prompt: prompt, options: options)
@@ -220,7 +234,12 @@ module AgentHarness
220
234
 
221
235
  transport = resolve_chat_transport(options)
222
236
  messages = format_messages_for_transport(conversation, transport)
237
+ extension_context = apply_extensions_to_chat(messages, tools, options)
238
+ messages = extension_context.messages
239
+ tools = extension_context.tools
240
+ options = extension_context.options
223
241
  messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
242
+ validate_chat_mcp_servers!(options[:mcp_servers])
224
243
  transport_opts = chat_transport_options(runtime, options)
225
244
  transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
226
245
  transport_opts[:observer] = observer if observer
@@ -233,11 +252,13 @@ module AgentHarness
233
252
  &on_chunk
234
253
  )
235
254
 
255
+ response = apply_extensions_after_response(extension_context, response)
256
+
236
257
  track_tokens(response) if response.tokens
237
258
  log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
238
259
 
239
260
  response
240
- rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
261
+ rescue ExtensionCompatibilityError, ProviderError, AuthenticationError, RateLimitError, TimeoutError
241
262
  raise
242
263
  rescue => e
243
264
  last_msg = conversation&.last || messages&.last
@@ -419,7 +440,13 @@ module AgentHarness
419
440
  end
420
441
 
421
442
  def normalize_mcp_servers(options)
422
- servers = options[:mcp_servers]
443
+ if options.key?(:mcp_servers)
444
+ servers = options[:mcp_servers]
445
+ else
446
+ # Configuration stores mcp_servers as a Hash keyed by name; extract values.
447
+ config_servers = @configuration.mcp_servers
448
+ servers = config_servers.is_a?(Hash) ? config_servers.values : config_servers
449
+ end
423
450
  return options if servers.nil?
424
451
 
425
452
  unless servers.is_a?(Array)
@@ -454,12 +481,12 @@ module AgentHarness
454
481
  sub_agent = options[:sub_agent]
455
482
  return options unless sub_agent
456
483
 
457
- resolved = AgentHarness.configuration.resolve_sub_agent(sub_agent)
484
+ resolved = @configuration.resolve_sub_agent(sub_agent)
458
485
  translated = SubAgentTranslator.for_provider(
459
486
  self.class.provider_name,
460
487
  resolved,
461
- tool_registry: AgentHarness.configuration.tool_registry,
462
- mcp_servers: AgentHarness.configuration.mcp_servers
488
+ tool_registry: @configuration.tool_registry,
489
+ mcp_servers: @configuration.mcp_servers
463
490
  )
464
491
 
465
492
  options.merge(sub_agent: resolved, translated_sub_agent: translated)
@@ -477,6 +504,182 @@ module AgentHarness
477
504
  [{role: "system", content: translated_sub_agent[:runtime_instructions]}] + messages
478
505
  end
479
506
 
507
+ def resolve_extensions(options)
508
+ Array(options[:extensions]).filter_map do |reference|
509
+ @configuration.resolve_extension(reference)
510
+ end
511
+ end
512
+
513
+ def apply_extensions_to_prompt(prompt, options)
514
+ extensions = resolve_extensions(options)
515
+ strict = options.fetch(:extensions_strict, true)
516
+ Extensions::Composition.compose(extensions) if extensions.size > 1
517
+ context = Extensions::MessageContext.new(
518
+ provider: self,
519
+ extensions: extensions,
520
+ mode: :message,
521
+ prompt: prompt,
522
+ options: deep_dup(options),
523
+ metadata: {}
524
+ )
525
+
526
+ validate_extensions!(extensions, strict: strict)
527
+ reject_tool_extensions_in_message_mode!(extensions)
528
+ reject_mcp_extensions_in_message_mode!(extensions)
529
+ apply_extension_system_prompt!(context)
530
+ extensions.each { |extension| extension.on_message_before(context) }
531
+ context
532
+ end
533
+
534
+ def apply_extensions_to_chat(messages, tools, options)
535
+ extensions = resolve_extensions(options)
536
+ strict = options.fetch(:extensions_strict, true)
537
+ Extensions::Composition.compose(extensions) if extensions.size > 1
538
+ context = Extensions::MessageContext.new(
539
+ provider: self,
540
+ extensions: extensions,
541
+ mode: :chat,
542
+ messages: deep_dup(messages),
543
+ tools: merge_extension_tools(tools, extensions),
544
+ options: deep_dup(options),
545
+ metadata: {}
546
+ )
547
+
548
+ validate_extensions!(extensions, strict: strict)
549
+ merge_extension_mcp_servers!(context)
550
+ apply_extension_system_messages!(context)
551
+ extensions.each { |extension| extension.on_message_before(context) }
552
+ extensions.each { |extension| extension.on_tools_available(context) } if context.tools&.any?
553
+ context
554
+ end
555
+
556
+ def apply_extensions_after_response(context, response)
557
+ return response unless context
558
+
559
+ context.response = response
560
+ context.extensions.each { |extension| extension.on_message_after(context) }
561
+ context.response
562
+ end
563
+
564
+ def validate_extensions!(extensions, strict: true)
565
+ extensions.each do |extension|
566
+ report = Extensions::Compatibility.check!(provider: self, extension: extension, strict: strict)
567
+ next if report.fully_supported?
568
+
569
+ @logger&.warn(
570
+ "[AgentHarness::#{self.class.provider_name}] Extension '#{extension.name}' has " \
571
+ "unsupported features that will be unavailable: #{report.unsupported_features.inspect}"
572
+ )
573
+ end
574
+ end
575
+
576
+ def validate_chat_mcp_servers!(mcp_servers)
577
+ return if mcp_servers.nil? || mcp_servers.empty?
578
+
579
+ # Chat transports do not support request-scoped MCP servers.
580
+ # Raise early so extensions with MCP requirements are not silently ignored.
581
+ raise McpUnsupportedError.new(
582
+ "Chat mode does not support request-scoped MCP servers. " \
583
+ "Extensions or options requiring MCP servers cannot be used with send_chat_message.",
584
+ provider: self.class.provider_name
585
+ )
586
+ end
587
+
588
+ def reject_tool_extensions_in_message_mode!(extensions)
589
+ tool_extensions = extensions.select { |ext| ext.tools.any? }
590
+ return if tool_extensions.empty?
591
+
592
+ names = tool_extensions.map(&:name).join(", ")
593
+ raise ExtensionCompatibilityError.new(
594
+ "Extensions with tools are not supported in message mode (CLI execution " \
595
+ "cannot accept dynamic tool definitions): #{names}",
596
+ provider: self.class.provider_name
597
+ )
598
+ end
599
+
600
+ def reject_mcp_extensions_in_message_mode!(extensions)
601
+ mcp_extensions = extensions.select { |ext| ext.mcp_servers.any? }
602
+ return if mcp_extensions.empty?
603
+
604
+ names = mcp_extensions.map(&:name).join(", ")
605
+ raise ExtensionCompatibilityError.new(
606
+ "Extensions with MCP servers are not supported in message mode: #{names}",
607
+ provider: self.class.provider_name
608
+ )
609
+ end
610
+
611
+ def merge_extension_mcp_servers!(context)
612
+ extension_servers = context.extensions.flat_map(&:mcp_servers)
613
+ return if extension_servers.empty?
614
+
615
+ merged = Array(context.options[:mcp_servers]) + extension_servers
616
+ context.options = context.options.merge(mcp_servers: merged)
617
+ end
618
+
619
+ def merge_extension_tools(tools, extensions)
620
+ extension_tools = extensions.flat_map(&:tools)
621
+ return tools unless extension_tools.any?
622
+
623
+ normalized_extension_tools = extension_tools.map { |t| normalize_extension_tool_for_provider(t) }
624
+ merged = Array(tools) + normalized_extension_tools
625
+ names = merged.map { |t| extract_tool_name(t) }.compact
626
+ duplicates = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
627
+ unless duplicates.empty?
628
+ raise ConfigurationError,
629
+ "Tool name conflict between user-provided and extension tools: #{duplicates.join(", ")}"
630
+ end
631
+ merged
632
+ end
633
+
634
+ def extract_tool_name(tool)
635
+ tool[:name] || tool["name"] ||
636
+ tool.dig(:function, :name) || tool.dig(:function, "name") ||
637
+ tool.dig("function", "name") || tool.dig("function", :name)
638
+ end
639
+
640
+ def normalize_extension_tool_for_provider(tool)
641
+ case chat_transport_type
642
+ when :openai_compatible
643
+ # OpenAI-style: {type: "function", function: {name: ..., description: ...}}
644
+ return tool if tool[:type] == "function" || tool["type"] == "function"
645
+
646
+ func = {name: tool[:name] || tool["name"]}
647
+ description = tool[:description] || tool["description"]
648
+ func[:description] = description if description
649
+ parameters = tool[:parameters] || tool["parameters"]
650
+ func[:parameters] = parameters if parameters
651
+ {type: "function", function: func}
652
+ else
653
+ # Anthropic-style: {name: ..., description: ..., input_schema: ...}
654
+ # Convert `parameters` key to `input_schema` for Anthropic/TextTransport compatibility.
655
+ normalized = tool.dup
656
+ params = normalized.delete(:parameters) || normalized.delete("parameters")
657
+ if params && !normalized.key?(:input_schema) && !normalized.key?("input_schema")
658
+ normalized[:input_schema] = params
659
+ end
660
+ normalized
661
+ end
662
+ end
663
+
664
+ def apply_extension_system_prompt!(context)
665
+ additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
666
+ addition.nil? || addition.empty?
667
+ end
668
+ return if additions.empty?
669
+
670
+ context.prompt = [additions.join("\n\n"), context.prompt].join("\n\n")
671
+ end
672
+
673
+ def apply_extension_system_messages!(context)
674
+ additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
675
+ addition.nil? || addition.empty?
676
+ end
677
+ return if additions.empty?
678
+
679
+ system_messages = additions.map { |addition| {role: "system", content: addition} }
680
+ context.messages = system_messages + context.messages
681
+ end
682
+
480
683
  def command_execution_options(options)
481
684
  execution_options = {
482
685
  idle_timeout: options[:idle_timeout],
@@ -9,6 +9,7 @@ module AgentHarness
9
9
  # Provides integration with the OpenAI Codex CLI tool.
10
10
  class Codex < Base
11
11
  include RateLimitResetParsing
12
+ include McpConfigFileSupport
12
13
 
13
14
  SUPPORTED_CLI_VERSION = "0.116.0"
14
15
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
@@ -186,7 +187,7 @@ module AgentHarness
186
187
  vision: false,
187
188
  tool_use: true,
188
189
  json_mode: false,
189
- mcp: false,
190
+ mcp: true,
190
191
  dangerous_mode: true
191
192
  }
192
193
  end
@@ -199,6 +200,27 @@ module AgentHarness
199
200
 
200
201
  def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
201
202
 
203
+ def send_message(prompt:, **options)
204
+ super
205
+ ensure
206
+ cleanup_mcp_tempfiles!
207
+ end
208
+
209
+ def supports_mcp?
210
+ true
211
+ end
212
+
213
+ def supported_mcp_transports
214
+ %w[stdio http sse]
215
+ end
216
+
217
+ def build_mcp_flags(mcp_servers, working_dir: nil)
218
+ return [] if mcp_servers.empty?
219
+
220
+ config_path = write_mcp_config_file(mcp_servers, working_dir: working_dir)
221
+ ["--mcp-config", config_path]
222
+ end
223
+
202
224
  def test_command_overrides
203
225
  ["--skip-git-repo-check", "--output-last-message"]
204
226
  end
@@ -449,6 +471,11 @@ module AgentHarness
449
471
  cmd += sandbox_bypass_flags
450
472
  end
451
473
 
474
+ # Add MCP server flags (validated/normalized by Base#send_message)
475
+ if options[:mcp_servers]&.any?
476
+ cmd += build_mcp_flags(options[:mcp_servers])
477
+ end
478
+
452
479
  if options[:session]
453
480
  cmd += session_flags(options[:session])
454
481
  end
@@ -1259,6 +1286,10 @@ module AgentHarness
1259
1286
  config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
1260
1287
  File.join(config_dir, "config.json")
1261
1288
  end
1289
+
1290
+ def mcp_provider_key
1291
+ :codex
1292
+ end
1262
1293
  end
1263
1294
  end
1264
1295
  end