use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use claude_code_sdk_rust::mcp::create_sdk_mcp_server;
use claude_code_sdk_rust::{
tool, ClaudeAgentClient, ClaudeAgentOptions, MCPContent, MCPServerConnectionStatus,
PermissionResult, StreamEvent,
};
const TOOL_CAPABLE_MODEL: &str = "claude-haiku-4-5-20251001";
const MAGIC_WORD: &str = "xyzzy-platypus-1729";
const STREAM_TIMEOUT: Duration = Duration::from_secs(120);
fn has_real_claude_auth() -> bool {
std::env::var_os("ANTHROPIC_API_KEY").is_some()
|| std::env::var_os("CLAUDE_CODE_OAUTH_TOKEN").is_some()
|| has_logged_in_claude_cli()
}
fn has_logged_in_claude_cli() -> bool {
let cli_responds = std::process::Command::new("claude")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false);
let has_user_config = std::env::var_os("HOME")
.map(|home| std::path::Path::new(&home).join(".claude.json").exists())
.unwrap_or(false);
cli_responds && has_user_config
}
fn looks_unavailable(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
lowered.contains("currently unavailable")
|| lowered.contains("is unavailable")
|| lowered.contains("does not have access")
|| lowered.contains("model not found")
|| lowered.contains("unknown model")
}
fn options_with_magic_tool(call_count: Arc<AtomicUsize>, allow_tool: bool) -> ClaudeAgentOptions {
let counter = Arc::clone(&call_count);
let magic = tool(
"magic_word",
"Returns the secret magic word. Call with empty arguments.",
serde_json::json!({"type": "object", "properties": {}, "additionalProperties": false}),
move |_input| {
counter.fetch_add(1, Ordering::SeqCst);
Ok(vec![MCPContent::Text {
text: MAGIC_WORD.to_string(),
}])
},
);
let server = create_sdk_mcp_server("roder", vec![magic]);
ClaudeAgentOptions::builder()
.model(TOOL_CAPABLE_MODEL)
.include_partial_messages(true)
.max_turns(3)
.sdk_mcp_server("roder", server)
.tools(Vec::new()) .can_use_tool(move |tool_name, _input, _ctx| async move {
if allow_tool && tool_name == "mcp__roder__magic_word" {
Ok(PermissionResult::allow())
} else {
Ok(PermissionResult::deny(format!("{tool_name} not permitted")))
}
})
.build()
}
struct Captured {
content: String,
tool_starts: Vec<String>,
tool_results: usize,
errors: Vec<String>,
saw_complete: bool,
}
async fn drain_stream(
mut events: tokio::sync::mpsc::UnboundedReceiver<StreamEvent>,
) -> Captured {
let mut cap = Captured {
content: String::new(),
tool_starts: Vec::new(),
tool_results: 0,
errors: Vec::new(),
saw_complete: false,
};
let _ = tokio::time::timeout(STREAM_TIMEOUT, async {
while let Some(event) = events.recv().await {
match event {
StreamEvent::ContentChunk(text) => cap.content.push_str(&text),
StreamEvent::ToolUseStart { name, .. } => cap.tool_starts.push(name),
StreamEvent::ToolResult { .. } => cap.tool_results += 1,
StreamEvent::Complete(_) | StreamEvent::TurnComplete(_) => cap.saw_complete = true,
StreamEvent::Error(message) => cap.errors.push(message),
_ => {}
}
}
})
.await;
cap
}
#[tokio::test]
#[ignore = "requires Claude CLI authentication and may incur API usage"]
async fn real_claude_cli_invokes_sdk_mcp_tool() {
if !has_real_claude_auth() {
eprintln!("skipping: Claude CLI authentication is required");
return;
}
let call_count = Arc::new(AtomicUsize::new(0));
let options = options_with_magic_tool(Arc::clone(&call_count), true);
let events = ClaudeAgentClient::spawn_stream_message(
options,
"Call the mcp__roder__magic_word tool with empty arguments, then reply with \
exactly the word it returned and nothing else.",
);
let cap = drain_stream(events).await;
if cap.errors.iter().any(|e| looks_unavailable(e)) {
eprintln!("skipping: model unavailable: {:?}", cap.errors);
return;
}
assert!(cap.errors.is_empty(), "unexpected stream errors: {:?}", cap.errors);
assert!(cap.saw_complete, "stream ended without a Complete/TurnComplete event");
assert!(
call_count.load(Ordering::SeqCst) >= 1,
"expected the MCP tool handler to run at least once"
);
assert!(
cap.tool_starts.iter().any(|n| n.contains("magic_word")),
"expected a ToolUseStart for magic_word, saw {:?}",
cap.tool_starts
);
if cap.tool_results == 0 {
eprintln!("note: no ToolResult stream events observed for the MCP tool result");
}
assert!(
cap.content.to_ascii_lowercase().contains(MAGIC_WORD),
"expected final content to contain the magic word, got {:?}",
cap.content
);
}
#[tokio::test]
#[ignore = "requires Claude CLI authentication and may incur API usage"]
async fn real_claude_cli_executes_builtin_bash_tool() {
if !has_real_claude_auth() {
eprintln!("skipping: Claude CLI authentication is required");
return;
}
const BASH_SENTINEL: &str = "bash-sentinel-8675309";
let options = ClaudeAgentOptions::builder()
.model(TOOL_CAPABLE_MODEL)
.include_partial_messages(true)
.max_turns(3)
.can_use_tool(|_tool_name, _input, _ctx| async move { Ok(PermissionResult::allow()) })
.build();
let events = ClaudeAgentClient::spawn_stream_message(
options,
format!(
"Use the Bash tool to run exactly: echo {BASH_SENTINEL}. \
Then tell me the command's output."
),
);
let cap = drain_stream(events).await;
if cap.errors.iter().any(|e| looks_unavailable(e)) {
eprintln!("skipping: model unavailable: {:?}", cap.errors);
return;
}
assert!(cap.errors.is_empty(), "unexpected stream errors: {:?}", cap.errors);
assert!(
cap.tool_starts.iter().any(|n| n == "Bash"),
"expected a Bash ToolUseStart event, saw {:?}",
cap.tool_starts
);
assert!(
cap.content.contains(BASH_SENTINEL),
"expected the Bash command output in the reply, got {:?}",
cap.content
);
}
#[tokio::test]
#[ignore = "requires Claude CLI authentication and may incur API usage"]
async fn real_claude_cli_reports_sdk_mcp_server_status() {
if !has_real_claude_auth() {
eprintln!("skipping: Claude CLI authentication is required");
return;
}
let call_count = Arc::new(AtomicUsize::new(0));
let options = options_with_magic_tool(call_count, true);
let mut client = ClaudeAgentClient::new(options).expect("client builds");
if let Err(err) = client.connect().await {
if looks_unavailable(&err.to_string()) {
eprintln!("skipping: model unavailable: {err}");
return;
}
panic!("connect failed: {err}");
}
let status = client
.get_mcp_status()
.await
.expect("get_mcp_status control request should succeed against the live CLI");
for server in &status.mcp_servers {
assert!(!server.name.is_empty(), "server status missing a name");
}
match status.mcp_servers.iter().find(|server| server.name == "roder") {
Some(roder) => {
assert!(
matches!(roder.status, MCPServerConnectionStatus::Connected),
"expected 'roder' to be Connected, got {:?}",
roder.status
);
if let Some(tools) = &roder.tools {
assert!(
tools.iter().any(|t| t.name.contains("magic_word")),
"expected 'roder' to advertise the magic_word tool, got {:?}",
tools.iter().map(|t| &t.name).collect::<Vec<_>>()
);
}
}
None => eprintln!(
"note: in-process 'roder' server not listed in mcp_status; reported servers: {:?}",
status.mcp_servers.iter().map(|s| &s.name).collect::<Vec<_>>()
),
}
let _ = client.close().await;
}
#[tokio::test]
#[ignore = "requires Claude CLI authentication and may incur API usage"]
async fn real_claude_cli_interactive_multi_turn_retains_context() {
if !has_real_claude_auth() {
eprintln!("skipping: Claude CLI authentication is required");
return;
}
let options = ClaudeAgentOptions::builder()
.model(TOOL_CAPABLE_MODEL)
.max_turns(1)
.build();
let mut client = ClaudeAgentClient::new(options).expect("client builds");
if let Err(err) = client.connect().await {
if looks_unavailable(&err.to_string()) {
eprintln!("skipping: model unavailable: {err}");
return;
}
panic!("connect failed: {err}");
}
let first = match client
.send_message("Remember the codeword 'aardvark-42'. Reply with exactly: ok")
.await
{
Ok(response) => response,
Err(err) => {
let _ = client.close().await;
if looks_unavailable(&err.to_string()) {
eprintln!("skipping: model unavailable: {err}");
return;
}
panic!("first turn failed: {err}");
}
};
if looks_unavailable(&first.content) {
let _ = client.close().await;
eprintln!("skipping: model unavailable: {:?}", first.content);
return;
}
let second = client
.send_message("What codeword did I ask you to remember? Reply with only the codeword.")
.await
.expect("second turn should succeed");
assert!(
second.content.to_ascii_lowercase().contains("aardvark-42"),
"expected the model to recall the codeword across turns, got {:?}",
second.content
);
let _ = client.close().await;
}