Skip to content

Commit 4694eb7

Browse files
committed
feat(mcp): add MCP closed-loop automation for AI agents
- Add mcp_instructions, skip_callback_state, mcp_config_path, env_vars fields to CliAgentConfig for MCP integration - Append MCP tool instructions to CLI prompts when MCP is configured, enabling AI agents to read full issue context via work_items.get, comments.list, and write results back via comments.create - Support --mcp-config flag for claude-code executor - Inject per-agent environment variables into subprocess - skip_callback_state suppresses both start and final state updates when AI manages state directly via MCP - Add MCP closed-loop example to config.example.toml - Bump version to 0.3.0 - 18/18 tests pass, clippy zero warnings
1 parent e832fc6 commit 4694eb7

5 files changed

Lines changed: 233 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "openpr-webhook"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2024"
55
authors = ["g1e2x87"]
66
license = "MIT OR Apache-2.0"

config.example.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,39 @@ callback_token = "opr_xxx"
6363
update_state_on_start = "in_progress"
6464
update_state_on_success = "done"
6565
update_state_on_fail = "todo"
66+
67+
# --- MCP closed-loop example (AI reads full issue context via MCP) ---
68+
69+
[[agents]]
70+
id = "ai-fixer"
71+
name = "AI Issue Fixer"
72+
agent_type = "cli"
73+
74+
[agents.cli]
75+
executor = "claude-code"
76+
workdir = "/opt/worker/code/openpr"
77+
timeout_secs = 900
78+
max_output_chars = 12000
79+
prompt_template = "Fix issue {issue_id}: {title}\nContext: {reason}"
80+
81+
# MCP closed-loop: AI updates issue state directly via MCP tools,
82+
# so we skip the callback state update to avoid duplication.
83+
skip_callback_state = true
84+
callback = "mcp"
85+
callback_url = "http://127.0.0.1:8090/mcp/rpc"
86+
callback_token = "opr_xxx"
87+
88+
# Optional: path to MCP config for claude-code (--mcp-config flag).
89+
# If omitted, claude-code uses its global ~/.claude.json MCP config.
90+
# mcp_config_path = "/path/to/mcp-config.json"
91+
92+
# Custom MCP instructions appended to the prompt. If omitted, a sensible
93+
# default is used that instructs the AI to read/write via OpenPR MCP tools.
94+
# mcp_instructions = "Use work_items.get to read issue {issue_id}, then fix it."
95+
96+
# Extra environment variables injected into the executor subprocess.
97+
# Useful for per-agent API URLs, bot tokens, or workspace IDs.
98+
[agents.cli.env_vars]
99+
OPENPR_API_URL = "http://localhost:3000"
100+
OPENPR_BOT_TOKEN = "opr_xxx"
101+
OPENPR_WORKSPACE_ID = "e5166fd1-..."

src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ pub struct CliAgentConfig {
123123
pub update_state_on_start: Option<String>,
124124
pub update_state_on_success: Option<String>,
125125
pub update_state_on_fail: Option<String>,
126+
/// MCP instructions appended to the CLI prompt, enabling AI agents
127+
/// to read full issue context and write results back via MCP tools.
128+
pub mcp_instructions: Option<String>,
129+
/// When true, the callback will not include a state update field,
130+
/// because the AI agent updates issue state directly via MCP.
131+
#[serde(default)]
132+
pub skip_callback_state: bool,
133+
/// Path to an MCP config file passed to claude-code via `--mcp-config`.
134+
pub mcp_config_path: Option<String>,
135+
/// Extra environment variables injected into the executor subprocess.
136+
#[serde(default)]
137+
pub env_vars: std::collections::HashMap<String, String>,
126138
}
127139

128140
#[derive(Deserialize, Clone, Debug)]

src/dispatcher.rs

Lines changed: 183 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1-
use crate::{callback, config::AgentConfig, config::Config};
1+
use crate::{callback, config::AgentConfig, config::CliAgentConfig, config::Config};
22
use serde_json::Value;
33
use std::process::Stdio;
44
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
55

66
const 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+
822
pub 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

267303
pub fn extract_issue_id(payload: &Value) -> Option<String> {
@@ -538,8 +574,10 @@ fn format_message(agent: &AgentConfig, payload: &Value) -> String {
538574

539575
#[cfg(test)]
540576
mod 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

Comments
 (0)