pub mod oauth;
pub mod rpc;
pub mod store;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
pub use oauth::{Discovery, discover, refresh, register, sign_in, valid_access_token};
pub use rpc::{McpRpc, ToolResult};
pub use store::{McpTokenStore, StoredServer};
use crate::error::ClientError;
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpTokens {
pub server: String,
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub client_id: String,
}
impl McpTokens {
pub fn needs_refresh(&self) -> bool {
Utc::now() + Duration::seconds(60) >= self.expires_at
}
}
impl std::fmt::Debug for McpTokens {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpTokens")
.field("server", &self.server)
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("expires_at", &self.expires_at)
.field("client_id", &self.client_id)
.finish()
}
}
pub fn normalize_origin(server_url: &str) -> Result<String, ClientError> {
let url = url::Url::parse(server_url).map_err(|e| ClientError::Graph {
status: 400,
message: format!("invalid MCP server URL: {e}"),
})?;
if url.host_str().is_none() {
return Err(ClientError::Graph {
status: 400,
message: "MCP server URL has no host".to_string(),
});
}
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
return Err(ClientError::Graph {
status: 400,
message: "MCP server URL must use https (http is allowed for localhost only)"
.to_string(),
});
}
Ok(url.origin().ascii_serialization())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_urls_must_be_https_except_on_loopback() {
assert_eq!(
normalize_origin("https://pidge.example.com/mcp").unwrap(),
"https://pidge.example.com"
);
assert_eq!(
normalize_origin("http://localhost:8080/mcp").unwrap(),
"http://localhost:8080"
);
assert!(normalize_origin("http://127.0.0.1:8080/mcp").is_ok());
let err = normalize_origin("http://pidge.example.com/mcp").unwrap_err();
assert!(err.to_string().contains("https"), "{err}");
}
#[test]
fn debug_redacts_both_tokens() {
let tokens = McpTokens {
server: "https://mcp.example.com/mcp".into(),
access_token: "super-secret-access".into(),
refresh_token: "super-secret-refresh".into(),
expires_at: Utc::now(),
client_id: "client-jwt".into(),
};
let debug = format!("{tokens:?}");
assert!(!debug.contains("super-secret-access"));
assert!(!debug.contains("super-secret-refresh"));
assert!(debug.contains("<redacted>"));
assert!(debug.contains("mcp.example.com"));
assert!(debug.contains("client-jwt"));
}
}