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_page, strip_html};
use crate::mcp::{CallToolResult, ToolDefinition};
use crate::types::{
    AnyPage, PageListResponse, PageListResponseV1, SearchResult,
};

use super::schema;

pub fn definitions() -> Vec<ToolDefinition> {
    vec![
        ToolDefinition {
            name: "search_pages".to_string(),
            description: "Search for Confluence pages using CQL (Confluence Query Language)"
                .to_string(),
            input_schema: schema(&[
                (
                    "cql",
                    "string",
                    true,
                    "CQL query string (e.g., 'space = DEV AND title ~ \"API\"')",
                ),
                (
                    "limit",
                    "number",
                    false,
                    "Maximum number of results to return (default: 10)",
                ),
            ]),
        },
        ToolDefinition {
            name: "get_page_children".to_string(),
            description: "Get child pages of a Confluence page".to_string(),
            input_schema: schema(&[
                ("pageId", "string", true, "The ID of the parent page"),
                (
                    "limit",
                    "number",
                    false,
                    "Maximum number of children to return (default: 25)",
                ),
            ]),
        },
    ]
}

pub async fn search_pages(client: &ConfluenceClient, args: &Value) -> CallToolResult {
    let cql = match args.get("cql").and_then(|v| v.as_str()) {
        Some(c) => c,
        None => return CallToolResult::text("Error: cql is required."),
    };
    let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10);

    let encoded_cql = urlencoding(cql);
    let result: Result<SearchResult, String> = client
        .get_v1(&format!(
            "/search?cql={encoded_cql}&limit={limit}"
        ))
        .await;

    match result {
        Ok(sr) => {
            if sr.results.is_empty() {
                return CallToolResult::text("No pages found matching your query.");
            }

            let formatted: Vec<String> = sr
                .results
                .iter()
                .map(|r| {
                    let mut lines = vec![format!("**{}**", r.title)];
                    if let Some(content) = &r.content {
                        lines.push(format!("- **ID**: {}", content.id));
                        lines.push(format!("- **Status**: {}", content.status));
                        if let Some(space) = &content.space {
                            lines.push(format!("- **Space**: {} ({})", space.name, space.key));
                        }
                    }
                    if let Some(excerpt) = &r.excerpt {
                        let clean = strip_html(excerpt);
                        let truncated = if clean.len() > 200 {
                            format!("{}...", &clean[..200])
                        } else {
                            clean
                        };
                        lines.push(format!("- **Excerpt**: {truncated}"));
                    }
                    lines.join("\n")
                })
                .collect();

            let total = sr.total_size.unwrap_or(sr.results.len() as i64);
            CallToolResult::text(format!(
                "Found {} result(s) (showing {}):\n\n{}",
                total,
                sr.results.len(),
                formatted.join("\n\n---\n\n")
            ))
        }
        Err(e) => CallToolResult::text(format!("Error searching pages: {e}")),
    }
}

pub async fn get_page_children(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 limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(25);

    if client.config().is_cloud {
        match client
            .get::<PageListResponse>(&format!("/pages/{page_id}/children?limit={limit}"))
            .await
        {
            Ok(result) => {
                if result.results.is_empty() {
                    return CallToolResult::text("No child pages found.");
                }
                let formatted: Vec<String> = result
                    .results
                    .iter()
                    .map(|p| {
                        let ap = AnyPage::V2(
                            serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap(),
                        );
                        format_page(&ap)
                    })
                    .collect();
                CallToolResult::text(format!(
                    "Found {} child page(s):\n\n{}",
                    result.results.len(),
                    formatted.join("\n\n---\n\n")
                ))
            }
            Err(e) => CallToolResult::text(format!("Error getting child pages: {e}")),
        }
    } else {
        match client
            .get::<PageListResponseV1>(&format!(
                "/content/{page_id}/child/page?limit={limit}&expand=version"
            ))
            .await
        {
            Ok(result) => {
                if result.results.is_empty() {
                    return CallToolResult::text("No child pages found.");
                }
                let formatted: Vec<String> = result
                    .results
                    .iter()
                    .map(|p| {
                        let ap = AnyPage::V1(
                            serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap(),
                        );
                        format_page(&ap)
                    })
                    .collect();
                CallToolResult::text(format!(
                    "Found {} child page(s):\n\n{}",
                    result.results.len(),
                    formatted.join("\n\n---\n\n")
                ))
            }
            Err(e) => CallToolResult::text(format!("Error getting child pages: {e}")),
        }
    }
}

fn urlencoding(s: &str) -> String {
    let mut out = String::new();
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            _ => {
                out.push('%');
                out.push_str(&format!("{b:02X}"));
            }
        }
    }
    out
}