use anyhow::Result;
use chrono::{DateTime, Duration, Local, Utc};
use colored::Colorize;
use serde_json::json;
use pidge_client::ClientError;
use pidge_client::mcp::{McpRpc, McpTokenStore, McpTokens, StoredServer};
use pidge_core::TokenStorage;
use crate::commands::mcp::McpUsageError;
use crate::commands::mcp_session::{
McpCalls, RefreshingRpc, SessionLookup, backend_name, lookup_session, preferred_backend_for,
};
pub async fn run(
url: Option<String>,
store: Option<TokenStorage>,
json_output: bool,
) -> Result<()> {
let (server_url, preferred) = match url {
Some(u) => {
let preferred = match store {
Some(s) => s,
None => preferred_backend_for(&u, &McpTokenStore::list()?),
};
(u, preferred)
}
None => {
let servers = McpTokenStore::list()?;
let server_url = match resolve_default_server(&servers) {
ServerResolution::Only(s) => s.server,
ServerResolution::None => return Err(McpUsageError::NoServerConnected.into()),
ServerResolution::Many(list) => {
return Err(ambiguous_server_error(&list, "pidge mcp status"));
}
};
let preferred = store.unwrap_or_else(|| preferred_backend_for(&server_url, &servers));
(server_url, preferred)
}
};
let http = reqwest::Client::new();
let lookup = lookup_session(&http, &server_url, preferred).await?;
let (tokens, backend) = session_or_error(lookup, &server_url)
.map_err(|e| crate::commands::mcp::remap_session_expired(&server_url, e))?;
let raw = McpRpc::new(
http.clone(),
tokens.server.clone(),
tokens.access_token.clone(),
);
let mut rpc = RefreshingRpc::new(http, raw, tokens.clone(), backend);
let list_text = accounts_list(&mut rpc)
.await
.map_err(|e| crate::commands::mcp::remap_session_expired(&tokens.server, e))?;
if json_output {
println!(
"{}",
json!({
"server": tokens.server,
"storage": backend,
"expires_at": tokens.expires_at,
"accounts": list_text,
})
);
} else {
println!("Server: {}", tokens.server.cyan());
println!("Storage: {}", backend_name(backend));
println!(
"Token expires: {} ({})",
format_relative(Utc::now(), tokens.expires_at),
tokens
.expires_at
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M %Z")
);
println!();
println!("{list_text}");
}
Ok(())
}
async fn accounts_list(rpc: &mut RefreshingRpc) -> Result<String> {
rpc.initialize().await?;
Ok(rpc.call_tool("accounts_list", json!({})).await?.text)
}
fn session_or_error(lookup: SessionLookup, server_url: &str) -> Result<(McpTokens, TokenStorage)> {
match lookup {
SessionLookup::Found(tokens, backend) => Ok((tokens, backend)),
SessionLookup::Expired => Err(ClientError::SessionExpired {
email: server_url.to_string(),
}
.into()),
SessionLookup::Absent => Err(McpUsageError::NotConnected {
server: server_url.to_string(),
}
.into()),
}
}
enum ServerResolution {
Only(StoredServer),
None,
Many(Vec<StoredServer>),
}
fn resolve_default_server(servers: &[StoredServer]) -> ServerResolution {
match servers {
[] => ServerResolution::None,
[only] => ServerResolution::Only(only.clone()),
many => ServerResolution::Many(many.to_vec()),
}
}
fn ambiguous_server_error(list: &[StoredServer], command: &str) -> anyhow::Error {
let servers = list
.iter()
.map(|s| s.server.as_str())
.collect::<Vec<_>>()
.join(", ");
let example = format!("{command} {}", list[0].server);
McpUsageError::AmbiguousServer { servers, example }.into()
}
fn format_relative(now: DateTime<Utc>, target: DateTime<Utc>) -> String {
let delta = target - now;
if delta >= Duration::zero() {
format!("in {}", humanize(delta))
} else {
format!("{} ago", humanize(-delta))
}
}
fn humanize(d: Duration) -> String {
if d.num_days() >= 1 {
format!("{}d", d.num_days())
} else if d.num_hours() >= 1 {
format!("{}h", d.num_hours())
} else if d.num_minutes() >= 1 {
format!("{}m", d.num_minutes())
} else {
format!("{}s", d.num_seconds().max(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn server(url: &str, storage: TokenStorage) -> StoredServer {
StoredServer {
server: url.to_string(),
storage,
}
}
fn fake_tokens(server: &str) -> McpTokens {
McpTokens {
server: server.to_string(),
access_token: "AT".into(),
refresh_token: "RT".into(),
expires_at: Utc::now() + Duration::seconds(3600),
client_id: "client-jwt".into(),
}
}
#[test]
fn resolve_default_server_reports_none_when_nothing_is_stored() {
assert!(matches!(
resolve_default_server(&[]),
ServerResolution::None
));
}
#[test]
fn resolve_default_server_picks_the_only_entry() {
let entry = server("https://mcp.example.com", TokenStorage::Keychain);
match resolve_default_server(std::slice::from_ref(&entry)) {
ServerResolution::Only(s) => assert_eq!(s, entry),
_ => panic!("expected Only, got a different variant"),
}
}
#[test]
fn resolve_default_server_reports_many_without_picking_one() {
let entries = vec![
server("https://a.example.com", TokenStorage::Keychain),
server("https://b.example.com", TokenStorage::File),
];
match resolve_default_server(&entries) {
ServerResolution::Many(list) => assert_eq!(list, entries),
_ => panic!("expected Many"),
}
}
#[test]
fn explicit_url_preference_comes_from_the_index_when_store_is_not_given() {
let servers = vec![server("https://mcp.example.com", TokenStorage::File)];
assert_eq!(
preferred_backend_for("https://mcp.example.com", &servers),
TokenStorage::File
);
}
#[test]
fn explicit_url_preference_defaults_to_keychain_when_unindexed() {
let servers = vec![server("https://other.example.com", TokenStorage::File)];
assert_eq!(
preferred_backend_for("https://mcp.example.com", &servers),
TokenStorage::Keychain
);
}
#[test]
fn ambiguous_server_error_names_the_calling_command_not_a_hardcoded_one() {
let list = vec![
server("https://a.example.com", TokenStorage::Keychain),
server("https://b.example.com", TokenStorage::File),
];
let err = ambiguous_server_error(&list, "pidge mcp logout");
let message = err.to_string();
assert!(
message.contains("pidge mcp logout https://a.example.com"),
"{message}"
);
assert!(message.contains("https://a.example.com"), "{message}");
assert!(message.contains("https://b.example.com"), "{message}");
}
#[test]
fn session_or_error_passes_through_a_found_session() {
let tokens = fake_tokens("https://mcp.example.com");
let (out_tokens, backend) = session_or_error(
SessionLookup::Found(tokens.clone(), TokenStorage::File),
"https://mcp.example.com",
)
.unwrap();
assert_eq!(out_tokens, tokens);
assert_eq!(backend, TokenStorage::File);
}
#[test]
fn session_or_error_maps_expired_to_session_expired_for_the_remap_hint() {
let err = session_or_error(SessionLookup::Expired, "https://mcp.example.com").unwrap_err();
assert!(matches!(
err.downcast_ref::<ClientError>(),
Some(ClientError::SessionExpired { .. })
));
}
#[test]
fn session_or_error_maps_absent_to_not_connected() {
let err = session_or_error(SessionLookup::Absent, "https://mcp.example.com").unwrap_err();
assert!(matches!(
err.downcast_ref::<McpUsageError>(),
Some(McpUsageError::NotConnected { .. })
));
}
#[test]
fn format_relative_reports_future_expiry_as_in_x() {
let now = Utc::now();
let target = now + Duration::minutes(45);
assert_eq!(format_relative(now, target), "in 45m");
}
#[test]
fn format_relative_reports_past_expiry_as_x_ago() {
let now = Utc::now();
let target = now - Duration::hours(3);
assert_eq!(format_relative(now, target), "3h ago");
}
}