use regex::Regex;
use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router};
use serde::Deserialize;
use serde_json::Value;
use crate::client::ConfluenceClient;
use crate::config::ExpandParams;
use crate::formatters::{
format_bytes, format_page, format_page_detailed, format_space, html_to_markdown, strip_html,
};
use crate::macros::{extract_headings, extract_section, process_confluence_macros};
use crate::types::*;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListSpacesParams {
#[schemars(description = "Maximum number of spaces to return (default: 25)")]
pub limit: Option<u32>,
#[serde(rename = "type")]
#[schemars(description = "Filter by space type: global, personal")]
pub space_type: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetSpacePagesParams {
#[schemars(description = "The space key")]
pub space_key: String,
#[schemars(description = "Maximum number of pages to return (default: 25)")]
pub limit: Option<u32>,
#[schemars(description = "Page status: current, archived, draft (default: current)")]
pub status: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetPageParams {
#[schemars(description = "The page ID (e.g., '123456')")]
pub page_id: Option<String>,
#[schemars(description = "The space key (required if using title)")]
pub space_key: Option<String>,
#[schemars(description = "The page title (requires spaceKey)")]
pub title: Option<String>,
#[schemars(description = "Whether to include the page body content (default: true)")]
pub include_body: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetPageFullParams {
#[schemars(description = "The page ID (e.g., '123456')")]
pub page_id: Option<String>,
#[schemars(description = "The space key (required if using title)")]
pub space_key: Option<String>,
#[schemars(description = "The page title (requires spaceKey)")]
pub title: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreatePageParams {
#[schemars(description = "The space key where the page will be created")]
pub space_key: String,
#[schemars(description = "The title of the page")]
pub title: String,
#[schemars(description = "The page content in HTML or storage format")]
pub content: String,
#[schemars(description = "The ID of the parent page (for nested pages)")]
pub parent_page_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePageParams {
#[schemars(description = "The ID of the page to update")]
pub page_id: String,
#[schemars(description = "New title for the page (optional)")]
pub title: Option<String>,
#[schemars(description = "The new page content in HTML or storage format")]
pub content: String,
#[schemars(description = "A comment describing this update")]
pub version_comment: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeletePageParams {
#[schemars(description = "The ID of the page to delete")]
pub page_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchPagesParams {
#[schemars(description = "CQL query string (e.g., 'space = DEV AND title ~ \"API\"')")]
pub cql: String,
#[schemars(description = "Maximum number of results to return (default: 10)")]
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetPageChildrenParams {
#[schemars(description = "The ID of the parent page")]
pub page_id: String,
#[schemars(description = "Maximum number of children to return (default: 25)")]
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AddLabelsParams {
#[schemars(description = "The ID of the page")]
pub page_id: String,
#[schemars(description = "Array of labels to add")]
pub labels: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetLabelsParams {
#[schemars(description = "The ID of the page")]
pub page_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadPageOutlineParams {
#[schemars(description = "The page ID to get the outline for")]
pub page_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadPageSectionParams {
#[schemars(description = "The page ID")]
pub page_id: String,
#[schemars(description = "The heading text to find (partial match supported)")]
pub heading_text: String,
#[schemars(description = "Optional: specific heading level (1-6) to match")]
pub heading_level: Option<u8>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadPageOffsetParams {
#[schemars(description = "The page ID")]
pub page_id: String,
#[schemars(description = "Character offset to start reading from (default: 0)")]
pub offset: Option<u64>,
#[schemars(description = "Number of characters to read (default: 30000)")]
pub length: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListPageAttachmentsParams {
#[schemars(description = "The page ID to list attachments for")]
pub page_id: String,
#[schemars(description = "Filter by media type (e.g., 'image/png', 'image/', 'application/pdf')")]
pub media_type: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetAttachmentUrlParams {
#[schemars(description = "The page ID containing the attachment")]
pub page_id: String,
#[schemars(description = "The filename of the attachment")]
pub filename: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetAttachmentBase64Params {
#[schemars(description = "The page ID containing the attachment")]
pub page_id: String,
#[schemars(description = "The filename of the attachment")]
pub filename: String,
#[serde(rename = "maxSizeKB")]
#[schemars(description = "Maximum file size in KB to fetch (default: 500KB)")]
pub max_size_kb: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetPageStorageFormatParams {
#[schemars(description = "The page ID to get storage format for")]
pub page_id: String,
#[schemars(
description = "Optional: Extract only a section containing this text (useful for large pages)"
)]
pub section: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateReleaseTableCellParams {
#[schemars(description = "The page ID containing the table")]
pub page_id: String,
#[schemars(
description = "Text to identify the row (e.g., a date, feature name, or SDLC number)"
)]
pub row_identifier: String,
#[schemars(description = "Text to identify the column header")]
pub column_identifier: String,
#[schemars(description = "New HTML/storage format content for the cell")]
pub new_content: String,
#[schemars(description = "How to update: replace (default), append, or prepend")]
pub replace_mode: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FindReplaceInPageParams {
#[schemars(description = "The page ID to update")]
pub page_id: String,
#[schemars(description = "The text or regex pattern to find")]
pub find_pattern: String,
#[schemars(description = "The replacement text/content")]
pub replace_with: String,
#[schemars(description = "Whether findPattern is a regex (default: false)")]
pub is_regex: Option<bool>,
#[schemars(description = "Replace all occurrences (default: false)")]
pub global_replace: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct InsertTableRowParams {
#[schemars(description = "The page ID containing the table")]
pub page_id: String,
#[schemars(description = "Text to identify the table (e.g., a column header)")]
pub table_identifier: String,
#[schemars(description = "Insert after the row containing this text (default: end of table)")]
pub after_row_identifier: Option<String>,
#[schemars(description = "Complete <tr>...</tr> HTML for the new row")]
pub row_content: String,
}
#[derive(Clone)]
pub struct ConfluenceServer {
client: ConfluenceClient,
}
#[tool_router(server_handler)]
impl ConfluenceServer {
pub fn new(client: ConfluenceClient) -> Self {
Self { client }
}
#[tool(description = "List all accessible Confluence spaces")]
async fn list_spaces(&self, Parameters(params): Parameters<ListSpacesParams>) -> String {
let limit = params.limit.unwrap_or(25);
let space_type = params.space_type.as_deref();
let endpoint = if self.client.config().is_cloud {
let mut url = format!("/spaces?limit={limit}");
if let Some(t) = space_type {
url.push_str(&format!("&type={t}"));
}
url
} else {
let mut url = format!("/space?limit={limit}");
if let Some(t) = space_type {
url.push_str(&format!("&type={t}"));
}
url
};
match self.client.get::<SpaceListResponse>(&endpoint).await {
Ok(result) => {
if result.results.is_empty() {
return "No spaces found.".to_string();
}
let formatted: Vec<String> =
result.results.iter().map(|s| format_space(s)).collect();
format!(
"Found {} space(s):\n\n{}",
result.results.len(),
formatted.join("\n\n---\n\n")
)
}
Err(e) => format!("Error listing spaces: {e}"),
}
}
#[tool(description = "Get all pages in a Confluence space")]
async fn get_space_pages(
&self,
Parameters(params): Parameters<GetSpacePagesParams>,
) -> String {
let space_key = ¶ms.space_key;
let limit = params.limit.unwrap_or(25);
let status = params.status.as_deref().unwrap_or("current");
if self.client.config().is_cloud {
let spaces_result: Result<SpaceListResponse, _> =
self.client.get(&format!("/spaces?keys={space_key}")).await;
match spaces_result {
Ok(sr) => {
if sr.results.is_empty() {
return format!("Space with key \"{space_key}\" not found.");
}
let space_id = &sr.results[0].id;
match self
.client
.get::<PageListResponse>(&format!(
"/spaces/{space_id}/pages?limit={limit}&status={status}"
))
.await
{
Ok(result) => format_page_list_v2(&result.results, space_key),
Err(e) => format!("Error getting space pages: {e}"),
}
}
Err(e) => format!("Error getting space pages: {e}"),
}
} else {
match self
.client
.get::<PageListResponseV1>(&format!(
"/content?spaceKey={space_key}&limit={limit}&status={status}&expand=version,space"
))
.await
{
Ok(result) => format_page_list_v1(&result.results, space_key),
Err(e) => format!("Error getting space pages: {e}"),
}
}
}
#[tool(description = "Fetch a Confluence page by its ID or title")]
async fn get_page(&self, Parameters(params): Parameters<GetPageParams>) -> String {
let include_body = params.include_body.unwrap_or(true);
if params.page_id.is_none() && (params.space_key.is_none() || params.title.is_none()) {
return "Error: Please provide either pageId, or both spaceKey and title.".to_string();
}
let cfg = self.client.config();
let result: Result<AnyPage, String> = if cfg.is_cloud {
fetch_page_cloud(
&self.client,
params.page_id.as_deref(),
params.space_key.as_deref(),
params.title.as_deref(),
include_body,
)
.await
} else {
fetch_page_server(
&self.client,
params.page_id.as_deref(),
params.space_key.as_deref(),
params.title.as_deref(),
include_body,
)
.await
};
match result {
Ok(page) => {
format_page_detailed(&page, &cfg.host, cfg.is_cloud, cfg.max_content_length)
}
Err(e) => format!("Error fetching page: {e}"),
}
}
#[tool(description = "Fetch complete Confluence page content in storage format (for automation, analysis, or export). Returns raw storage format without markdown conversion.")]
async fn get_page_full(&self, Parameters(params): Parameters<GetPageFullParams>) -> String {
if params.page_id.is_none() && (params.space_key.is_none() || params.title.is_none()) {
return "Error: Please provide either pageId, or both spaceKey and title.".to_string();
}
let cfg = self.client.config();
let result: Result<AnyPage, String> = if cfg.is_cloud {
fetch_page_cloud(
&self.client,
params.page_id.as_deref(),
params.space_key.as_deref(),
params.title.as_deref(),
true,
)
.await
} else {
fetch_page_server(
&self.client,
params.page_id.as_deref(),
params.space_key.as_deref(),
params.title.as_deref(),
true,
)
.await
};
match result {
Ok(page) => {
let storage = page.storage_value().to_string();
let size = storage.len();
let url = page.webui_link().map_or_else(
|| format!("{}/pages/{}", cfg.host, page.id()),
|w| {
if w.starts_with('/') {
let prefix = if cfg.is_cloud { "/wiki" } else { "" };
format!("{}{prefix}{w}", cfg.host)
} else {
w.to_string()
}
},
);
format!(
"**Page:** {} (ID: {})\n**Version:** {}\n**Size:** {} bytes\n**URL:** {}\n\n---\n\n**Raw Storage Format:**\n\n```xml\n{}\n```",
page.title(), page.id(), page.version_number(), size, url, storage,
)
}
Err(e) => format!("Error fetching page: {e}"),
}
}
#[tool(description = "Create a new Confluence page")]
async fn create_page(&self, Parameters(params): Parameters<CreatePageParams>) -> String {
let cfg = self.client.config();
let result: Result<CreatePageResponse, String> = if cfg.is_cloud {
let spaces: SpaceListResponse = match self
.client
.get(&format!("/spaces?keys={}", params.space_key))
.await
{
Ok(s) => s,
Err(e) => return format!("Error creating page: {e}"),
};
if spaces.results.is_empty() {
return format!("Space with key \"{}\" not found.", params.space_key);
}
let space_id = &spaces.results[0].id;
let mut body = serde_json::json!({
"spaceId": space_id,
"status": "current",
"title": params.title,
"body": {
"representation": "storage",
"value": params.content,
}
});
if let Some(pid) = ¶ms.parent_page_id {
body["parentId"] = Value::String(pid.clone());
}
self.client.post("/pages", &body).await
} else {
let mut body = serde_json::json!({
"type": "page",
"title": params.title,
"space": { "key": params.space_key },
"body": {
"storage": {
"value": params.content,
"representation": "storage"
}
}
});
if let Some(pid) = ¶ms.parent_page_id {
body["ancestors"] = serde_json::json!([{ "id": pid }]);
}
self.client.post("/content", &body).await
};
match result {
Ok(resp) => {
let web_url =
resp.links
.as_ref()
.and_then(|l| l.webui.as_ref())
.map_or_else(
|| {
format!(
"{}/wiki/spaces/{}/pages/{}",
cfg.host, params.space_key, resp.id
)
},
|w| {
if w.starts_with('/') {
let prefix = if cfg.is_cloud { "/wiki" } else { "" };
format!("{}{prefix}{w}", cfg.host)
} else {
w.to_string()
}
},
);
format!(
"✅ Page created successfully!\n\n**Title**: {}\n**ID**: {}\n**URL**: {}",
resp.title, resp.id, web_url
)
}
Err(e) => format!("Error creating page: {e}"),
}
}
#[tool(description = "Update an existing Confluence page")]
async fn update_page(&self, Parameters(params): Parameters<UpdatePageParams>) -> String {
let page_id = ¶ms.page_id;
let cfg = self.client.config();
let result: Result<(), String> = async {
if cfg.is_cloud {
let current: ConfluencePage =
self.client.get(&format!("/pages/{page_id}")).await?;
let current_version = current.version.as_ref().map_or(1, |v| v.number);
let current_title = current.title.clone();
let body = serde_json::json!({
"id": page_id,
"status": "current",
"title": params.title.as_deref().unwrap_or(¤t_title),
"body": {
"representation": "storage",
"value": params.content,
},
"version": {
"number": current_version + 1,
"message": params.version_comment,
}
});
self.client
.put::<Value>(&format!("/pages/{page_id}"), &body)
.await?;
Ok(())
} else {
let current: ConfluencePageV1 = self
.client
.get(&format!("/content/{page_id}?expand=version"))
.await?;
let current_version = current.version.as_ref().map_or(1, |v| v.number);
let current_title = current.title.clone();
let body = serde_json::json!({
"type": "page",
"title": params.title.as_deref().unwrap_or(¤t_title),
"body": {
"storage": {
"value": params.content,
"representation": "storage"
}
},
"version": {
"number": current_version + 1,
"message": params.version_comment,
}
});
self.client
.put::<Value>(&format!("/content/{page_id}"), &body)
.await?;
Ok(())
}
}
.await;
match result {
Ok(()) => format!("✅ Page updated successfully!\n\n**ID**: {page_id}"),
Err(e) => format!("Error updating page: {e}"),
}
}
#[tool(description = "Delete a Confluence page")]
async fn delete_page(&self, Parameters(params): Parameters<DeletePageParams>) -> String {
let page_id = ¶ms.page_id;
let endpoint = if self.client.config().is_cloud {
format!("/pages/{page_id}")
} else {
format!("/content/{page_id}")
};
match self.client.delete(&endpoint).await {
Ok(()) => format!("✅ Page {page_id} deleted successfully."),
Err(e) => format!("Error deleting page: {e}"),
}
}
#[tool(description = "Search for Confluence pages using CQL (Confluence Query Language)")]
async fn search_pages(&self, Parameters(params): Parameters<SearchPagesParams>) -> String {
let limit = params.limit.unwrap_or(10);
let encoded_cql = urlencoding(¶ms.cql);
let result: Result<SearchResult, String> = self
.client
.get_v1(&format!("/search?cql={encoded_cql}&limit={limit}"))
.await;
match result {
Ok(sr) => {
if sr.results.is_empty() {
return "No pages found matching your query.".to_string();
}
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);
format!(
"Found {} result(s) (showing {}):\n\n{}",
total,
sr.results.len(),
formatted.join("\n\n---\n\n")
)
}
Err(e) => format!("Error searching pages: {e}"),
}
}
#[tool(description = "Get child pages of a Confluence page")]
async fn get_page_children(
&self,
Parameters(params): Parameters<GetPageChildrenParams>,
) -> String {
let page_id = ¶ms.page_id;
let limit = params.limit.unwrap_or(25);
if self.client.config().is_cloud {
match self
.client
.get::<PageListResponse>(&format!("/pages/{page_id}/children?limit={limit}"))
.await
{
Ok(result) => {
if result.results.is_empty() {
return "No child pages found.".to_string();
}
let formatted: Vec<String> = result
.results
.iter()
.map(|p| {
let ap = AnyPage::V2(clone_page_v2(p));
format_page(&ap)
})
.collect();
format!(
"Found {} child page(s):\n\n{}",
result.results.len(),
formatted.join("\n\n---\n\n")
)
}
Err(e) => format!("Error getting child pages: {e}"),
}
} else {
match self
.client
.get::<PageListResponseV1>(&format!(
"/content/{page_id}/child/page?limit={limit}&expand=version"
))
.await
{
Ok(result) => {
if result.results.is_empty() {
return "No child pages found.".to_string();
}
let formatted: Vec<String> = result
.results
.iter()
.map(|p| {
let ap = AnyPage::V1(clone_page_v1(p));
format_page(&ap)
})
.collect();
format!(
"Found {} child page(s):\n\n{}",
result.results.len(),
formatted.join("\n\n---\n\n")
)
}
Err(e) => format!("Error getting child pages: {e}"),
}
}
}
#[tool(description = "Add labels to a Confluence page")]
async fn add_labels(&self, Parameters(params): Parameters<AddLabelsParams>) -> String {
let page_id = ¶ms.page_id;
let label_data: Vec<Value> = params
.labels
.iter()
.map(|name| serde_json::json!({ "prefix": "global", "name": name }))
.collect();
let body = Value::Array(label_data);
let endpoint = if self.client.config().is_cloud {
format!("/pages/{page_id}/labels")
} else {
format!("/content/{page_id}/label")
};
match self.client.post::<Value>(&endpoint, &body).await {
Ok(_) => format!(
"✅ Labels added to page {page_id}: {}",
params.labels.join(", ")
),
Err(e) => format!("Error adding labels: {e}"),
}
}
#[tool(description = "Get labels from a Confluence page")]
async fn get_labels(&self, Parameters(params): Parameters<GetLabelsParams>) -> String {
let page_id = ¶ms.page_id;
let endpoint = if self.client.config().is_cloud {
format!("/pages/{page_id}/labels")
} else {
format!("/content/{page_id}/label")
};
match self.client.get::<LabelListResponse>(&endpoint).await {
Ok(result) => {
if result.results.is_empty() {
return "No labels found on this page.".to_string();
}
let label_list: Vec<&str> =
result.results.iter().map(|l| l.name.as_str()).collect();
format!("Labels on page {page_id}: {}", label_list.join(", "))
}
Err(e) => format!("Error getting labels: {e}"),
}
}
#[tool(description = "Get the outline/structure of a Confluence page (headings only). Use this first to understand large pages before reading specific sections.")]
async fn read_page_outline(
&self,
Parameters(params): Parameters<ReadPageOutlineParams>,
) -> String {
let page_id = ¶ms.page_id;
match fetch_page_body(&self.client, page_id).await {
Ok((title, body)) => {
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
let headings = extract_headings(&body);
if headings.is_empty() {
return 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();
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) => format!("Error getting page outline: {e}"),
}
}
#[tool(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.")]
async fn read_page_section(
&self,
Parameters(params): Parameters<ReadPageSectionParams>,
) -> String {
let page_id = ¶ms.page_id;
match fetch_page_body(&self.client, page_id).await {
Ok((title, body)) => {
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
match extract_section(&body, ¶ms.heading_text, params.heading_level) {
Some(section_html) => {
let markdown = html_to_markdown(§ion_html, false, 0);
format!(
"# {title}\n## Section: {}\n\n{markdown}",
params.heading_text
)
}
None => {
let headings = extract_headings(&body);
let heading_list: Vec<String> =
headings.iter().map(|h| format!(" - {}", h.text)).collect();
format!(
"Section \"{}\" not found in page \"{title}\".\n\nAvailable sections:\n{}",
params.heading_text,
heading_list.join("\n")
)
}
}
}
Err(e) => format!("Error reading page section: {e}"),
}
}
#[tool(description = "Read a page starting from a specific character offset. Use this to continue reading a truncated page.")]
async fn read_page_offset(
&self,
Parameters(params): Parameters<ReadPageOffsetParams>,
) -> String {
let page_id = ¶ms.page_id;
let offset = params.offset.unwrap_or(0) as usize;
let length = params.length.unwrap_or(30000) as usize;
match fetch_page_body(&self.client, page_id).await {
Ok((title, body)) => {
if body.is_empty() {
return 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 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()
};
format!("{header}{chunk}{footer}")
}
Err(e) => format!("Error reading page offset: {e}"),
}
}
#[tool(description = "List all attachments (images, files) on a Confluence page with metadata")]
async fn list_page_attachments(
&self,
Parameters(params): Parameters<ListPageAttachmentsParams>,
) -> String {
let page_id = ¶ms.page_id;
match fetch_attachments(&self.client, page_id).await {
Ok((mut attachments, base_url)) => {
if attachments.is_empty() {
return "No attachments found on this page.".to_string();
}
if let Some(filter) = ¶ms.media_type {
attachments.retain(|a| {
attachment_media_type(a)
.to_lowercase()
.contains(&filter.to_lowercase())
});
if attachments.is_empty() {
return 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()
};
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) => format!("Error listing attachments: {e}"),
}
}
#[tool(description = "Get the direct download URL for a Confluence attachment")]
async fn get_attachment_url(
&self,
Parameters(params): Parameters<GetAttachmentUrlParams>,
) -> String {
let page_id = ¶ms.page_id;
match fetch_attachments(&self.client, page_id).await {
Ok((attachments, base_url)) => {
let attachment = attachments
.iter()
.find(|a| a.title.to_lowercase() == params.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);
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();
format!(
"Attachment \"{}\" not found.\n\nAvailable attachments: {}",
params.filename,
if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
}
)
}
}
}
Err(e) => format!("Error getting attachment URL: {e}"),
}
}
#[tool(description = "Fetch an attachment and return it as base64-encoded data. Useful for images with vision-capable models.")]
async fn get_attachment_base64(
&self,
Parameters(params): Parameters<GetAttachmentBase64Params>,
) -> String {
let page_id = ¶ms.page_id;
let max_size_kb = params.max_size_kb.unwrap_or(500);
match fetch_attachments(&self.client, page_id).await {
Ok((attachments, base_url)) => {
let attachment = attachments
.iter()
.find(|a| a.title.to_lowercase() == params.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 format!(
"Attachment \"{}\" ({}) exceeds the maximum size limit of {}KB.\n\nUse `get_attachment_url` to get the direct URL instead.",
params.filename,
format_bytes(file_size),
max_size_kb,
);
}
let dl = attachment_download_path(att);
if dl.is_empty() {
return format!(
"No download URL found for attachment \"{}\".",
params.filename
);
}
let full_url = if dl.starts_with('/') {
format!("{base_url}{dl}")
} else {
dl
};
match self.client.fetch_binary(&full_url).await {
Ok((data, mime, size)) => {
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) => format!("Error fetching attachment: {e}"),
}
}
None => {
let available: Vec<&str> =
attachments.iter().map(|a| a.title.as_str()).collect();
format!(
"Attachment \"{}\" not found.\n\nAvailable attachments: {}",
params.filename,
if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
}
)
}
}
}
Err(e) => format!("Error fetching attachment: {e}"),
}
}
#[tool(description = "Get the raw Confluence storage format (XML/HTML) of a page. Essential for making precise edits to complex pages with tables, macros, etc.")]
async fn get_page_storage_format(
&self,
Parameters(params): Parameters<GetPageStorageFormatParams>,
) -> String {
let page_id = ¶ms.page_id;
match fetch_page_for_edit(&self.client, page_id).await {
Ok((title, body, version)) => {
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
let output_content = if let Some(sec) = ¶ms.section {
let lower_body = body.to_lowercase();
let lower_sec = sec.to_lowercase();
if let Some(idx) = lower_body.find(&lower_sec) {
let before = &body[..idx];
let after = &body[idx..];
let tr_start = before.rfind("<tr").unwrap_or(0);
let table_start = before.rfind("<table").unwrap_or(0);
let start = tr_start.max(table_start).max(idx.saturating_sub(2000));
let tr_end = after.find("</tr>").map(|p| idx + p + 5);
let end = tr_end.unwrap_or_else(|| (idx + 5000).min(body.len()));
body[start..end].to_string()
} else {
body.clone()
}
} else {
body.clone()
};
let section_note = params
.section
.as_ref()
.map(|s| format!("| **Extracted Section** | Around \"{s}\" |\n"))
.unwrap_or_default();
format!(
"# Storage Format: {title}\n\n| Property | Value |\n|----------|-------|\n| **Page ID** | {page_id} |\n| **Current Version** | {version} |\n| **Content Length** | {} characters |\n{section_note}\n## Raw Storage Format\n\n```xml\n{output_content}\n```\n\n---\n💡 Use `update_page` with this storage format to make edits. Remember to increment the version.",
body.len(),
)
}
Err(e) => format!("Error getting storage format: {e}"),
}
}
#[tool(description = "Update a specific cell in a Confluence table. Finds a row by identifier and updates a specific column.")]
async fn update_release_table_cell(
&self,
Parameters(params): Parameters<UpdateReleaseTableCellParams>,
) -> String {
let page_id = ¶ms.page_id;
let replace_mode = params.replace_mode.as_deref().unwrap_or("replace");
let (title, body, version) = match fetch_page_for_edit(&self.client, page_id).await {
Ok(v) => v,
Err(e) => return format!("Error updating table cell: {e}"),
};
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
let table_re = Regex::new(r"(?is)<table[^>]*>[\s\S]*?</table>").unwrap();
let tables: Vec<&str> = table_re.find_iter(&body).map(|m| m.as_str()).collect();
if tables.is_empty() {
return format!("No tables found in page \"{title}\".");
}
let mut updated_content = body.clone();
let mut cell_found = false;
let mut cell_updated = false;
let col_lower = params.column_identifier.to_lowercase();
let row_lower = params.row_identifier.to_lowercase();
for table in &tables {
if !table.to_lowercase().contains(&col_lower) {
continue;
}
let row_re = Regex::new(r"(?is)<tr[^>]*>[\s\S]*?</tr>").unwrap();
let rows: Vec<&str> = row_re.find_iter(table).map(|m| m.as_str()).collect();
if rows.is_empty() {
continue;
}
let header_cell_re = Regex::new(r"(?is)<t[hd][^>]*>[\s\S]*?</t[hd]>").unwrap();
let header_cells: Vec<&str> = header_cell_re
.find_iter(rows[0])
.map(|m| m.as_str())
.collect();
let col_idx = header_cells
.iter()
.position(|c| c.to_lowercase().contains(&col_lower));
let col_idx = match col_idx {
Some(i) => i,
None => continue,
};
for row in rows.iter().skip(1) {
if !row.to_lowercase().contains(&row_lower) {
continue;
}
cell_found = true;
let td_re = Regex::new(r"(?is)<td([^>]*)>([\s\S]*?)</td>").unwrap();
let cells: Vec<regex::Captures> = td_re.captures_iter(row).collect();
if col_idx >= cells.len() {
continue;
}
let target_cap = &cells[col_idx];
let full_cell = target_cap.get(0).unwrap().as_str();
let attrs = &target_cap[1];
let existing = &target_cap[2];
let final_content = match replace_mode {
"append" => format!("{existing}{}", params.new_content),
"prepend" => format!("{}{existing}", params.new_content),
_ => params.new_content.clone(),
};
let new_cell = format!("<td{attrs}>{final_content}</td>");
let new_row = row.replace(full_cell, &new_cell);
let new_table = table.replace(*row, &new_row);
updated_content = updated_content.replace(*table, &new_table);
cell_updated = true;
break;
}
if cell_updated {
break;
}
}
if !cell_found {
return format!(
"Could not find a row containing \"{}\" with column \"{}\" in page \"{title}\".\n\nTip: Use `get_page_storage_format` to inspect the page structure.",
params.row_identifier, params.column_identifier
);
}
if !cell_updated {
return "Found the row but could not update the cell. The table structure may be complex.\n\nTip: Use `get_page_storage_format` to inspect the exact structure and use `update_page` directly.".to_string();
}
let new_version = version + 1;
let msg = format!(
"Updated {} column for {}",
params.column_identifier, params.row_identifier
);
match save_page(
&self.client,
page_id,
&title,
&updated_content,
new_version,
&msg,
)
.await
{
Ok(()) => format!(
"✅ Successfully updated the \"{}\" column for row \"{}\" in page \"{title}\".\n\n**New Version**: {new_version}",
params.column_identifier, params.row_identifier
),
Err(e) => format!("Error updating table cell: {e}"),
}
}
#[tool(
description = "Find and replace text/patterns in a Confluence page storage format."
)]
async fn find_replace_in_page(
&self,
Parameters(params): Parameters<FindReplaceInPageParams>,
) -> String {
let page_id = ¶ms.page_id;
let is_regex = params.is_regex.unwrap_or(false);
let global_replace = params.global_replace.unwrap_or(false);
let (title, body, version) = match fetch_page_for_edit(&self.client, page_id).await {
Ok(v) => v,
Err(e) => return format!("Error in find/replace: {e}"),
};
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
let match_count = if is_regex {
match Regex::new(¶ms.find_pattern) {
Ok(re) => re.find_iter(&body).count(),
Err(e) => return format!("Invalid regex: {e}"),
}
} else {
body.matches(&*params.find_pattern).count()
};
if match_count == 0 {
return format!(
"Pattern \"{}\" not found in page \"{title}\".",
params.find_pattern
);
}
let updated_content = if is_regex {
let re = Regex::new(¶ms.find_pattern).unwrap();
if global_replace {
re.replace_all(&body, &*params.replace_with).to_string()
} else {
re.replace(&body, &*params.replace_with).to_string()
}
} else if global_replace {
body.replace(&*params.find_pattern, ¶ms.replace_with)
} else {
body.replacen(&*params.find_pattern, ¶ms.replace_with, 1)
};
let replacement_count = if global_replace { match_count } else { 1 };
let new_version = version + 1;
let truncated_pattern = if params.find_pattern.len() > 50 {
format!("{}...", ¶ms.find_pattern[..50])
} else {
params.find_pattern.clone()
};
let msg = format!("Find/replace: \"{truncated_pattern}\"");
match save_page(
&self.client,
page_id,
&title,
&updated_content,
new_version,
&msg,
)
.await
{
Ok(()) => format!(
"✅ Successfully replaced {replacement_count} occurrence(s) in page \"{title}\".\n\n**New Version**: {new_version}"
),
Err(e) => format!("Error in find/replace: {e}"),
}
}
#[tool(description = "Insert a new row into a Confluence table.")]
async fn insert_table_row(
&self,
Parameters(params): Parameters<InsertTableRowParams>,
) -> String {
let page_id = ¶ms.page_id;
let (title, body, version) = match fetch_page_for_edit(&self.client, page_id).await {
Ok(v) => v,
Err(e) => return format!("Error inserting table row: {e}"),
};
if body.is_empty() {
return format!("Page \"{title}\" has no content.");
}
let table_re = Regex::new(r"(?is)<table[^>]*>[\s\S]*?</table>").unwrap();
let tables: Vec<&str> = table_re.find_iter(&body).map(|m| m.as_str()).collect();
if tables.is_empty() {
return format!("No tables found in page \"{title}\".");
}
let table_lower = params.table_identifier.to_lowercase();
let target_table = tables
.iter()
.find(|t| t.to_lowercase().contains(&table_lower));
let target_table = match target_table {
Some(t) => *t,
None => {
return format!(
"Could not find a table containing \"{}\" in page \"{title}\".",
params.table_identifier
);
}
};
let updated_table = if let Some(after_row) = ¶ms.after_row_identifier {
let row_re = Regex::new(r"(?is)<tr[^>]*>[\s\S]*?</tr>").unwrap();
let rows: Vec<&str> = row_re
.find_iter(target_table)
.map(|m| m.as_str())
.collect();
let after_lower = after_row.to_lowercase();
let mut insert_after_idx = None;
for (i, row) in rows.iter().enumerate() {
if row.to_lowercase().contains(&after_lower) {
insert_after_idx = Some(i);
}
}
match insert_after_idx {
Some(idx) => {
let target_row = rows[idx];
target_table
.replace(target_row, &format!("{target_row}{}", params.row_content))
}
None => {
return format!(
"Could not find a row containing \"{after_row}\" in the table."
);
}
}
} else if let Some(tbody_end) = target_table.rfind("</tbody>") {
format!(
"{}{}{}",
&target_table[..tbody_end],
params.row_content,
&target_table[tbody_end..]
)
} else if let Some(table_end) = target_table.rfind("</table>") {
format!(
"{}{}{}",
&target_table[..table_end],
params.row_content,
&target_table[table_end..]
)
} else {
target_table.to_string()
};
let updated_content = body.replace(target_table, &updated_table);
let new_version = version + 1;
match save_page(
&self.client,
page_id,
&title,
&updated_content,
new_version,
"Added new table row",
)
.await
{
Ok(()) => format!(
"✅ Successfully inserted new row in table in page \"{title}\".\n\n**New Version**: {new_version}"
),
Err(e) => format!("Error inserting table row: {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
}
fn clone_page_v2(p: &ConfluencePage) -> ConfluencePage {
serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap()
}
fn clone_page_v1(p: &ConfluencePageV1) -> ConfluencePageV1 {
serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap()
}
fn format_page_list_v2(pages: &[ConfluencePage], space_key: &str) -> String {
if pages.is_empty() {
return format!("No pages found in space {space_key}.");
}
let formatted: Vec<String> = pages
.iter()
.map(|p| format_page(&AnyPage::V2(clone_page_v2(p))))
.collect();
format!(
"Found {} page(s) in space {space_key}:\n\n{}",
pages.len(),
formatted.join("\n\n---\n\n")
)
}
fn format_page_list_v1(pages: &[ConfluencePageV1], space_key: &str) -> String {
if pages.is_empty() {
return format!("No pages found in space {space_key}.");
}
let formatted: Vec<String> = pages
.iter()
.map(|p| format_page(&AnyPage::V1(clone_page_v1(p))))
.collect();
format!(
"Found {} page(s) in space {space_key}:\n\n{}",
pages.len(),
formatted.join("\n\n---\n\n")
)
}
async fn fetch_page_cloud(
client: &ConfluenceClient,
page_id: Option<&str>,
space_key: Option<&str>,
title: Option<&str>,
include_body: bool,
) -> Result<AnyPage, String> {
if let Some(pid) = page_id {
let body_format = if include_body {
"&body-format=storage"
} else {
""
};
let page: ConfluencePage = client
.get(&format!("/pages/{pid}?{body_format}"))
.await?;
Ok(AnyPage::V2(page))
} else {
let cql = format!(
"space=\"{}\" AND title=\"{}\"",
space_key.unwrap(),
title.unwrap()
);
let search: SearchResult = client
.get_v1(&format!(
"/search?cql={}&limit=1",
urlencoding(&cql)
))
.await?;
if search.results.is_empty() || search.results[0].content.is_none() {
return Err(format!(
"Page \"{}\" not found in space {}.",
title.unwrap(),
space_key.unwrap()
));
}
let found_id = &search.results[0].content.as_ref().unwrap().id;
let body_format = if include_body {
"&body-format=storage"
} else {
""
};
let page: ConfluencePage = client
.get(&format!("/pages/{found_id}?{body_format}"))
.await?;
Ok(AnyPage::V2(page))
}
}
async fn fetch_page_server(
client: &ConfluenceClient,
page_id: Option<&str>,
space_key: Option<&str>,
title: Option<&str>,
include_body: bool,
) -> Result<AnyPage, String> {
let expand = if include_body {
"body.storage,version,ancestors,space"
} else {
"version,ancestors,space"
};
if let Some(pid) = page_id {
let page: ConfluencePageV1 = client
.get(&format!("/content/{pid}?expand={expand}"))
.await?;
Ok(AnyPage::V1(page))
} else {
let result: PageListResponseV1 = client
.get(&format!(
"/content?spaceKey={}&title={}&expand={expand}",
space_key.unwrap(),
urlencoding(title.unwrap())
))
.await?;
if result.results.is_empty() {
return Err(format!(
"Page \"{}\" not found in space {}.",
title.unwrap(),
space_key.unwrap()
));
}
Ok(AnyPage::V1(result.results.into_iter().next().unwrap()))
}
}
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))
}
}
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()
}
async fn fetch_page_for_edit(
client: &ConfluenceClient,
page_id: &str,
) -> Result<(String, String, i64), 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);
let ver = page.version.map_or(1, |v| v.number);
Ok((page.title, body, ver))
} else {
let page: ConfluencePageV1 = client
.get(&format!(
"/content/{page_id}?expand=body.storage,version"
))
.await?;
let body = page
.body
.and_then(|b| b.storage)
.map_or(String::new(), |s| s.value);
let ver = page.version.map_or(1, |v| v.number);
Ok((page.title, body, ver))
}
}
async fn save_page(
client: &ConfluenceClient,
page_id: &str,
title: &str,
content: &str,
version: i64,
message: &str,
) -> Result<(), String> {
if client.config().is_cloud {
let body = serde_json::json!({
"id": page_id,
"status": "current",
"title": title,
"body": {
"representation": "storage",
"value": content,
},
"version": {
"number": version,
"message": message,
}
});
client
.put::<Value>(&format!("/pages/{page_id}"), &body)
.await?;
} else {
let body = serde_json::json!({
"type": "page",
"title": title,
"body": {
"storage": {
"value": content,
"representation": "storage"
}
},
"version": {
"number": version,
"message": message,
}
});
client
.put::<Value>(&format!("/content/{page_id}"), &body)
.await?;
}
Ok(())
}