mcp-confluence 1.0.0

MCP server for Confluence integration - create, update, search, and manage Confluence pages
use base64::Engine;
use reqwest::Client;
use serde::de::DeserializeOwned;

use crate::config::Config;

/// HTTP client for the Confluence REST API.
#[derive(Clone)]
pub struct ConfluenceClient {
    config: Config,
    http: Client,
}

impl ConfluenceClient {
    pub fn new(config: Config) -> Self {
        let http = Client::builder()
            .danger_accept_invalid_certs(true)
            .build()
            .expect("failed to build HTTP client");
        Self { config, http }
    }

    pub fn config(&self) -> &Config {
        &self.config
    }

    fn auth_header(&self) -> String {
        if self.config.use_bearer {
            format!("Bearer {}", self.config.api_token)
        } else {
            let credentials = format!(
                "{}:{}",
                self.config.email.as_deref().unwrap_or(""),
                self.config.api_token
            );
            let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
            format!("Basic {encoded}")
        }
    }

    fn base_url(&self) -> String {
        if self.config.is_cloud {
            format!("{}/wiki/api/v2", self.config.host)
        } else {
            format!("{}/rest/api", self.config.host)
        }
    }

    /// Make a JSON API request to Confluence.
    /// When `use_v1_api` is true and the instance is Cloud, the v1 REST endpoint is used.
    pub async fn request<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        method: &str,
        body: Option<&serde_json::Value>,
        use_v1_api: bool,
    ) -> Result<T, String> {
        let url = if use_v1_api && self.config.is_cloud {
            format!("{}/wiki/rest/api{endpoint}", self.config.host)
        } else {
            format!("{}{endpoint}", self.base_url())
        };

        eprintln!("[DEBUG] Fetching: {url}");
        eprintln!(
            "[DEBUG] Auth type: {}",
            if self.config.use_bearer {
                "Bearer"
            } else {
                "Basic"
            }
        );

        let mut req = match method {
            "POST" => self.http.post(&url),
            "PUT" => self.http.put(&url),
            "DELETE" => self.http.delete(&url),
            _ => self.http.get(&url),
        };

        req = req
            .header("Authorization", self.auth_header())
            .header("Accept", "application/json")
            .header("Content-Type", "application/json");

        if let Some(b) = body {
            req = req.json(b);
        }

        let resp = req.send().await.map_err(|e| format!("HTTP error: {e}"))?;
        let status = resp.status();

        if status == reqwest::StatusCode::NO_CONTENT {
            // Deserialize an empty JSON object for 204 responses.
            return serde_json::from_str("{}").map_err(|e| e.to_string());
        }

        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            return Err(format!("Confluence API error ({status}): {text}"));
        }

        resp.json::<T>()
            .await
            .map_err(|e| format!("JSON parse error: {e}"))
    }

    /// Convenience wrapper – GET, no body, default API version.
    pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, String> {
        self.request(endpoint, "GET", None, false).await
    }

    /// Convenience wrapper – GET with v1 API.
    pub async fn get_v1<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, String> {
        self.request(endpoint, "GET", None, true).await
    }

    /// Convenience wrapper – POST.
    pub async fn post<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        body: &serde_json::Value,
    ) -> Result<T, String> {
        self.request(endpoint, "POST", Some(body), false).await
    }

    /// Convenience wrapper – PUT.
    pub async fn put<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        body: &serde_json::Value,
    ) -> Result<T, String> {
        self.request(endpoint, "PUT", Some(body), false).await
    }

    /// Convenience wrapper – DELETE.
    pub async fn delete(&self, endpoint: &str) -> Result<(), String> {
        let _: serde_json::Value = self.request(endpoint, "DELETE", None, false).await?;
        Ok(())
    }

    /// Fetch binary data from a URL (for attachments).
    /// Returns `(base64_data, mime_type, byte_size)`.
    pub async fn fetch_binary(&self, download_url: &str) -> Result<(String, String, usize), String> {
        let url = if download_url.starts_with('/') {
            format!("{}{download_url}", self.config.host)
        } else {
            download_url.to_string()
        };

        eprintln!("[DEBUG] Fetching binary: {url}");

        let resp = self
            .http
            .get(&url)
            .header("Authorization", self.auth_header())
            .send()
            .await
            .map_err(|e| format!("HTTP error: {e}"))?;

        if !resp.status().is_success() {
            return Err(format!(
                "Failed to fetch attachment ({}): {}",
                resp.status(),
                resp.status().canonical_reason().unwrap_or("unknown")
            ));
        }

        let mime = resp
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("application/octet-stream")
            .to_string();

        let bytes = resp.bytes().await.map_err(|e| format!("Read error: {e}"))?;
        let size = bytes.len();
        let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);

        Ok((b64, mime, size))
    }
}