mod jobs;
mod render;
use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::Result;
use rmcp::{
ErrorData, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router,
};
use serde::Deserialize;
use crate::profile::{AppConfig, Profile, load_config};
use crate::profile_cache::{THIRD_PARTY_CACHE_FILE, USAGE_CACHE_FILE, load_profile_cache};
use crate::providers::ThirdPartyStats;
use crate::runtime::{Isolation, ProfileRuntime};
use crate::usage::{PlanTier, UsageInfo, UsageWindow, now_epoch_secs, now_ms};
use render::ProfileSnapshot;
const DEFAULT_RUN_TIMEOUT_SECS: u64 = 300;
const MAX_RUN_TIMEOUT_SECS: u64 = 3600;
const DEFAULT_MAX_OUTPUT_TOKENS: &str = "64000";
fn throughput_json(profile: &str, now: i64) -> serde_json::Value {
let rows: Vec<serde_json::Value> = crate::throughput::summary(profile, now)
.into_iter()
.map(|m| {
serde_json::json!({
"model": m.model,
"tok_s": (m.tok_s * 10.0).round() / 10.0,
"samples": m.samples,
"degraded": m.degraded,
"rate_limited_recent": m.rate_limited_recent,
"retry_after_s": m.retry_after_s,
})
})
.collect();
serde_json::Value::Array(rows)
}
fn provider_label(profile: &Profile) -> String {
profile
.provider
.map(|p| p.display_name().to_string())
.unwrap_or_else(|| "anthropic".to_string())
}
fn tier_label(profile: &Profile) -> Option<String> {
if profile.is_third_party() {
return None;
}
let fetched = load_profile_cache::<UsageInfo>(profile.name.as_str(), USAGE_CACHE_FILE)
.and_then(|u| u.plan)
.map(|p| p.tier)
.filter(|t| *t != PlanTier::Unknown);
match fetched {
Some(tier) => tier.short_label(),
None => {
let sub = profile
.credentials
.as_ref()?
.claude_ai_oauth
.as_ref()?
.subscription_type
.as_deref()?;
PlanTier::from_subscription_type(Some(sub)).short_label()
}
}
}
fn load_windows(name: &str) -> (Option<UsageWindow>, Option<UsageWindow>) {
match load_profile_cache::<UsageInfo>(name, USAGE_CACHE_FILE) {
Some(u) => (u.five_hour, u.seven_day),
None => (None, None),
}
}
fn windows_json(name: &str) -> serde_json::Value {
let Some(usage) = load_profile_cache::<UsageInfo>(name, USAGE_CACHE_FILE) else {
return serde_json::Value::Array(Vec::new());
};
let windows: Vec<serde_json::Value> = usage
.windows()
.into_iter()
.map(|(label, w)| {
serde_json::json!({
"label": label,
"utilization_pct": w.utilization,
"resets_at": w.resets_at,
})
})
.collect();
serde_json::Value::Array(windows)
}
fn active_footer(config: &AppConfig) -> String {
let active = config.state.active_profile.as_deref();
let (five_h, seven_d) = match active {
Some(name) => load_windows(name),
None => (None, None),
};
render::live_footer(active, five_h.as_ref(), seven_d.as_ref())
}
fn with_footer(json: serde_json::Value, footer: String) -> Vec<ContentBlock> {
vec![
ContentBlock::text(json.to_string()),
ContentBlock::text(footer),
]
}
#[derive(Clone)]
pub(crate) struct ClauthServer {
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct SwitchArgs {
name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct DelegateArgs {
profile: String,
prompt: String,
model: Option<String>,
cwd: Option<String>,
env: Option<HashMap<String, String>>,
args: Option<Vec<String>>,
timeout_secs: Option<u64>,
isolated: Option<bool>,
background: Option<bool>,
monitor: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct DelegateResultArgs {
job_id: String,
wait_secs: Option<u64>,
}
#[tool_router]
impl ClauthServer {
pub(crate) fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "List all clauth profiles from disk cache (zero quota). Per profile: \
`windows[]` carries the 5h, 7d, and per-model weekly (`7d <model>`) `{label, utilization_pct, \
resets_at}` where `utilization_pct` is the percent of that window already USED (higher = less \
headroom) and `resets_at` is ISO-8601; \
`has_live_session` = a clauth-managed `claude` session currently owns it; `throughput[]` = \
observed per-model `{model, tok_s, samples, degraded, rate_limited_recent, retry_after_s}` from \
past `delegate` calls; \
`third_party` = a cached one-line headline for provider-key profiles (deepseek/zai/…)"
)]
async fn list_profiles(&self) -> Result<CallToolResult, ErrorData> {
let config = load_config().map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let now = now_epoch_secs();
let profiles: Vec<serde_json::Value> = config
.profiles
.iter()
.map(|p| {
let name = p.name.as_str();
let third_party = if p.is_third_party() {
load_profile_cache::<ThirdPartyStats>(name, THIRD_PARTY_CACHE_FILE)
.as_ref()
.map(render::third_party_headline)
} else {
None
};
serde_json::json!({
"name": name,
"active": config.is_active(name),
"provider": provider_label(p),
"base_url": p.base_url,
"tier": tier_label(p),
"has_live_session": crate::runtime::has_live_session(name),
"windows": windows_json(name),
"third_party": third_party,
"throughput": throughput_json(name, now),
})
})
.collect();
let payload = serde_json::json!({ "profiles": profiles });
Ok(CallToolResult::success(vec![ContentBlock::text(
payload.to_string(),
)]))
}
#[tool(
description = "Report which profile owns the credentials this session loaded. `source` \
explains how it resolved: `refresh_match` (a profile's stored token matches the live creds), \
`session_dir` (this session's runtime dir pins the profile), `credential_less_active` (the \
configured active profile, with no creds on disk to match). Appends a live usage footer (% used)"
)]
async fn which(&self) -> Result<CallToolResult, ErrorData> {
let config = load_config().map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let resolved = crate::which::resolve_active(&config);
let throughput = resolved
.as_ref()
.map(|(name, _)| throughput_json(name, now_epoch_secs()))
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
let tier = resolved.as_ref().and_then(|(name, _)| {
config
.profiles
.iter()
.find(|p| p.name.as_str() == name.as_str())
.and_then(tier_label)
});
let payload = serde_json::json!({
"profile": resolved.as_ref().map(|(name, _)| name),
"source": resolved.as_ref().map(|(_, source)| source.as_str()),
"tier": tier,
"throughput": throughput,
});
Ok(CallToolResult::success(with_footer(
payload,
active_footer(&config),
)))
}
#[tool(
description = "Relink the global active profile (`~/.claude` credentials). A `clauth start` session is pinned to its own runtime and unaffected; a session on the global credentials adopts the change on its next token refresh"
)]
async fn switch(
&self,
Parameters(SwitchArgs { name }): Parameters<SwitchArgs>,
) -> Result<CallToolResult, ErrorData> {
let mut config =
load_config().map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let Some(name) = config.canonical_name(&name) else {
let payload =
serde_json::json!({ "ok": false, "reason": format!("profile not found: {name}") });
return Ok(CallToolResult::error(with_footer(
payload,
active_footer(&config),
)));
};
let on_divergence = config.state.default_divergence;
match crate::actions::switch_profile_noninteractive(&mut config, &name, on_divergence) {
Ok((previous, active)) => {
let payload = serde_json::json!({
"ok": true,
"previous": previous,
"active": active,
});
Ok(CallToolResult::success(with_footer(
payload,
active_footer(&config),
)))
}
Err(e) => {
let payload = serde_json::json!({ "ok": false, "reason": e.to_string() });
Ok(CallToolResult::error(with_footer(
payload,
active_footer(&config),
)))
}
}
}
#[tool(
description = "Delegate a headless task to a profile; SPENDS that account's real usage \
window. The depth-1 cap blocks only a nested clauth `delegate` (a delegate cannot delegate again); \
in-delegate subagents run, but under the SAME delegated profile, not other accounts. For a \
one-shot task PREFER `isolated: true` — a clean blind session that skips the operator persona \
(the runtime's `CLAUDE.md`, plugins, hooks, skills) and loads NO MCP servers, so it is cheaper \
(and on an api-key profile, fewer billed tokens). A SHARED delegate (the default) instead inherits \
that persona plus the runtime config-dir's MCP servers; use it only when the task needs repo tools \
/ codebase nav. Scope a shared delegate with `args:[\"--mcp-config\",\"<json|path>\",\"--strict-mcp-config\"]`. \
SEPARATELY, a delegate loads the project `CLAUDE.md` of its `cwd` (defaults to this server's cwd) \
regardless of `isolated`, so set `cwd` to a clean dir for a one-shot to avoid an unrelated \
project's house-style. Optional cwd/env/args/timeout_secs/isolated shape the spawned `claude`. \
Returns the delegate envelope (`result`, \
`is_error`, `total_cost_usd`, token usage) — read `total_cost_usd`/usage to self-throttle; the \
`result` is the delegate's own self-report, so spot-verify it like any subagent. Set \
`background: true` to get a `{job_id}` back at once instead of blocking; the result auto-arrives \
via a hook, or fetch it with `delegate_result({job_id})`. Add `monitor: true` so a \
`delegate_result` poll on the still-running job reports `elapsed_secs` + the target's live `quota`"
)]
async fn delegate(
&self,
Parameters(DelegateArgs {
profile,
prompt,
model,
cwd,
env,
args,
timeout_secs,
isolated,
background,
monitor,
}): Parameters<DelegateArgs>,
) -> Result<CallToolResult, ErrorData> {
let depth: u32 = match std::env::var(MCP_DEPTH_ENV) {
Ok(v) => v.trim().parse().unwrap_or(u32::MAX),
Err(_) => 0,
};
if depth >= 1 {
let payload = serde_json::json!({
"profile": profile,
"is_error": true,
"result": "delegation depth exceeded (max 1)",
});
return Ok(CallToolResult::error(vec![ContentBlock::text(
payload.to_string(),
)]));
}
let timeout = Duration::from_secs(
timeout_secs
.unwrap_or(DEFAULT_RUN_TIMEOUT_SECS)
.clamp(1, MAX_RUN_TIMEOUT_SECS),
);
let isolation = if isolated.unwrap_or(false) {
Isolation::Isolated
} else {
Isolation::Shared
};
if background.unwrap_or(false) {
let started_at = now_ms();
let job_id = jobs::new_job_id(started_at);
jobs::write_running(&job_id, &profile, started_at, monitor.unwrap_or(false)).map_err(
|e| ErrorData::internal_error(format!("failed to record job: {e}"), None),
)?;
let job_id_task = job_id.clone();
let profile_task = profile.clone();
tokio::task::spawn_blocking(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_delegate(DelegateOpts {
profile: &profile_task,
prompt: &prompt,
model: model.as_deref(),
cwd: cwd.as_deref(),
env: env.unwrap_or_default(),
extra_args: args.unwrap_or_default(),
timeout,
isolation,
depth,
})
}));
let envelope = match outcome {
Ok(Ok(v)) => v,
Ok(Err(reason)) => serde_json::json!({
"profile": profile_task,
"is_error": true,
"result": reason,
}),
Err(_) => serde_json::json!({
"profile": profile_task,
"is_error": true,
"result": "delegate task panicked",
}),
};
let _ = jobs::write_done(&job_id_task, &profile_task, started_at, envelope);
});
let payload = serde_json::json!({
"job_id": job_id,
"profile": profile,
"started_at": started_at,
"status": "running",
});
return Ok(CallToolResult::success(vec![ContentBlock::text(
payload.to_string(),
)]));
}
let target = profile.clone();
let outcome = tokio::task::spawn_blocking(move || {
run_delegate(DelegateOpts {
profile: &target,
prompt: &prompt,
model: model.as_deref(),
cwd: cwd.as_deref(),
env: env.unwrap_or_default(),
extra_args: args.unwrap_or_default(),
timeout,
isolation,
depth,
})
})
.await
.map_err(|e| ErrorData::internal_error(format!("delegate task panicked: {e}"), None))?;
let envelope = match outcome {
Ok(v) => v,
Err(reason) => serde_json::json!({
"profile": profile,
"is_error": true,
"result": reason,
}),
};
let (five_h, seven_d) = load_windows(&profile);
let mut footer =
render::live_footer(Some(profile.as_str()), five_h.as_ref(), seven_d.as_ref());
if let Some(note) = throughput_note(&profile, now_epoch_secs()) {
footer.push('\n');
footer.push_str(¬e);
}
let is_error = envelope
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let content = with_footer(envelope, footer);
if is_error {
Ok(CallToolResult::error(content))
} else {
Ok(CallToolResult::success(content))
}
}
#[tool(
description = "Fetch the result of a `delegate` call made with `background: true`, by \
`job_id`. `wait_secs` (0..=60, default 0) long-polls for completion. Returns the delegate \
envelope when done (same shape as a blocking `delegate`, with the live usage footer), \
`{status:\"running\"}` if it hasn't finished, or an error for an unknown `job_id`. Normally the \
result auto-arrives via a hook — use this only when delegate hooks are disabled"
)]
async fn delegate_result(
&self,
Parameters(DelegateResultArgs { job_id, wait_secs }): Parameters<DelegateResultArgs>,
) -> Result<CallToolResult, ErrorData> {
if !jobs::is_safe_job_id(&job_id) {
let payload = serde_json::json!({ "is_error": true, "result": "invalid job_id" });
return Ok(CallToolResult::error(vec![ContentBlock::text(
payload.to_string(),
)]));
}
let wait = wait_secs.unwrap_or(0).min(MAX_RESULT_WAIT_SECS);
let jid = job_id.clone();
let outcome = tokio::task::spawn_blocking(move || wait_for_done(&jid, wait))
.await
.map_err(|e| ErrorData::internal_error(format!("wait task panicked: {e}"), None))?;
match outcome {
WaitOutcome::Unknown => {
let payload = serde_json::json!({ "is_error": true, "result": format!("unknown job_id: {job_id}") });
Ok(CallToolResult::error(vec![ContentBlock::text(
payload.to_string(),
)]))
}
WaitOutcome::Running(record) => {
let elapsed_secs = now_ms().saturating_sub(record.started_at) / 1000;
let mut payload = serde_json::json!({
"job_id": job_id,
"status": "running",
"elapsed_secs": elapsed_secs,
});
if record.monitor {
payload["quota"] = windows_json(&record.profile);
}
Ok(CallToolResult::success(vec![ContentBlock::text(
payload.to_string(),
)]))
}
WaitOutcome::Done(record) => {
jobs::remove(&job_id);
let envelope = record.envelope.unwrap_or_else(|| {
serde_json::json!({
"profile": record.profile,
"is_error": true,
"result": "job finished without an envelope",
})
});
let (five_h, seven_d) = load_windows(&record.profile);
let mut footer = render::live_footer(
Some(record.profile.as_str()),
five_h.as_ref(),
seven_d.as_ref(),
);
if let Some(note) = throughput_note(&record.profile, now_epoch_secs()) {
footer.push('\n');
footer.push_str(¬e);
}
let is_error = envelope
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let content = with_footer(envelope, footer);
if is_error {
Ok(CallToolResult::error(content))
} else {
Ok(CallToolResult::success(content))
}
}
}
}
}
const MCP_DEPTH_ENV: &str = "CLAUTH_MCP_DEPTH";
const RUN_POLL_INTERVAL: Duration = Duration::from_millis(50);
const MAX_RESULT_WAIT_SECS: u64 = 60;
const JOB_POLL_INTERVAL: Duration = Duration::from_millis(200);
const AWAIT_JOB_DEADLINE_SECS: u64 = MAX_RUN_TIMEOUT_SECS + 600;
enum WaitOutcome {
Done(jobs::JobRecord),
Running(jobs::JobRecord),
Unknown,
}
fn wait_for_done(job_id: &str, deadline_secs: u64) -> WaitOutcome {
let start = Instant::now();
let deadline = Duration::from_secs(deadline_secs);
loop {
match jobs::read(job_id) {
Some(r) if r.state == jobs::JobState::Done => return WaitOutcome::Done(r),
Some(r) if start.elapsed() >= deadline => return WaitOutcome::Running(r),
Some(_) => {}
None => return WaitOutcome::Unknown,
}
std::thread::sleep(JOB_POLL_INTERVAL);
}
}
pub(crate) fn await_job() -> ! {
use std::io::Read;
let mut input = String::new();
let _ = std::io::stdin().read_to_string(&mut input);
let job_id = serde_json::from_str::<serde_json::Value>(&input)
.ok()
.as_ref()
.and_then(extract_job_id)
.filter(|id| jobs::is_safe_job_id(id));
let Some(job_id) = job_id else {
std::process::exit(0); };
let start = Instant::now();
let deadline = Duration::from_secs(AWAIT_JOB_DEADLINE_SECS);
loop {
match jobs::read(&job_id) {
Some(r) if r.state == jobs::JobState::Done => {
let envelope = r.envelope.unwrap_or_else(|| {
serde_json::json!({
"profile": r.profile,
"is_error": true,
"result": "job finished without an envelope",
})
});
println!("{envelope}");
std::process::exit(2); }
Some(_) if start.elapsed() >= deadline => {
println!(
"delegate job {job_id} still running; call `delegate_result` to retrieve it"
);
std::process::exit(2);
}
Some(_) => {}
None => std::process::exit(0), }
std::thread::sleep(JOB_POLL_INTERVAL);
}
}
fn extract_job_id(payload: &serde_json::Value) -> Option<String> {
payload
.get("tool_response")
.and_then(find_job_id)
.or_else(|| find_job_id(payload))
}
fn find_job_id(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(s)) = map.get("job_id") {
return Some(s.clone());
}
map.values().find_map(find_job_id)
}
serde_json::Value::Array(arr) => arr.iter().find_map(find_job_id),
serde_json::Value::String(s) => serde_json::from_str::<serde_json::Value>(s)
.ok()
.as_ref()
.and_then(find_job_id),
_ => None,
}
}
struct DelegateOpts<'a> {
profile: &'a str,
prompt: &'a str,
model: Option<&'a str>,
cwd: Option<&'a str>,
env: HashMap<String, String>,
extra_args: Vec<String>,
timeout: Duration,
isolation: Isolation,
depth: u32,
}
const DELEGATE_ENV_STRIP: &[&str] = &[
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
];
fn apply_delegate_env(
command: &mut Command,
caller_env: &HashMap<String, String>,
config_dir: &std::path::Path,
depth: u32,
) {
for key in DELEGATE_ENV_STRIP {
command.env_remove(key);
}
command.envs(caller_env);
if !caller_env.contains_key("CLAUDE_CODE_MAX_OUTPUT_TOKENS") {
command.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS);
}
command
.env("CLAUDE_CONFIG_DIR", config_dir)
.env(MCP_DEPTH_ENV, (depth + 1).to_string());
}
fn run_delegate(opts: DelegateOpts<'_>) -> std::result::Result<serde_json::Value, String> {
let config = load_config().map_err(|e| format!("failed to load config: {e}"))?;
let target = config
.find(opts.profile)
.ok_or_else(|| format!("profile not found: {}", opts.profile))?;
if let Some(dir) = opts.cwd
&& !std::path::Path::new(dir).is_dir()
{
return Err(format!("cwd does not exist or is not a directory: {dir}"));
}
let runtime = ProfileRuntime::acquire(target, opts.isolation)
.map_err(|e| format!("failed to acquire runtime: {e}"))?;
let mut command = Command::new("claude");
apply_delegate_env(&mut command, &opts.env, runtime.config_dir(), opts.depth);
command
.args(["-p", opts.prompt, "--output-format", "json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if opts.isolation == Isolation::Isolated {
command.arg("--strict-mcp-config");
}
if let Some(m) = opts.model {
command.args(["--model", m]);
}
if let Some(dir) = opts.cwd {
command.current_dir(dir);
}
command.args(&opts.extra_args);
let mut child = command
.spawn()
.map_err(|e| format!("failed to spawn claude: {e}"))?;
let stdout_reader = child
.stdout
.take()
.map(|mut h| std::thread::spawn(move || drain_pipe(&mut h)));
let stderr_reader = child
.stderr
.take()
.map(|mut h| std::thread::spawn(move || drain_pipe(&mut h)));
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if start.elapsed() >= opts.timeout {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"delegate timed out after {}s",
opts.timeout.as_secs()
));
}
std::thread::sleep(RUN_POLL_INTERVAL);
}
Err(e) => return Err(format!("failed to wait for claude: {e}")),
}
};
let stdout_bytes = join_reader(stdout_reader);
let stderr_bytes = join_reader(stderr_reader);
let stdout = String::from_utf8_lossy(&stdout_bytes);
let now = now_epoch_secs();
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr_bytes);
if let Some(retry_after) = rate_limit_hint(&format!("{stderr}{stdout}")) {
crate::throughput::record_rate_limit(opts.profile, opts.model, retry_after, now);
}
return Err(format!(
"claude exited with {}: {}",
status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string()),
truncate(stderr.trim(), 2000)
));
}
let envelope = parse_delegate_envelope(stdout.trim())?;
if envelope
.get("is_error")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
if let Some(retry_after) = rate_limit_hint(&envelope.to_string()) {
crate::throughput::record_rate_limit(opts.profile, opts.model, retry_after, now);
}
} else {
record_throughput_from_envelope(opts.profile, opts.model, &envelope, now);
}
Ok(envelope)
}
fn parse_delegate_envelope(stdout: &str) -> std::result::Result<serde_json::Value, String> {
match serde_json::from_str::<serde_json::Value>(stdout) {
Ok(serde_json::Value::Array(items)) => result_event(items).ok_or_else(|| {
format!(
"no result event in claude output: {}",
truncate(stdout, 2000)
)
}),
Ok(other) => Ok(other),
Err(e) => {
let items = stdout
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
.collect();
result_event(items).ok_or_else(|| {
format!(
"failed to parse claude output: {e}: {}",
truncate(stdout, 2000)
)
})
}
}
}
fn result_event(mut items: Vec<serde_json::Value>) -> Option<serde_json::Value> {
match items
.iter()
.rposition(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
{
Some(i) => Some(items.swap_remove(i)),
None => items.pop(),
}
}
fn record_throughput_from_envelope(
profile: &str,
model: Option<&str>,
envelope: &serde_json::Value,
now: i64,
) {
let output_tokens = envelope
.get("usage")
.and_then(|u| u.get("output_tokens"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let duration_ms = envelope
.get("duration_api_ms")
.or_else(|| envelope.get("duration_ms"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
crate::throughput::record_success(profile, model, output_tokens, duration_ms, now);
}
fn rate_limit_hint(text: &str) -> Option<Option<u64>> {
let lower = text.to_lowercase();
let limited = lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("429")
|| lower.contains("overloaded");
if !limited {
return None;
}
let retry_after = lower.find("retry").and_then(|i| {
lower[i..]
.split(|c: char| !c.is_ascii_digit())
.find(|s| !s.is_empty())
.and_then(|s| s.parse::<u64>().ok())
});
Some(retry_after)
}
fn throughput_note(profile: &str, now: i64) -> Option<String> {
let flagged: Vec<String> = crate::throughput::summary(profile, now)
.into_iter()
.filter(|m| m.degraded || m.rate_limited_recent)
.map(|m| {
if m.rate_limited_recent {
match m.retry_after_s {
Some(s) => format!("{} rate-limited (retry ~{s}s)", m.model),
None => format!("{} rate-limited", m.model),
}
} else {
format!("{} slow (~{:.0} tok/s)", m.model, m.tok_s)
}
})
.collect();
(!flagged.is_empty()).then(|| format!("⚠ throughput: {}", flagged.join(", ")))
}
fn drain_pipe<R: std::io::Read>(reader: &mut R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf);
buf
}
fn join_reader(handle: Option<std::thread::JoinHandle<Vec<u8>>>) -> Vec<u8> {
handle.and_then(|h| h.join().ok()).unwrap_or_default()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[tool_handler]
impl ServerHandler for ClauthServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.instructions = Some(build_instructions());
info
}
}
fn build_instructions() -> String {
let Ok(config) = load_config() else {
return "clauth manages multiple Claude Code accounts (\"profiles\"). \
Call `list_profiles` for live usage figures."
.to_string();
};
let snapshots: Vec<ProfileSnapshot> = config
.profiles
.iter()
.map(|p| {
let name = p.name.as_str();
ProfileSnapshot {
name: name.to_string(),
active: config.is_active(name),
provider: provider_label(p),
base_url: p.base_url.clone(),
sub_type: tier_label(p),
}
})
.collect();
render::instructions_block(&snapshots, &crate::which::session_auth())
}
pub(crate) fn serve() -> Result<()> {
crate::runtime::gc_stale_runtimes();
jobs::gc(now_ms());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(run_server())
}
async fn run_server() -> Result<()> {
use rmcp::{ServiceExt, transport::stdio};
let service = ClauthServer::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
#[cfg(test)]
#[path = "../../tests/inline/mcp_run.rs"]
mod tests;
#[cfg(test)]
#[path = "../../tests/inline/mcp_switch_tool.rs"]
mod switch_tool_tests;