1- use crate :: { callback, config:: AgentConfig , config:: Config } ;
1+ use crate :: { callback, config:: AgentConfig , config:: CliAgentConfig , config :: Config } ;
22use serde_json:: Value ;
33use std:: process:: Stdio ;
44use std:: time:: { Duration , Instant , SystemTime , UNIX_EPOCH } ;
55
66const DEFAULT_SUBPROCESS_TIMEOUT_SECS : u64 = 60 ;
77
8+ #[ allow( clippy:: doc_markdown, clippy:: literal_string_with_formatting_args) ]
9+ const DEFAULT_MCP_INSTRUCTIONS : & str = r#"## MCP Integration
10+
11+ You have OpenPR MCP tools available. Use them to get full issue context before working:
12+
13+ 1. Call `work_items.get` with work_item_id="{issue_id}" to read full issue details (title, description, state, priority, assignee)
14+ 2. Call `comments.list` with work_item_id="{issue_id}" to read all comments and discussion
15+ 3. Call `work_items.list_labels` with work_item_id="{issue_id}" to read labels
16+
17+ After completing your work:
18+
19+ 4. Call `comments.create` with work_item_id="{issue_id}" to post a summary of what you did
20+ 5. Call `work_items.update` with work_item_id="{issue_id}" and state="done" if the fix is complete"# ;
21+
822pub async fn dispatch ( config : & Config , agent : & AgentConfig , payload : & Value ) -> String {
923 match agent. agent_type . as_str ( ) {
1024 "openclaw" => dispatch_openclaw ( config, agent, payload) . await ,
@@ -72,7 +86,11 @@ pub async fn execute_cli_task(
7286 } ) ;
7387 let prompt = build_cli_prompt ( agent, payload, & issue_id) ;
7488
75- let start_state = cfg. update_state_on_start . clone ( ) ;
89+ let start_state = if cfg. skip_callback_state {
90+ None
91+ } else {
92+ cfg. update_state_on_start . clone ( )
93+ } ;
7694 if config. callback_enabled ( ) && start_state. is_some ( ) {
7795 let start_payload = callback:: build_callback_payload (
7896 issue_id. clone ( ) ,
@@ -91,16 +109,13 @@ pub async fn execute_cli_task(
91109 }
92110 }
93111
94- let run = run_cli_executor (
95- & cfg. executor ,
96- cfg. workdir . as_deref ( ) ,
97- & prompt,
98- cfg. timeout_secs ,
99- cfg. max_output_chars ,
100- )
101- . await ;
112+ let run = run_cli_executor ( cfg, & prompt) . await ;
102113
103- let final_state = callback:: state_for_status ( cfg, & run. status ) ;
114+ let final_state = if cfg. skip_callback_state {
115+ None
116+ } else {
117+ callback:: state_for_status ( cfg, & run. status )
118+ } ;
104119 let summary = if run. status == "success" {
105120 "cli execution completed" . to_string ( )
106121 } else {
@@ -143,14 +158,8 @@ struct CliRunResult {
143158 stderr_tail : String ,
144159}
145160
146- async fn run_cli_executor (
147- executor : & str ,
148- workdir : Option < & str > ,
149- prompt : & str ,
150- timeout_secs : u64 ,
151- max_output_chars : usize ,
152- ) -> CliRunResult {
153- let ( program, args) = match build_executor_command ( executor, prompt) {
161+ async fn run_cli_executor ( cfg : & CliAgentConfig , prompt : & str ) -> CliRunResult {
162+ let ( program, args) = match build_executor_command ( & cfg. executor , prompt, cfg. mcp_config_path . as_deref ( ) ) {
154163 Ok ( v) => v,
155164 Err ( err) => {
156165 return CliRunResult {
@@ -168,9 +177,12 @@ async fn run_cli_executor(
168177 . stdout ( Stdio :: piped ( ) )
169178 . stderr ( Stdio :: piped ( ) )
170179 . kill_on_drop ( true ) ;
171- if let Some ( dir) = workdir {
180+ if let Some ( dir) = & cfg . workdir {
172181 cmd. current_dir ( dir) ;
173182 }
183+ for ( key, value) in & cfg. env_vars {
184+ cmd. env ( key, value) ;
185+ }
174186
175187 let started = Instant :: now ( ) ;
176188 let child = match cmd. spawn ( ) {
@@ -186,6 +198,8 @@ async fn run_cli_executor(
186198 }
187199 } ;
188200
201+ let timeout_secs = cfg. timeout_secs ;
202+ let max_output_chars = cfg. max_output_chars ;
189203 let output_result = tokio:: time:: timeout ( Duration :: from_secs ( timeout_secs) , child. wait_with_output ( ) ) . await ;
190204
191205 match output_result {
@@ -221,18 +235,22 @@ async fn run_cli_executor(
221235 }
222236}
223237
224- fn build_executor_command ( executor : & str , prompt : & str ) -> Result < ( & ' static str , Vec < String > ) , String > {
238+ fn build_executor_command (
239+ executor : & str ,
240+ prompt : & str ,
241+ mcp_config_path : Option < & str > ,
242+ ) -> Result < ( & ' static str , Vec < String > ) , String > {
225243 match executor {
226244 "codex" => Ok ( ( "codex" , vec ! [ "exec" . into( ) , "--full-auto" . into( ) , prompt. into( ) ] ) ) ,
227- "claude-code" => Ok ( (
228- "claude" ,
229- vec ! [
230- "--print " . into( ) ,
231- "--permission-mode" . into( ) ,
232- "bypassPermissions" . into ( ) ,
233- prompt. into( ) ,
234- ] ,
235- ) ) ,
245+ "claude-code" => {
246+ let mut args = vec ! [ "--print" . into ( ) , "--permission-mode" . into ( ) , "bypassPermissions" . into ( ) ] ;
247+ if let Some ( mcp_path ) = mcp_config_path {
248+ args . push ( "--mcp-config " . into ( ) ) ;
249+ args . push ( mcp_path . into ( ) ) ;
250+ }
251+ args . push ( prompt. into ( ) ) ;
252+ Ok ( ( "claude" , args ) )
253+ }
236254 "opencode" => Ok ( ( "opencode" , vec ! [ "run" . into( ) , prompt. into( ) ] ) ) ,
237255 _ => Err ( format ! ( "executor not allowed: {executor}" ) ) ,
238256 }
@@ -259,9 +277,27 @@ fn build_cli_prompt(agent: &AgentConfig, payload: &Value, issue_id: &str) -> Str
259277 . and_then ( Value :: as_str)
260278 . unwrap_or ( "unknown" ) ;
261279
262- base. replace ( "{issue_id}" , issue_id)
280+ let user_prompt = base
281+ . replace ( "{issue_id}" , issue_id)
263282 . replace ( "{title}" , title)
264- . replace ( "{reason}" , reason)
283+ . replace ( "{reason}" , reason) ;
284+
285+ // Only append MCP instructions when explicitly configured or when
286+ // mcp_config_path / env_vars indicate MCP integration is active.
287+ let cli = agent. cli . as_ref ( ) ;
288+ let has_mcp_config =
289+ cli. is_some_and ( |c| c. mcp_instructions . is_some ( ) || c. mcp_config_path . is_some ( ) || !c. env_vars . is_empty ( ) ) ;
290+
291+ if !has_mcp_config {
292+ return user_prompt;
293+ }
294+
295+ let mcp_instructions = cli
296+ . and_then ( |c| c. mcp_instructions . as_deref ( ) )
297+ . unwrap_or ( DEFAULT_MCP_INSTRUCTIONS ) ;
298+
299+ let instructions = mcp_instructions. replace ( "{issue_id}" , issue_id) ;
300+ format ! ( "{user_prompt}\n \n {instructions}" )
265301}
266302
267303pub fn extract_issue_id ( payload : & Value ) -> Option < String > {
@@ -538,8 +574,10 @@ fn format_message(agent: &AgentConfig, payload: &Value) -> String {
538574
539575#[ cfg( test) ]
540576mod tests {
541- use super :: { build_executor_command, dispatch, extract_issue_id, outbound_signature_header_value} ;
542- use crate :: config:: Config ;
577+ use super :: {
578+ build_cli_prompt, build_executor_command, dispatch, extract_issue_id, outbound_signature_header_value,
579+ } ;
580+ use crate :: { callback, config:: Config } ;
543581 use serde_json:: json;
544582
545583 fn base_config ( ) -> Config {
@@ -571,10 +609,119 @@ webhook_secrets = []
571609
572610 #[ test]
573611 fn cli_executor_whitelist_builds_expected_command ( ) {
574- let ( _, args) = build_executor_command ( "codex" , "fix it" ) . expect ( "codex should be allowed" ) ;
612+ let ( _, args) = build_executor_command ( "codex" , "fix it" , None ) . expect ( "codex should be allowed" ) ;
575613 assert_eq ! ( args, vec![ "exec" , "--full-auto" , "fix it" ] ) ;
576614
577- assert ! ( build_executor_command( "bash" , "rm -rf /" ) . is_err( ) ) ;
615+ assert ! ( build_executor_command( "bash" , "rm -rf /" , None ) . is_err( ) ) ;
616+ }
617+
618+ #[ test]
619+ fn claude_code_executor_includes_mcp_config_when_set ( ) {
620+ let ( prog, args) =
621+ build_executor_command ( "claude-code" , "fix it" , Some ( "/path/to/mcp.json" ) ) . expect ( "claude-code allowed" ) ;
622+ assert_eq ! ( prog, "claude" ) ;
623+ assert ! ( args. contains( & "--mcp-config" . to_string( ) ) ) ;
624+ assert ! ( args. contains( & "/path/to/mcp.json" . to_string( ) ) ) ;
625+ }
626+
627+ #[ test]
628+ fn claude_code_executor_omits_mcp_config_when_none ( ) {
629+ let ( _, args) = build_executor_command ( "claude-code" , "fix it" , None ) . expect ( "claude-code allowed" ) ;
630+ assert ! ( !args. contains( & "--mcp-config" . to_string( ) ) ) ;
631+ }
632+
633+ #[ test]
634+ fn build_cli_prompt_appends_default_mcp_instructions_when_env_vars_set ( ) {
635+ let agent: crate :: config:: AgentConfig = toml:: from_str (
636+ r#"
637+ id = "a1"
638+ name = "CLI"
639+ agent_type = "cli"
640+ [cli]
641+ executor = "codex"
642+ prompt_template = "Fix issue {issue_id}: {title}"
643+ [cli.env_vars]
644+ OPENPR_API_URL = "http://localhost:3000"
645+ "# ,
646+ )
647+ . expect ( "parse agent" ) ;
648+
649+ let payload =
650+ json ! ( { "data" : { "issue" : { "id" : "42" , "title" : "Login bug" } } , "bot_context" : { "trigger_reason" : "assigned" } } ) ;
651+ let prompt = build_cli_prompt ( & agent, & payload, "42" ) ;
652+
653+ assert ! ( prompt. starts_with( "Fix issue 42: Login bug" ) ) ;
654+ assert ! ( prompt. contains( "work_items.get" ) ) ;
655+ assert ! ( prompt. contains( "comments.list" ) ) ;
656+ assert ! ( prompt. contains( "comments.create" ) ) ;
657+ }
658+
659+ #[ test]
660+ fn build_cli_prompt_omits_mcp_instructions_when_no_mcp_config ( ) {
661+ let agent: crate :: config:: AgentConfig = toml:: from_str (
662+ r#"
663+ id = "a1"
664+ name = "CLI"
665+ agent_type = "cli"
666+ [cli]
667+ executor = "codex"
668+ prompt_template = "Fix issue {issue_id}: {title}"
669+ "# ,
670+ )
671+ . expect ( "parse agent" ) ;
672+
673+ let payload =
674+ json ! ( { "data" : { "issue" : { "id" : "42" , "title" : "Login bug" } } , "bot_context" : { "trigger_reason" : "assigned" } } ) ;
675+ let prompt = build_cli_prompt ( & agent, & payload, "42" ) ;
676+
677+ assert ! ( prompt. starts_with( "Fix issue 42: Login bug" ) ) ;
678+ assert ! (
679+ !prompt. contains( "work_items.get" ) ,
680+ "should not contain MCP instructions"
681+ ) ;
682+ }
683+
684+ #[ test]
685+ fn build_cli_prompt_uses_custom_mcp_instructions ( ) {
686+ let agent: crate :: config:: AgentConfig = toml:: from_str (
687+ r#"
688+ id = "a1"
689+ name = "CLI"
690+ agent_type = "cli"
691+ [cli]
692+ executor = "codex"
693+ prompt_template = "Fix {issue_id}"
694+ mcp_instructions = "Custom: read issue {issue_id} first"
695+ "# ,
696+ )
697+ . expect ( "parse agent" ) ;
698+
699+ let payload = json ! ( { "data" : { "issue" : { "id" : "99" } } } ) ;
700+ let prompt = build_cli_prompt ( & agent, & payload, "99" ) ;
701+
702+ assert ! ( prompt. contains( "Custom: read issue 99 first" ) ) ;
703+ assert ! ( !prompt. contains( "work_items.get" ) ) ;
704+ }
705+
706+ #[ test]
707+ fn skip_callback_state_returns_none ( ) {
708+ let cfg: crate :: config:: CliAgentConfig = toml:: from_str (
709+ r#"
710+ executor = "codex"
711+ skip_callback_state = true
712+ update_state_on_success = "done"
713+ "# ,
714+ )
715+ . expect ( "parse cli config" ) ;
716+
717+ assert ! ( cfg. skip_callback_state) ;
718+ // When skip_callback_state is true, state should be None regardless of status
719+ let state = if cfg. skip_callback_state {
720+ None
721+ } else {
722+ callback:: state_for_status ( & cfg, "success" )
723+ } ;
724+ assert ! ( state. is_none( ) ) ;
578725 }
579726
580727 #[ test]
0 commit comments