mcp-confluence 1.0.0

MCP server for Confluence integration - create, update, search, and manage Confluence pages
pub mod spaces;
pub mod pages;
pub mod search;
pub mod labels;
pub mod reading;
pub mod attachments;
pub mod storage;
pub mod tables;

use serde_json::Value;

use crate::client::ConfluenceClient;
use crate::mcp::{CallToolResult, ToolDefinition};

/// Helper to build an input schema JSON object from a list of (name, type, required, description).
pub fn schema(
    properties: &[(&str, &str, bool, &str)],
) -> Value {
    let mut props = serde_json::Map::new();
    let mut required = Vec::new();

    for &(name, ty, req, desc) in properties {
        let mut prop = serde_json::Map::new();
        prop.insert("type".to_string(), Value::String(ty.to_string()));
        prop.insert("description".to_string(), Value::String(desc.to_string()));
        props.insert(name.to_string(), Value::Object(prop));
        if req {
            required.push(Value::String(name.to_string()));
        }
    }

    let mut obj = serde_json::Map::new();
    obj.insert("type".to_string(), Value::String("object".to_string()));
    obj.insert("properties".to_string(), Value::Object(props));
    if !required.is_empty() {
        obj.insert("required".to_string(), Value::Array(required));
    }
    Value::Object(obj)
}

/// Helper for array-of-string property.
pub fn schema_with_array(
    properties: &[(&str, &str, bool, &str)],
    array_props: &[(&str, &str, bool, &str)],
) -> Value {
    let mut val = schema(properties);
    let obj = val.as_object_mut().unwrap();

    // Collect required entries from array props first
    let mut extra_required: Vec<Value> = Vec::new();
    let mut extra_props: Vec<(String, Value)> = Vec::new();

    for &(name, item_type, req, desc) in array_props {
        let mut prop = serde_json::Map::new();
        prop.insert("type".to_string(), Value::String("array".to_string()));
        prop.insert("description".to_string(), Value::String(desc.to_string()));
        let mut items = serde_json::Map::new();
        items.insert("type".to_string(), Value::String(item_type.to_string()));
        prop.insert("items".to_string(), Value::Object(items));
        extra_props.push((name.to_string(), Value::Object(prop)));
        if req {
            extra_required.push(Value::String(name.to_string()));
        }
    }

    let props = obj.get_mut("properties").unwrap().as_object_mut().unwrap();
    for (k, v) in extra_props {
        props.insert(k, v);
    }

    let required = obj
        .entry("required")
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .unwrap();
    required.extend(extra_required);

    val
}

/// Collect all tool definitions.
pub fn list_tools() -> Vec<ToolDefinition> {
    let mut tools = Vec::new();
    tools.extend(spaces::definitions());
    tools.extend(pages::definitions());
    tools.extend(search::definitions());
    tools.extend(labels::definitions());
    tools.extend(reading::definitions());
    tools.extend(attachments::definitions());
    tools.extend(storage::definitions());
    tools.extend(tables::definitions());
    tools
}

/// Dispatch a tool call by name.
pub async fn call_tool(
    client: &ConfluenceClient,
    name: &str,
    args: &Value,
) -> CallToolResult {
    match name {
        // Spaces
        "list_spaces" => spaces::list_spaces(client, args).await,
        "get_space_pages" => spaces::get_space_pages(client, args).await,

        // Pages
        "get_page" => pages::get_page(client, args).await,
        "get_page_full" => pages::get_page_full(client, args).await,
        "create_page" => pages::create_page(client, args).await,
        "update_page" => pages::update_page(client, args).await,
        "delete_page" => pages::delete_page(client, args).await,

        // Search
        "search_pages" => search::search_pages(client, args).await,
        "get_page_children" => search::get_page_children(client, args).await,

        // Labels
        "add_labels" => labels::add_labels(client, args).await,
        "get_labels" => labels::get_labels(client, args).await,

        // Reading
        "read_page_outline" => reading::read_page_outline(client, args).await,
        "read_page_section" => reading::read_page_section(client, args).await,
        "read_page_offset" => reading::read_page_offset(client, args).await,

        // Attachments
        "list_page_attachments" => attachments::list_page_attachments(client, args).await,
        "get_attachment_url" => attachments::get_attachment_url(client, args).await,
        "get_attachment_base64" => attachments::get_attachment_base64(client, args).await,

        // Storage
        "get_page_storage_format" => storage::get_page_storage_format(client, args).await,

        // Tables
        "update_release_table_cell" => tables::update_release_table_cell(client, args).await,
        "find_replace_in_page" => tables::find_replace_in_page(client, args).await,
        "insert_table_row" => tables::insert_table_row(client, args).await,

        _ => CallToolResult::text(format!("Unknown tool: {name}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── schema ──

    #[test]
    fn schema_basic() {
        let s = schema(&[
            ("name", "string", true, "The name"),
            ("count", "integer", false, "A count"),
        ]);
        let obj = s.as_object().unwrap();
        assert_eq!(obj["type"], "object");

        let props = obj["properties"].as_object().unwrap();
        assert!(props.contains_key("name"));
        assert!(props.contains_key("count"));
        assert_eq!(props["name"]["type"], "string");
        assert_eq!(props["name"]["description"], "The name");
        assert_eq!(props["count"]["type"], "integer");

        let required = obj["required"].as_array().unwrap();
        assert_eq!(required.len(), 1);
        assert_eq!(required[0], "name");
    }

    #[test]
    fn schema_no_required() {
        let s = schema(&[("opt", "string", false, "Optional field")]);
        let obj = s.as_object().unwrap();
        assert!(!obj.contains_key("required"));
    }

    #[test]
    fn schema_empty() {
        let s = schema(&[]);
        let obj = s.as_object().unwrap();
        assert_eq!(obj["type"], "object");
        assert!(obj["properties"].as_object().unwrap().is_empty());
    }

    // ── schema_with_array ──

    #[test]
    fn schema_with_array_basic() {
        let s = schema_with_array(
            &[("pageId", "string", true, "Page ID")],
            &[("labels", "string", true, "Labels to add")],
        );
        let obj = s.as_object().unwrap();
        let props = obj["properties"].as_object().unwrap();

        assert!(props.contains_key("pageId"));
        assert!(props.contains_key("labels"));
        assert_eq!(props["labels"]["type"], "array");
        assert_eq!(props["labels"]["items"]["type"], "string");

        let required = obj["required"].as_array().unwrap();
        assert!(required.contains(&Value::String("pageId".to_string())));
        assert!(required.contains(&Value::String("labels".to_string())));
    }

    #[test]
    fn schema_with_array_optional() {
        let s = schema_with_array(
            &[],
            &[("tags", "string", false, "Optional tags")],
        );
        let obj = s.as_object().unwrap();
        let props = obj["properties"].as_object().unwrap();
        assert!(props.contains_key("tags"));
        // No required array entry for optional
        assert!(!obj.contains_key("required") || obj["required"].as_array().unwrap().is_empty());
    }

    // ── list_tools ──

    #[test]
    fn list_tools_returns_all() {
        let tools = list_tools();
        assert!(!tools.is_empty());

        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
        // Spot-check key tools exist
        assert!(names.contains(&"list_spaces"));
        assert!(names.contains(&"get_page"));
        assert!(names.contains(&"create_page"));
        assert!(names.contains(&"update_page"));
        assert!(names.contains(&"delete_page"));
        assert!(names.contains(&"search_pages"));
        assert!(names.contains(&"add_labels"));
        assert!(names.contains(&"get_labels"));
        assert!(names.contains(&"read_page_outline"));
        assert!(names.contains(&"read_page_section"));
        assert!(names.contains(&"read_page_offset"));
        assert!(names.contains(&"list_page_attachments"));
        assert!(names.contains(&"get_attachment_url"));
        assert!(names.contains(&"get_attachment_base64"));
        assert!(names.contains(&"get_page_storage_format"));
        assert!(names.contains(&"update_release_table_cell"));
        assert!(names.contains(&"find_replace_in_page"));
        assert!(names.contains(&"insert_table_row"));
    }

    #[test]
    fn tool_definitions_have_schemas() {
        let tools = list_tools();
        for tool in &tools {
            assert!(!tool.name.is_empty(), "Tool name should not be empty");
            assert!(!tool.description.is_empty(), "Tool {} should have a description", tool.name);
            let schema = tool.input_schema.as_object().unwrap();
            assert_eq!(schema["type"], "object", "Tool {} schema should be an object", tool.name);
        }
    }

    // ── call_tool unknown ──

    #[tokio::test]
    async fn call_tool_unknown() {
        let config = crate::config::Config {
            host: "https://example.com".to_string(),
            email: None,
            api_token: "tok".to_string(),
            api_version: "1".to_string(),
            is_cloud: false,
            use_bearer: true,
            max_content_length: 30000,
        };
        let client = crate::client::ConfluenceClient::new(config);
        let result = call_tool(&client, "nonexistent_tool", &Value::Null).await;
        assert_eq!(result.content.len(), 1);
        assert!(result.content[0].text.contains("Unknown tool"));
    }
}