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::config::ExpandParams;
use crate::formatters::html_to_markdown;
use crate::macros::{extract_headings, extract_section, process_confluence_macros};
use crate::mcp::{CallToolResult, ToolDefinition};
use crate::types::{ConfluencePage, ConfluencePageV1};

use super::schema;

pub fn definitions() -> Vec<ToolDefinition> {
    vec![
        ToolDefinition {
            name: "read_page_outline".to_string(),
            description: "Get the outline/structure of a Confluence page (headings only). Use this first to understand large pages before reading specific sections.".to_string(),
            input_schema: schema(&[
                ("pageId", "string", true, "The page ID to get the outline for"),
            ]),
        },
        ToolDefinition {
            name: "read_page_section".to_string(),
            description: "Read a specific section of a Confluence page by heading name. Use after read_page_outline to get content from specific sections of large pages.".to_string(),
            input_schema: schema(&[
                ("pageId", "string", true, "The page ID"),
                ("headingText", "string", true, "The heading text to find (partial match supported)"),
                ("headingLevel", "number", false, "Optional: specific heading level (1-6) to match"),
            ]),
        },
        ToolDefinition {
            name: "read_page_offset".to_string(),
            description: "Read a page starting from a specific character offset. Use this to continue reading a truncated page.".to_string(),
            input_schema: schema(&[
                ("pageId", "string", true, "The page ID"),
                ("offset", "number", false, "Character offset to start reading from (default: 0)"),
                ("length", "number", false, "Number of characters to read (default: 30000)"),
            ]),
        },
    ]
}

/// Fetch body content and title for a page.
async fn fetch_page_body(
    client: &ConfluenceClient,
    page_id: &str,
) -> Result<(String, String), String> {
    if client.config().is_cloud {
        let page: ConfluencePage = client
            .get(&format!("/pages/{page_id}?body-format=storage"))
            .await?;
        let body = page
            .body
            .and_then(|b| b.storage)
            .map_or(String::new(), |s| s.value);
        Ok((page.title, body))
    } else {
        let page: ConfluencePageV1 = client
            .get(&format!(
                "/content/{page_id}?expand={}",
                ExpandParams::PAGE_CONTENT
            ))
            .await?;
        let body = page
            .body
            .and_then(|b| b.storage)
            .map_or(String::new(), |s| s.value);
        Ok((page.title, body))
    }
}

pub async fn read_page_outline(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."),
    };

    match fetch_page_body(client, page_id).await {
        Ok((title, body)) => {
            if body.is_empty() {
                return CallToolResult::text(format!("Page \"{title}\" has no content."));
            }

            let headings = extract_headings(&body);
            if headings.is_empty() {
                return CallToolResult::text(format!(
                    "# {title}\n\nThis page has no headings. Use `get_page` to read the full content."
                ));
            }

            let outline: Vec<String> = headings
                .iter()
                .map(|h| {
                    let indent = "  ".repeat((h.level as usize).saturating_sub(1));
                    let hashes = "#".repeat(h.level as usize);
                    format!("{indent}{hashes} {}", h.text)
                })
                .collect();

            CallToolResult::text(format!(
                "# Page Outline: {title}\n\n**Total sections**: {}\n\n## Structure\n\n{}\n\n---\n💡 Use `read_page_section` with a heading name to read specific sections.",
                headings.len(),
                outline.join("\n")
            ))
        }
        Err(e) => CallToolResult::text(format!("Error getting page outline: {e}")),
    }
}

pub async fn read_page_section(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 heading_text = match args.get("headingText").and_then(|v| v.as_str()) {
        Some(t) => t,
        None => return CallToolResult::text("Error: headingText is required."),
    };
    let heading_level = args
        .get("headingLevel")
        .and_then(|v| v.as_u64())
        .map(|v| v as u8);

    match fetch_page_body(client, page_id).await {
        Ok((title, body)) => {
            if body.is_empty() {
                return CallToolResult::text(format!("Page \"{title}\" has no content."));
            }

            match extract_section(&body, heading_text, heading_level) {
                Some(section_html) => {
                    let markdown = html_to_markdown(&section_html, false, 0);
                    CallToolResult::text(format!(
                        "# {title}\n## Section: {heading_text}\n\n{markdown}"
                    ))
                }
                None => {
                    let headings = extract_headings(&body);
                    let heading_list: Vec<String> =
                        headings.iter().map(|h| format!("  - {}", h.text)).collect();
                    CallToolResult::text(format!(
                        "Section \"{heading_text}\" not found in page \"{title}\".\n\nAvailable sections:\n{}",
                        heading_list.join("\n")
                    ))
                }
            }
        }
        Err(e) => CallToolResult::text(format!("Error reading page section: {e}")),
    }
}

pub async fn read_page_offset(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 offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
    let length = args
        .get("length")
        .and_then(|v| v.as_u64())
        .unwrap_or(30000) as usize;

    match fetch_page_body(client, page_id).await {
        Ok((title, body)) => {
            if body.is_empty() {
                return CallToolResult::text(format!("Page \"{title}\" has no content."));
            }

            let processed = process_confluence_macros(&body);
            let full_md = html_to_markdown(&processed, false, 0);
            let total = full_md.len();

            if offset >= total {
                return CallToolResult::text(format!(
                    "Offset {offset} is beyond the end of the page (total length: {total} characters)."
                ));
            }

            let end = (offset + length).min(total);
            let chunk = &full_md[offset..end];
            let has_more = end < total;

            let header = format!(
                "# {title}\n📍 **Reading**: characters {} to {} of {total}\n\n",
                offset + 1,
                end
            );

            let footer = if has_more {
                format!(
                    "\n\n---\n📖 **{} characters remaining**. Use `read_page_offset` with offset={end} to continue.",
                    total - end
                )
            } else {
                "\n\n---\n✅ End of page.".to_string()
            };

            CallToolResult::text(format!("{header}{chunk}{footer}"))
        }
        Err(e) => CallToolResult::text(format!("Error reading page offset: {e}")),
    }
}