mcp-confluence 1.0.0

MCP server for Confluence integration - create, update, search, and manage Confluence pages
use serde_json::Value;

use crate::client::ConfluenceClient;
use crate::formatters::format_bytes;
use crate::mcp::{CallToolResult, ToolDefinition};
use crate::types::{AttachmentListResponse, ConfluenceAttachment};

use super::schema;

pub fn definitions() -> Vec<ToolDefinition> {
    vec![
        ToolDefinition {
            name: "list_page_attachments".to_string(),
            description:
                "List all attachments (images, files) on a Confluence page with metadata"
                    .to_string(),
            input_schema: schema(&[
                (
                    "pageId",
                    "string",
                    true,
                    "The page ID to list attachments for",
                ),
                (
                    "mediaType",
                    "string",
                    false,
                    "Filter by media type (e.g., 'image/png', 'image/', 'application/pdf')",
                ),
            ]),
        },
        ToolDefinition {
            name: "get_attachment_url".to_string(),
            description: "Get the direct download URL for a Confluence attachment".to_string(),
            input_schema: schema(&[
                (
                    "pageId",
                    "string",
                    true,
                    "The page ID containing the attachment",
                ),
                ("filename", "string", true, "The filename of the attachment"),
            ]),
        },
        ToolDefinition {
            name: "get_attachment_base64".to_string(),
            description: "Fetch an attachment and return it as base64-encoded data. Useful for images with vision-capable models.".to_string(),
            input_schema: schema(&[
                ("pageId", "string", true, "The page ID containing the attachment"),
                ("filename", "string", true, "The filename of the attachment"),
                ("maxSizeKB", "number", false, "Maximum file size in KB to fetch (default: 500KB)"),
            ]),
        },
    ]
}

/// Fetch attachments for a page.
async fn fetch_attachments(
    client: &ConfluenceClient,
    page_id: &str,
) -> Result<(Vec<ConfluenceAttachment>, String), String> {
    let base_url = client.config().host.clone();
    if client.config().is_cloud {
        let result: AttachmentListResponse = client
            .get(&format!("/pages/{page_id}/attachments"))
            .await?;
        Ok((result.results, base_url))
    } else {
        let result: AttachmentListResponse = client
            .get(&format!(
                "/content/{page_id}/child/attachment?expand=metadata,extensions"
            ))
            .await?;
        let base = result
            .links
            .as_ref()
            .and_then(|l| l.base.as_deref())
            .unwrap_or(&base_url)
            .to_string();
        Ok((result.results, base))
    }
}

fn attachment_media_type(att: &ConfluenceAttachment) -> String {
    att.media_type
        .clone()
        .or_else(|| att.extensions.as_ref().and_then(|e| e.media_type.clone()))
        .or_else(|| att.metadata.as_ref().and_then(|m| m.media_type.clone()))
        .unwrap_or_else(|| "unknown".to_string())
}

fn attachment_file_size(att: &ConfluenceAttachment) -> u64 {
    att.file_size
        .or_else(|| att.extensions.as_ref().and_then(|e| e.file_size))
        .unwrap_or(0)
}

fn attachment_download_path(att: &ConfluenceAttachment) -> String {
    att.download_link
        .clone()
        .or_else(|| att.links.as_ref().and_then(|l| l.download.clone()))
        .unwrap_or_default()
}

pub async fn list_page_attachments(client: &ConfluenceClient, args: &Value) -> CallToolResult {
    let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
        Some(id) => id,
        None => return CallToolResult::text("Error: pageId is required."),
    };
    let media_type_filter = args.get("mediaType").and_then(|v| v.as_str());

    match fetch_attachments(client, page_id).await {
        Ok((mut attachments, base_url)) => {
            if attachments.is_empty() {
                return CallToolResult::text("No attachments found on this page.");
            }

            if let Some(filter) = media_type_filter {
                attachments.retain(|a| {
                    attachment_media_type(a)
                        .to_lowercase()
                        .contains(&filter.to_lowercase())
                });
                if attachments.is_empty() {
                    return CallToolResult::text(format!(
                        "No attachments found matching media type \"{filter}\"."
                    ));
                }
            }

            let formatted: Vec<String> = attachments
                .iter()
                .enumerate()
                .map(|(i, att)| {
                    let mtype = attachment_media_type(att);
                    let size = attachment_file_size(att);
                    let dl = attachment_download_path(att);
                    let is_image = mtype.starts_with("image/");
                    let emoji = if is_image { " 🖼️" } else { "" };

                    let mut lines = vec![
                        format!("### {}. {}", i + 1, att.title),
                        format!("- **ID**: {}", att.id),
                        format!("- **Type**: {mtype}{emoji}"),
                        format!("- **Size**: {}", format_bytes(size)),
                    ];

                    if !dl.is_empty() {
                        let full = if dl.starts_with('/') {
                            format!("{base_url}{dl}")
                        } else {
                            dl
                        };
                        lines.push(format!("- **URL**: {full}"));
                    }

                    lines.join("\n")
                })
                .collect();

            let image_count = attachments
                .iter()
                .filter(|a| attachment_media_type(a).starts_with("image/"))
                .count();
            let img_note = if image_count > 0 {
                format!(" ({image_count} images)")
            } else {
                String::new()
            };

            CallToolResult::text(format!(
                "# Attachments for Page {page_id}\n\n**Total**: {} attachment(s){img_note}\n\n---\n\n{}\n\n---\n💡 Use `get_attachment_url` to get a direct URL, or `get_attachment_base64` to fetch image data for vision models.",
                attachments.len(),
                formatted.join("\n\n")
            ))
        }
        Err(e) => CallToolResult::text(format!("Error listing attachments: {e}")),
    }
}

pub async fn get_attachment_url(client: &ConfluenceClient, args: &Value) -> CallToolResult {
    let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
        Some(id) => id,
        None => return CallToolResult::text("Error: pageId is required."),
    };
    let filename = match args.get("filename").and_then(|v| v.as_str()) {
        Some(f) => f,
        None => return CallToolResult::text("Error: filename is required."),
    };

    match fetch_attachments(client, page_id).await {
        Ok((attachments, base_url)) => {
            let attachment = attachments
                .iter()
                .find(|a| a.title.to_lowercase() == filename.to_lowercase());

            match attachment {
                Some(att) => {
                    let dl = attachment_download_path(att);
                    let full_url = if dl.starts_with('/') {
                        format!("{base_url}{dl}")
                    } else {
                        dl
                    };
                    let mtype = attachment_media_type(att);
                    let size = attachment_file_size(att);

                    CallToolResult::text(format!(
                        "# Attachment: {}\n\n| Property | Value |\n|----------|-------|\n| **ID** | {} |\n| **Type** | {mtype} |\n| **Size** | {} |\n| **URL** | {full_url} |\n\n---\n📎 Direct link: {full_url}",
                        att.title,
                        att.id,
                        format_bytes(size),
                    ))
                }
                None => {
                    let available: Vec<&str> =
                        attachments.iter().map(|a| a.title.as_str()).collect();
                    CallToolResult::text(format!(
                        "Attachment \"{filename}\" not found.\n\nAvailable attachments: {}",
                        if available.is_empty() {
                            "none".to_string()
                        } else {
                            available.join(", ")
                        }
                    ))
                }
            }
        }
        Err(e) => CallToolResult::text(format!("Error getting attachment URL: {e}")),
    }
}

pub async fn get_attachment_base64(client: &ConfluenceClient, args: &Value) -> CallToolResult {
    let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
        Some(id) => id,
        None => return CallToolResult::text("Error: pageId is required."),
    };
    let filename = match args.get("filename").and_then(|v| v.as_str()) {
        Some(f) => f,
        None => return CallToolResult::text("Error: filename is required."),
    };
    let max_size_kb = args
        .get("maxSizeKB")
        .and_then(|v| v.as_u64())
        .unwrap_or(500);

    match fetch_attachments(client, page_id).await {
        Ok((attachments, base_url)) => {
            let attachment = attachments
                .iter()
                .find(|a| a.title.to_lowercase() == filename.to_lowercase());

            match attachment {
                Some(att) => {
                    let file_size = attachment_file_size(att);
                    let max_bytes = max_size_kb * 1024;

                    if file_size > max_bytes {
                        return CallToolResult::text(format!(
                            "Attachment \"{}\" ({}) exceeds the maximum size limit of {}KB.\n\nUse `get_attachment_url` to get the direct URL instead.",
                            filename,
                            format_bytes(file_size),
                            max_size_kb,
                        ));
                    }

                    let dl = attachment_download_path(att);
                    if dl.is_empty() {
                        return CallToolResult::text(format!(
                            "No download URL found for attachment \"{filename}\"."
                        ));
                    }

                    let full_url = if dl.starts_with('/') {
                        format!("{base_url}{dl}")
                    } else {
                        dl
                    };

                    match client.fetch_binary(&full_url).await {
                        Ok((data, mime, size)) => {
                            CallToolResult::text(format!(
                                "# Attachment: {}\n\n| Property | Value |\n|----------|-------|\n| **Type** | {mime} |\n| **Size** | {} |\n| **Base64 Length** | {} characters |\n\n## Base64 Data\n\n```\ndata:{mime};base64,{data}\n```\n\n---\n💡 The data URI above can be used directly in image tags or passed to vision-capable models.",
                                att.title,
                                format_bytes(size as u64),
                                data.len(),
                            ))
                        }
                        Err(e) => CallToolResult::text(format!("Error fetching attachment: {e}")),
                    }
                }
                None => {
                    let available: Vec<&str> =
                        attachments.iter().map(|a| a.title.as_str()).collect();
                    CallToolResult::text(format!(
                        "Attachment \"{filename}\" not found.\n\nAvailable attachments: {}",
                        if available.is_empty() {
                            "none".to_string()
                        } else {
                            available.join(", ")
                        }
                    ))
                }
            }
        }
        Err(e) => CallToolResult::text(format!("Error fetching attachment: {e}")),
    }
}