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)"),
]),
},
]
}
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(§ion_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}")),
}
}