use std::collections::BTreeMap;
use std::env;
use std::io;
use std::path::Path;
use std::process::Stdio;
use std::str;
use crate::cli_common::{CliRunnerBase, MAX_OUTPUT_BYTES};
use crate::types::{
ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, McpServerConfig,
McpTransport, RunnerError, StreamChunk, TokenUsage,
};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use tempfile::TempPath;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio_stream::StreamExt;
use tracing::{debug, instrument, warn};
use crate::config::RunnerConfig;
use crate::process::{
feed_stdin, private_config_file, read_stderr_capped, run_cli_command_with_input,
};
use crate::prompt::{extract_system_message, prepare_user_prompt};
use crate::sandbox::{apply_sandbox, build_policy};
use crate::stream::{CappedLines, GuardedStream, MAX_STREAMING_STDERR_BYTES};
fn mcp_servers_to_claude_json(servers: &[McpServerConfig]) -> String {
let entries: serde_json::Map<String, Value> = servers
.iter()
.map(|s| {
let cfg = match &s.transport {
McpTransport::Http { url, headers } => json!({
"type": "http",
"url": url,
"headers": headers.iter().map(|h| (h.name.clone(), Value::String(h.value.clone()))).collect::<serde_json::Map<_, _>>(),
}),
McpTransport::Sse { url, headers } => json!({
"type": "sse",
"url": url,
"headers": headers.iter().map(|h| (h.name.clone(), Value::String(h.value.clone()))).collect::<serde_json::Map<_, _>>(),
}),
McpTransport::Stdio { command, args, env } => json!({
"command": command,
"args": args,
"env": env.iter().map(|e| (e.name.clone(), Value::String(e.value.clone()))).collect::<serde_json::Map<_, _>>(),
}),
};
(s.name.clone(), cfg)
})
.collect();
json!({ "mcpServers": entries }).to_string()
}
fn mcp_config_file(servers: &[McpServerConfig]) -> Result<Option<TempPath>, RunnerError> {
if servers.is_empty() {
return Ok(None);
}
private_config_file("embacle-claude-mcp-", &mcp_servers_to_claude_json(servers)).map(Some)
}
fn allowed_tools_for(servers: &[McpServerConfig]) -> String {
servers
.iter()
.map(|s| format!("mcp__{}", s.name))
.collect::<Vec<_>>()
.join(",")
}
fn gross_usage(u: &ClaudeUsage) -> TokenUsage {
let fresh = u.input.unwrap_or(0);
let cache_read = u.cache_read.unwrap_or(0);
let cache_creation = u.cache_creation.unwrap_or(0);
let output = u.output.unwrap_or(0);
let prompt = fresh
.saturating_add(cache_read)
.saturating_add(cache_creation);
TokenUsage::new(prompt, output, prompt.saturating_add(output))
.with_cache(u.cache_read, u.cache_creation)
}
fn is_quota_refusal(terminal_reason: Option<&str>, message: &str) -> bool {
if terminal_reason.is_some_and(|r| {
r.eq_ignore_ascii_case("rate_limit") || r.eq_ignore_ascii_case("billing_error")
}) {
return true;
}
let lower = message.to_ascii_lowercase();
lower.contains("usage limit reached")
|| lower.contains("rate_limit_error")
|| lower.contains("billing_error")
|| lower.contains("rate limited")
|| (lower.contains("credit balance") && lower.contains("too low"))
}
const DEFAULT_MODEL: &str = "opus";
pub const CREDENTIAL_ENV_KEYS: &[&str] = &[OAUTH_TOKEN_KEY, API_KEY_KEY];
const OAUTH_TOKEN_KEY: &str = "CLAUDE_CODE_OAUTH_TOKEN";
const API_KEY_KEY: &str = "ANTHROPIC_API_KEY";
fn withholds_api_key(explicit: &[(String, String)], host_oauth: Option<&str>) -> bool {
let explicit_oauth = explicit
.iter()
.rev()
.find(|(key, _)| key == OAUTH_TOKEN_KEY)
.map(|(_, value)| value.as_str());
explicit_oauth
.or(host_oauth)
.is_some_and(|token| !token.is_empty())
}
const FALLBACK_MODELS: &[&str] = &["sonnet", "opus", "haiku"];
const ISOLATION_ENV: &[(&str, &str)] = &[
("CLAUDE_CODE_DISABLE_AUTO_MEMORY", "1"),
("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"),
];
const SETTING_SOURCES: &str = "user";
#[derive(Debug, Deserialize)]
struct ClaudeResponse {
result: Option<String>,
#[serde(default)]
is_error: bool,
#[serde(default)]
subtype: Option<String>,
#[serde(default)]
api_error_status: Option<u16>,
#[serde(default)]
terminal_reason: Option<String>,
usage: Option<ClaudeUsage>,
#[serde(rename = "modelUsage", default)]
model_usage: Option<BTreeMap<String, ClaudeModelUsage>>,
}
#[derive(Debug, Deserialize)]
struct ClaudeModelUsage {
#[serde(rename = "inputTokens", default)]
input: Option<u64>,
#[serde(rename = "outputTokens", default)]
output: Option<u64>,
}
impl ClaudeResponse {
fn served_model(&self) -> Option<&str> {
self.model_usage
.as_ref()?
.iter()
.max_by_key(|(id, usage)| {
(
usage
.input
.unwrap_or(0)
.saturating_add(usage.output.unwrap_or(0)),
*id,
)
})
.map(|(id, _)| id.as_str())
}
}
#[derive(Debug, Deserialize)]
struct ClaudeUsage {
#[serde(rename = "input_tokens")]
input: Option<u32>,
#[serde(rename = "output_tokens")]
output: Option<u32>,
#[serde(rename = "cache_read_input_tokens", default)]
cache_read: Option<u32>,
#[serde(rename = "cache_creation_input_tokens", default)]
cache_creation: Option<u32>,
}
pub struct ClaudeCodeRunner {
base: CliRunnerBase,
}
impl ClaudeCodeRunner {
#[must_use]
pub fn new(config: RunnerConfig) -> Self {
Self {
base: CliRunnerBase::new(
config.allowing_env_keys(CREDENTIAL_ENV_KEYS),
DEFAULT_MODEL,
FALLBACK_MODELS,
),
}
}
fn build_command(
&self,
system_prompt: Option<&str>,
output_format: &str,
max_tokens: Option<u32>,
mcp_servers: &[McpServerConfig],
mcp_config: Option<&Path>,
) -> Command {
let mut cmd = Command::new(&self.base.config.binary_path);
cmd.stdin(Stdio::piped());
cmd.args([
"--print",
"--output-format",
output_format,
"--no-session-persistence",
"--setting-sources",
SETTING_SOURCES,
]);
if output_format == "stream-json" {
cmd.arg("--verbose");
}
if let Some(sys) = system_prompt {
cmd.args(["--system-prompt", sys]);
}
cmd.args(["--model", self.sent_model()]);
match mcp_config {
Some(path) if !mcp_servers.is_empty() => {
cmd.arg("--mcp-config").arg(path).args([
"--strict-mcp-config",
"--allowed-tools",
&allowed_tools_for(mcp_servers),
]);
}
_ => {
cmd.arg("--strict-mcp-config");
}
}
for arg in &self.base.config.extra_args {
cmd.arg(arg);
}
if let Ok(policy) = build_policy(&self.base.config) {
apply_sandbox(&mut cmd, &policy);
debug!(
allowed_keys = ?policy.allowed_env_keys,
cwd = %policy.working_directory.display(),
"Sandbox applied to claude command"
);
} else {
warn!("Failed to build sandbox policy, running with inherited env");
cmd.envs(self.base.config.env.iter().map(|(k, v)| (k, v)));
}
let host_oauth = env::var(OAUTH_TOKEN_KEY).ok();
if withholds_api_key(&self.base.config.env, host_oauth.as_deref()) {
cmd.env_remove(API_KEY_KEY);
}
cmd.envs(ISOLATION_ENV.iter().copied());
if let Some(tokens) = max_tokens {
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", tokens.to_string());
}
cmd
}
fn parse_result_document(raw: &[u8]) -> Result<ClaudeResponse, RunnerError> {
let text = str::from_utf8(raw).map_err(|e| {
RunnerError::internal(format!("Claude Code output is not valid UTF-8: {e}"))
})?;
serde_json::from_str(text).map_err(|e| {
RunnerError::internal(format!("Failed to parse Claude Code JSON response: {e}"))
})
}
fn failure_of(parsed: &ClaudeResponse) -> Option<RunnerError> {
if !parsed.is_error {
return None;
}
let reason = parsed
.result
.as_deref()
.unwrap_or("Unknown error from Claude Code");
let message = match (parsed.subtype.as_deref(), parsed.api_error_status) {
(Some(subtype), Some(status)) => {
format!("{reason} (subtype: {subtype}, api_error_status: {status})")
}
(Some(subtype), None) => format!("{reason} (subtype: {subtype})"),
(None, _) => reason.to_owned(),
};
let is_context_overflow = parsed
.terminal_reason
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case("prompt_too_long"))
|| reason.to_ascii_lowercase().contains("prompt is too long");
if is_context_overflow {
return Some(RunnerError::context_length(format!(
"claude-code: context length exceeded — {message}"
)));
}
if is_quota_refusal(parsed.terminal_reason.as_deref(), reason) {
return Some(RunnerError::rate_limit("claude-code", message));
}
Some(RunnerError::external_service("claude-code", message))
}
fn sent_model(&self) -> &str {
self.base
.config
.model
.as_deref()
.unwrap_or_else(|| self.base.default_model())
}
fn response_from(
parsed: ClaudeResponse,
sent_model: &str,
) -> Result<ChatResponse, RunnerError> {
if let Some(failure) = Self::failure_of(&parsed) {
return Err(failure);
}
let model = parsed.served_model().unwrap_or(sent_model).to_owned();
let content = parsed.result.unwrap_or_default();
let usage = parsed.usage.map(|u| gross_usage(&u));
let response = ChatResponse {
content,
model,
usage,
finish_reason: Some("stop".to_owned()),
warnings: None,
tool_calls: None,
};
Ok(response)
}
fn stream_line_to_chunk(line: &str) -> Result<StreamChunk, RunnerError> {
if line.trim().is_empty() {
return Ok(StreamChunk {
delta: String::new(),
is_final: false,
finish_reason: None,
});
}
let value: Value = serde_json::from_str(line)
.map_err(|e| RunnerError::internal(format!("Invalid JSON in claude stream: {e}")))?;
let chunk_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
match chunk_type {
"result" => {
let parsed: ClaudeResponse = serde_json::from_value(value).map_err(|e| {
RunnerError::internal(format!("Invalid result in claude stream: {e}"))
})?;
if let Some(failure) = Self::failure_of(&parsed) {
return Err(failure);
}
Ok(StreamChunk {
delta: String::new(),
is_final: true,
finish_reason: Some("stop".to_owned()),
})
}
"assistant" => {
let text = value
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter(|item| {
item.get("type").and_then(|t| t.as_str()) == Some("text")
})
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
Ok(StreamChunk {
delta: text,
is_final: false,
finish_reason: None,
})
}
_ => Ok(StreamChunk {
delta: String::new(),
is_final: false,
finish_reason: None,
}),
}
}
}
#[async_trait]
impl LlmProvider for ClaudeCodeRunner {
crate::delegate_provider_base!(
"claude-code",
"Claude Code CLI",
LlmCapabilities::STREAMING | LlmCapabilities::TEMPERATURE | LlmCapabilities::MAX_TOKENS
);
#[instrument(skip_all, fields(runner = "claude_code"))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let system = extract_system_message(&request.messages);
let prepared = prepare_user_prompt(&request.messages)?;
let prompt = &prepared.prompt;
let mcp_config = mcp_config_file(&request.mcp_servers)?;
let mut cmd = self.build_command(
system,
"json",
request.max_tokens,
&request.mcp_servers,
mcp_config.as_deref(),
);
debug!(
binary = %self.base.config.binary_path.display(),
model = self.sent_model(),
has_system_prompt = system.is_some(),
prompt_len = prompt.len(),
"Spawning claude CLI"
);
let output = run_cli_command_with_input(
&mut cmd,
Some(prompt.as_bytes()),
self.base.config.timeout,
MAX_OUTPUT_BYTES,
)
.await?;
let parsed = match Self::parse_result_document(&output.stdout) {
Ok(parsed) => parsed,
Err(not_a_document) => {
self.base.check_exit_code(&output, "claude-code")?;
return Err(not_a_document);
}
};
if output.exit_code != 0 && !parsed.is_error {
self.base.check_exit_code(&output, "claude-code")?;
}
Self::response_from(parsed, self.sent_model())
}
#[instrument(skip_all, fields(runner = "claude_code"))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let system = extract_system_message(&request.messages);
let prepared = prepare_user_prompt(&request.messages)?;
let prompt = &prepared.prompt;
let mcp_config = mcp_config_file(&request.mcp_servers)?;
let mut cmd = self.build_command(
system,
"stream-json",
request.max_tokens,
&request.mcp_servers,
mcp_config.as_deref(),
);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
RunnerError::internal(format!("Failed to spawn claude for streaming: {e}"))
})?;
feed_stdin(child.stdin.take(), prompt.as_bytes().to_vec());
let stdout = child.stdout.take().ok_or_else(|| {
RunnerError::internal("Failed to capture claude stdout for streaming")
})?;
let stderr_task = tokio::spawn(read_stderr_capped(
child.stderr.take(),
MAX_STREAMING_STDERR_BYTES,
));
let reader = BufReader::new(stdout);
let lines = CappedLines::new(reader, MAX_OUTPUT_BYTES);
let stream = lines.map(move |line_result: Result<String, io::Error>| {
let line = line_result
.map_err(|e| RunnerError::internal(format!("Error reading claude stream: {e}")))?;
Self::stream_line_to_chunk(&line)
});
Ok(Box::pin(
GuardedStream::new(stream, child, stderr_task, self.base.config.timeout)
.keeping(mcp_config),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ErrorKind, McpHeader};
use std::ffi::OsStr;
use std::path::PathBuf;
fn parse_response(raw: &[u8]) -> Result<ChatResponse, RunnerError> {
ClaudeCodeRunner::response_from(ClaudeCodeRunner::parse_result_document(raw)?, "opus")
}
#[test]
fn test_parse_response_valid_json() {
let json = br#"{"result":"Hello world","is_error":false,"session_id":"abc123","usage":{"input_tokens":10,"output_tokens":5}}"#;
let response = parse_response(json).unwrap();
assert_eq!(response.content, "Hello world");
assert_eq!(response.model, "opus");
let usage = response.usage.unwrap(); assert_eq!(usage.prompt_tokens, 10);
assert_eq!(usage.completion_tokens, 5);
assert_eq!(usage.total_tokens, 15);
}
#[test]
fn the_response_names_the_model_model_usage_reports() {
let json = br#"{"result":"ok","is_error":false,"usage":{"input_tokens":2,"output_tokens":12},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":12,"costUSD":0.0033}}}"#;
let response = parse_response(json).unwrap();
assert_eq!(response.model, "claude-haiku-5-5");
}
#[test]
fn the_busiest_model_in_model_usage_names_the_turn() {
let json = br#"{"result":"ok","is_error":false,"modelUsage":{"claude-haiku-5-5":{"inputTokens":300,"outputTokens":20},"claude-opus-5-5":{"inputTokens":9000,"outputTokens":400}}}"#;
let response = parse_response(json).unwrap();
assert_eq!(response.model, "claude-opus-5-5");
}
#[test]
fn a_null_model_usage_keeps_a_quota_refusal_classified() {
let json = br#"{"result":"Claude usage limit reached","is_error":true,"api_error_status":429,"modelUsage":null}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::RateLimit, "{}", err.message);
}
#[test]
fn test_parse_response_error_flag() {
let json = br#"{"result":"stream closed unexpectedly","is_error":true}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(err.kind.is_transient(), "a broken pipe is worth retrying");
assert!(err.message.contains("stream closed unexpectedly"));
}
#[test]
fn the_prompt_count_absorbs_the_cached_share() {
let json = br#"{"result":"ok","is_error":false,"usage":{
"input_tokens":2,"output_tokens":7,
"cache_read_input_tokens":18483,"cache_creation_input_tokens":19681}}"#;
let response = parse_response(json).unwrap(); let usage = response.usage.expect("usage present");
assert_eq!(
usage.prompt_tokens, 38_166,
"prompt must be gross — 2 + 18483 + 19681 — because that is the convention every \
other runner reports and the one consumers carve the cached share back out of"
);
assert_eq!(
usage.cached_read_tokens,
Some(18_483),
"the read stays separately priced"
);
assert_eq!(usage.cached_write_tokens, Some(19_681), "so does the write");
assert_eq!(
usage.total_tokens, 38_173,
"total is the gross prompt plus output"
);
}
#[test]
fn a_turn_with_no_cache_is_unchanged() {
let json =
br#"{"result":"ok","is_error":false,"usage":{"input_tokens":500,"output_tokens":50}}"#;
let response = parse_response(json).unwrap(); let usage = response.usage.expect("usage present"); assert_eq!(usage.prompt_tokens, 500);
assert_eq!(usage.total_tokens, 550);
assert_eq!(
usage.cached_read_tokens, None,
"absent stays absent, never Some(0)"
);
}
#[test]
fn a_usage_limit_is_a_quota_refusal_not_a_fault() {
let json = br#"{"result":"Claude usage limit reached","is_error":true}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(
!err.kind.is_transient(),
"classifying a quota wall as transient is what made every retry loop hammer it; \
the window resets on its own clock, not on a backoff schedule"
);
}
#[test]
fn the_api_error_envelope_types_are_recognised() {
for body in [
br#"{"result":"{\"type\":\"rate_limit_error\"}","is_error":true}"#.as_slice(),
br#"{"result":"{\"type\":\"billing_error\"}","is_error":true}"#.as_slice(),
] {
let err = parse_response(body).unwrap_err();
assert_eq!(err.kind, ErrorKind::RateLimit, "body: {body:?}");
}
}
#[test]
fn new_lets_the_cli_credentials_through_the_sandbox() {
let runner = ClaudeCodeRunner::new(RunnerConfig::new(PathBuf::from("/bin/true")));
let keys = &runner.base.config.allowed_env_keys;
for key in CREDENTIAL_ENV_KEYS {
assert!(keys.iter().any(|k| k == key), "{key} must reach the child");
}
assert!(keys.iter().any(|k| k == "HOME"), "the defaults stay");
}
#[test]
fn an_oauth_token_withholds_the_api_key() {
let pooled = vec![(OAUTH_TOKEN_KEY.to_owned(), "sk-ant-oat-2".to_owned())];
assert!(withholds_api_key(&pooled, None), "a pool account's token");
assert!(
withholds_api_key(&[], Some("sk-ant-oat-1")),
"the host's token"
);
assert!(
withholds_api_key(&pooled, Some("sk-ant-oat-1")),
"both: still the subscription"
);
}
#[test]
fn the_api_key_is_forwarded_only_without_a_token() {
assert!(!withholds_api_key(&[], None));
let key_only = vec![(API_KEY_KEY.to_owned(), "sk-ant-api".to_owned())];
assert!(!withholds_api_key(&key_only, None));
assert!(!withholds_api_key(&[], Some("")), "an empty token is none");
}
#[test]
fn an_explicit_token_decides_over_the_hosts() {
let blanked = vec![(OAUTH_TOKEN_KEY.to_owned(), String::new())];
assert!(
!withholds_api_key(&blanked, Some("sk-ant-oat-1")),
"the explicit (empty) value is what the child sees"
);
}
#[test]
fn a_pooled_account_never_hands_the_child_the_api_key() {
let runner = ClaudeCodeRunner::new(
RunnerConfig::new(PathBuf::from("/bin/true"))
.with_env(OAUTH_TOKEN_KEY, "sk-ant-oat-2")
.with_env(API_KEY_KEY, "sk-ant-api"),
);
let cmd = runner.build_command(None, "json", None, &[], None);
let envs: Vec<_> = cmd.as_std().get_envs().collect();
let set: Vec<_> = envs
.iter()
.filter(|(_, value)| value.is_some())
.map(|(key, _)| *key)
.collect();
assert!(
!set.contains(&OsStr::new(API_KEY_KEY)),
"the key never reaches the child: {set:?}"
);
assert!(
envs.contains(&(
OsStr::new(OAUTH_TOKEN_KEY),
Some(OsStr::new("sk-ant-oat-2"))
)),
"the account's token reaches the child: {set:?}"
);
}
#[test]
fn the_subtype_and_status_ride_the_message() {
let json = br#"{"is_error":true,"subtype":"error_during_execution","api_error_status":529,"result":"API Error: 529 overloaded"}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::ExternalService);
assert_eq!(
err.message,
"claude-code: API Error: 529 overloaded (subtype: error_during_execution, api_error_status: 529)"
);
}
#[test]
fn a_result_line_that_is_an_error_fails_the_stream() {
let line = r#"{"type":"result","is_error":true,"subtype":"error_during_execution","result":"Claude usage limit reached"}"#;
let err = ClaudeCodeRunner::stream_line_to_chunk(line).unwrap_err();
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(err.message.contains("usage limit reached"));
let ok = ClaudeCodeRunner::stream_line_to_chunk(
r#"{"type":"result","is_error":false,"result":"pong"}"#,
)
.unwrap(); assert!(ok.is_final);
}
#[test]
fn a_low_credit_balance_is_a_quota_refusal() {
assert!(is_quota_refusal(None, "Credit balance is too low"));
assert!(is_quota_refusal(None, "credit balance too low"));
assert!(!is_quota_refusal(None, "your balance looks fine"));
}
#[test]
fn a_structured_terminal_reason_is_enough_on_its_own() {
assert!(is_quota_refusal(Some("rate_limit"), "something opaque"));
assert!(is_quota_refusal(Some("billing_error"), "something opaque"));
assert!(!is_quota_refusal(
Some("turn_setup_failed"),
"something opaque"
));
}
#[test]
fn test_parse_response_prompt_too_long_is_context_length() {
let json = br#"{"result":"Prompt is too long","is_error":true,"terminal_reason":"prompt_too_long"}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::ContextLength);
assert!(!err.kind.is_transient());
assert!(err.message.contains("Prompt is too long"));
}
#[test]
fn test_parse_response_prompt_too_long_via_message_fallback() {
let json = br#"{"result":"Prompt is too long","is_error":true}"#;
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::ContextLength);
}
#[test]
fn test_parse_response_missing_optional_fields() {
let json = br#"{"result":"hi","is_error":false}"#;
let response = parse_response(json).unwrap();
assert_eq!(response.content, "hi");
assert!(response.usage.is_none());
}
#[test]
fn test_parse_response_null_result() {
let json = br#"{"is_error":false}"#;
let response = parse_response(json).unwrap(); assert_eq!(response.content, "");
}
#[test]
fn test_parse_response_invalid_json() {
let json = b"not json at all";
let err = parse_response(json).unwrap_err();
assert_eq!(err.kind, ErrorKind::Internal);
}
fn http_server() -> McpServerConfig {
McpServerConfig {
name: "dravr".to_owned(),
transport: McpTransport::Http {
url: "http://127.0.0.1:8081/mcp".to_owned(),
headers: vec![McpHeader {
name: "Authorization".to_owned(),
value: "Bearer session-token".to_owned(),
}],
},
}
}
#[test]
fn an_http_server_carries_its_headers_as_an_object() {
let cfg: Value =
serde_json::from_str(&mcp_servers_to_claude_json(&[http_server()])).unwrap(); let e = &cfg["mcpServers"]["dravr"];
assert_eq!(e["type"], "http");
assert_eq!(e["url"], "http://127.0.0.1:8081/mcp");
assert_eq!(
e["headers"]["Authorization"], "Bearer session-token",
"the bearer must survive as a map entry — Claude Code reads headers as an object, \
and a turn that loses it reaches an MCP server that will refuse every tool"
);
}
#[test]
fn a_stdio_server_carries_command_args_and_env() {
let s = McpServerConfig {
name: "local".to_owned(),
transport: McpTransport::Stdio {
command: "/usr/local/bin/pierre-mcp".to_owned(),
args: vec!["--stdio".to_owned()],
env: vec![McpHeader {
name: "TOKEN".to_owned(),
value: "abc".to_owned(),
}],
},
};
let cfg: Value = serde_json::from_str(&mcp_servers_to_claude_json(&[s])).unwrap(); let e = &cfg["mcpServers"]["local"];
assert_eq!(e["command"], "/usr/local/bin/pierre-mcp");
assert_eq!(e["args"][0], "--stdio");
assert_eq!(e["env"]["TOKEN"], "abc");
assert!(
e.get("type").is_none(),
"stdio is the untagged shape; a type key here makes Claude Code read it as remote"
);
}
#[test]
fn every_server_reaches_the_config() {
let second = McpServerConfig {
name: "admin".to_owned(),
transport: McpTransport::Sse {
url: "http://127.0.0.1:8081/sse".to_owned(),
headers: vec![],
},
};
let cfg: Value =
serde_json::from_str(&mcp_servers_to_claude_json(&[http_server(), second])).unwrap(); let m = cfg["mcpServers"].as_object().unwrap(); assert_eq!(m.len(), 2, "dropping a server silently removes its tools");
assert_eq!(cfg["mcpServers"]["admin"]["type"], "sse");
}
#[test]
fn the_allowlist_names_each_server_not_each_tool() {
let second = McpServerConfig {
name: "admin".to_owned(),
transport: McpTransport::Sse {
url: "http://127.0.0.1:8081/sse".to_owned(),
headers: vec![],
},
};
assert_eq!(
allowed_tools_for(&[http_server(), second]),
"mcp__dravr,mcp__admin",
"tool names are not known before the session, so the server prefix is the only \
selector that can be built up front"
);
}
}