arcade-mcp/toolkits/confluence/arcade_confluence/client.py
Eric Gustin 6bc6f0c574
Add Confluence toolkit (#406)
12 tools for the new Confluence toolkit.

| Name | Description |

|----------------------------------|------------------------------------------------------------------------------------|
| Confluence.CreatePage | Create a new page at the root of the given
space. |
| Confluence.UpdatePageContent | Update a page's content. |
| Confluence.RenamePage | Rename a page by changing its title. |
| Confluence.GetPage | Retrieve a SINGLE page's content by its ID or
title. |
| Confluence.GetPagesById | Get the content of MULTIPLE pages by their
ID in a single efficient request. |
| Confluence.ListPages | Get the content of multiple pages by their ID.
|
| Confluence.ListAttachments | List attachments in a workspace. |
| Confluence.GetAttachmentsForPage | Get attachments for a page by its
ID or title. |
| Confluence.SearchContent | Search for content in Confluence. |
| Confluence.GetSpace | Get the details of a space by its ID or key. |
| Confluence.ListSpaces | List all spaces sorted by name in ascending
order. |
| Confluence.GetSpaceHierarchy | Retrieve the full hierarchical
structure of a Confluence space as a tree structure.|

### Confluence Clients
Confluence has deprecated most of their V1 endpoints, so most of the
tools use V2. However, we still need a V1 client to support search. The
V1 search API has not been deprecated yet, because there is no V2
equivalent. But we need to be aware in the future for when this
deprecation happens.


### Future work
* Content of pages are returned in the Confluence `storage` format. This
is the format that Confluence uses to store pages internally. We should
understand the storage format more deeply, and write utility function to
transform this format into plain text.
* Better protections against extremely large pages. I've tested up to
6,000 word pages.
* Tools for blog posts
* Tool for getting the children of a page. (`get_space_hierarchy` will
suffice for now)
* Allow for numerical titles
2025-05-22 13:18:19 -07:00

614 lines
22 KiB
Python

from enum import Enum
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
from arcade.sdk.errors import ToolExecutionError
from arcade_confluence.enums import BodyFormat, PageUpdateMode
from arcade_confluence.utils import (
build_child_url,
build_hierarchy,
remove_none_values,
)
class ConfluenceAPIVersion(str, Enum):
V1 = "wiki/rest/api"
V2 = "wiki/api/v2"
class ConfluenceClient:
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
BASE_URL = "https://api.atlassian.com/ex/confluence"
def __init__(self, token: str, api_version: ConfluenceAPIVersion):
self.token = token
self.cloud_id = self._get_cloud_id()
self.api_version = api_version.value
def _get_cloud_id(self) -> str:
"""
Fetch the cloudId for <workspace_name>.atlassian.net
using the OAuth2 3LO accessible-resources endpoint.
For details on why this is necessary, see: https://developer.atlassian.com/cloud/oauth/getting-started/making-calls-to-api
"""
headers = {"Authorization": f"Bearer {self.token}"}
resp = httpx.get(self.ACCESSIBLE_RESOURCES_URL, headers=headers)
resp.raise_for_status()
resp_json = resp.json()
if len(resp_json) == 0:
raise ToolExecutionError(message="No workspaces found for the authenticated user.")
return resp_json[0].get("id") # type: ignore[no-any-return]
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.token}",
}
async with httpx.AsyncClient() as client:
response = await client.request(
method,
f"{self.BASE_URL}/{self.cloud_id}/{self.api_version}/{path.lstrip('/')}",
headers=headers,
**kwargs,
)
response.raise_for_status()
return response.json()
async def get(self, path: str, **kwargs: Any) -> Any:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs: Any) -> Any:
return await self.request("POST", path, **kwargs)
async def put(self, path: str, **kwargs: Any) -> Any:
return await self.request("PUT", path, **kwargs)
class ConfluenceClientV1(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V1)
def _build_query_cql(self, query: str, enable_fuzzy: bool) -> str:
"""Build CQL for a single query (term or phrase).
Args:
query: The search query (single word term or multi-word phrase)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string for the query
"""
query = query.strip()
if not query:
return ""
# For phrases (multiple words), don't use fuzzy matching
if " " in query:
return f'(text ~ "{query}" OR title ~ "{query}" OR space.title ~ "{query}")'
else:
# For single terms, optionally use fuzzy matching
term_suffix = "~" if enable_fuzzy else ""
return f'(text ~ "{query}{term_suffix}" OR title ~ "{query}{term_suffix}" OR space.title ~ "{query}{term_suffix}")' # noqa: E501
def _build_and_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries that must ALL be present (AND logic).
Args:
queries: List of queries that must all be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with AND logic
"""
and_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
and_parts.append(query_cql)
if not and_parts:
return ""
return f"({' AND '.join(and_parts)})"
def _build_or_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries where ANY can be present (OR logic).
Args:
queries: List of queries where any can be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with OR logic
"""
or_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
or_parts.append(query_cql)
if not or_parts:
return ""
return f"({' OR '.join(or_parts)})"
def construct_cql(
self,
must_contain_all: list[str] | None,
can_contain_any: list[str] | None,
enable_fuzzy: bool = False,
) -> str:
"""Construct CQL query with AND/OR logic.
Learn about advanced searching using CQL here: https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/
Args:
must_contain_all: Queries that must ALL be present (AND logic)
can_contain_any: Queries where ANY can be present (OR logic)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL query string
Raises:
ToolExecutionError: If no search parameters are provided
"""
cql_parts = []
# Handle must_contain_all (AND logic)
if must_contain_all:
and_cql = self._build_and_cql(must_contain_all, enable_fuzzy)
if and_cql:
cql_parts.append(and_cql)
# Handle can_contain_any (OR logic)
if can_contain_any:
or_cql = self._build_or_cql(can_contain_any, enable_fuzzy)
if or_cql:
cql_parts.append(or_cql)
# If there's only one part, return it
if len(cql_parts) == 1:
return cql_parts[0]
# AND the must_contain_all with the can_contain_any
if len(cql_parts) > 1:
return f"({' AND '.join(cql_parts)})"
raise ToolExecutionError(message="At least one search parameter must be provided")
def transform_search_content_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /search endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
base_url = response.get("_links", {}).get("base", "")
transformed_results = []
for result in response.get("results", []):
content = result.get("content", {})
transformed_result = {
"id": content.get("id"),
"title": content.get("title"),
"type": content.get("type"),
"status": content.get("status"),
"excerpt": result.get("excerpt"),
"url": f"{base_url}{result.get('url')}",
}
transformed_results.append(transformed_result)
return {"results": transformed_results}
class ConfluenceClientV2(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V2)
def _transform_links(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, Any]:
"""
Transform the links in a page response by converting relative URLs to absolute URLs.
Args:
response: A page object from the API
base_url: The base URL to use for the transformation
Returns:
The transformed response
"""
result = response.copy()
if "_links" in result:
base_url = base_url or result["_links"].get("base", "")
webui_path = result["_links"].get("webui", "")
result["url"] = f"{base_url}{webui_path}"
del result["_links"]
return result
def transform_get_spaces_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /spaces endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor", [None]
)[0]
base_url = response.get("_links", {}).get("base", "")
results = response.get("results", [])
transformed_results = []
for space in results:
space_copy = space.copy()
if "_links" in space_copy and "webui" in space_copy["_links"]:
webui_path = space_copy["_links"]["webui"]
space_copy["url"] = base_url + webui_path
del space_copy["_links"]
transformed_results.append(space_copy)
results = {"spaces": transformed_results, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_list_pages_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor", [None]
)[0]
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
results = {"pages": pages, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_get_multiple_pages_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""Transform the response from the GET /pages endpoint."""
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
return {"pages": pages}
def transform_space_response(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a space object."""
return {"space": self._transform_links(response, base_url)}
def transform_page_response(self, response: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a page object."""
return {"page": self._transform_links(response)}
def transform_get_attachments_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages/{id}/attachments endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor", [None]
)[0]
base_url = response.get("_links", {}).get("base", "")
attachments = []
for attachment in response["results"]:
result = attachment.copy()
if "_links" in result:
webui_path = result["_links"].get("webui", "")
download_path = result["_links"].get("download", "")
result["url"] = f"{base_url}{webui_path}"
result["download_link"] = f"{base_url}{download_path}"
del result["_links"]
del result["webuiLink"]
del result["downloadLink"]
del result["version"]
attachments.append(result)
return {"attachments": attachments, "pagination_token": pagination_token}
def prepare_update_page_payload(
self,
page_id: str,
status: str,
title: str,
body_representation: str,
body_value: str,
version_number: int,
version_message: str,
) -> dict[str, Any]:
"""Prepare a payload for the PUT /pages/{id} endpoint."""
return {
"id": page_id,
"status": status,
"title": title,
"body": {
"representation": body_representation,
"value": body_value,
},
"version": {
"number": version_number,
"message": version_message,
},
}
def prepare_update_page_content_payload(
self,
content: str,
update_mode: PageUpdateMode,
old_content: str,
page_id: str,
status: str,
title: str,
body_representation: BodyFormat,
old_version_number: int,
) -> dict[str, Any]:
"""Prepare a payload for when updating the content of a page
Args:
content: The content to update the page with
update_mode: The mode of update to use
old_content: The content of the page before the update
page_id: The ID of the page to update
status: The status of the page
title: The title of the page
body_representation: The format that the body (content) is in
old_version_number: The version number of the page before the update
Returns:
A payload for the PUT /pages/{id} endpoint's json body
"""
updated_content = ""
updated_message = ""
if update_mode == PageUpdateMode.APPEND:
updated_content = f"{old_content}<br/>{content}"
updated_message = "Append content to the page"
elif update_mode == PageUpdateMode.PREPEND:
updated_content = f"{content}<br/>{old_content}"
updated_message = "Prepend content to the page"
elif update_mode == PageUpdateMode.REPLACE:
updated_content = content
updated_message = "Replace the page content"
payload = self.prepare_update_page_payload(
page_id=page_id,
status=status,
title=title,
body_representation=body_representation.to_api_value(),
body_value=updated_content,
version_number=old_version_number + 1,
version_message=updated_message,
)
return payload
async def get_root_pages_in_space(self, space_id: str) -> dict[str, Any]:
"""
Get the root pages in a space.
Requires Confluence scope 'read:page:confluence'
"""
params = {
"depth": "root",
"limit": 250,
}
pages = await self.get(f"spaces/{space_id}/pages", params=params)
base_url = pages.get("_links", {}).get("base", "")
return {"pages": [self._transform_links(page, base_url) for page in pages["results"]]}
async def get_space_homepage(self, space_id: str) -> dict[str, Any]:
"""
Get the homepage of a space.
Requires Confluence scope 'read:page:confluence'
"""
root_pages = await self.get_root_pages_in_space(space_id)
for page in root_pages["pages"]:
if page.get("url", "").endswith("overview"):
return self._transform_links(page)
raise ToolExecutionError(message="No homepage found for space.")
async def get_page_by_id(
self, page_id: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its ID.
Requires Confluence scope 'read:page:confluence'
Args:
page_id: The ID of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = remove_none_values({
"body-format": content_format.to_api_value(),
})
try:
page = await self.get(f"pages/{page_id}", params=params)
except httpx.HTTPStatusError as e:
# If the page is not found, return an empty page object
if e.response.status_code in [400, 404]:
return self.transform_page_response({})
raise
return self.transform_page_response(page)
async def get_page_by_title(
self, page_title: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its title.
Requires Confluence scope 'read:page:confluence'
Args:
page_title: The title of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = {
"title": page_title,
"body-format": content_format.to_api_value(),
}
response = await self.get("pages", params=params)
pages = response.get("results", [])
if not pages:
# If the page is not found, return an empty page object
return self.transform_page_response({})
return self.transform_page_response(pages[0])
async def get_space_by_id(self, space_id: str) -> dict[str, Any]:
"""Get a space by its ID.
Requires Confluence scope 'read:space:confluence'
Args:
space_id: The ID of the space to get
Returns:
The space object
"""
space = await self.get(f"spaces/{space_id}")
return self.transform_space_response(space)
async def get_space_by_key(self, space_key: str) -> dict[str, Any]:
"""Get a space by its key.
Requires Confluence scope 'read:space:confluence'
Args:
space_key: The key of the space to get
Returns:
The space object
"""
response = await self.get("spaces", params={"keys": [space_key]})
base_url = response.get("_links", {}).get("base", "")
spaces = response.get("results", [])
if not spaces:
raise ToolExecutionError(message=f"No space found with key: '{space_key}'")
return self.transform_space_response(spaces[0], base_url=base_url)
async def get_space(self, space_identifier: str) -> dict[str, Any]:
"""Get a space from its identifier.
Requires Confluence scope 'read:space:confluence'
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or key.
"""
return (
await self.get_space_by_id(space_identifier)
if space_identifier.isdigit()
else await self.get_space_by_key(space_identifier)
)
async def get_page_id(self, page_identifier: str) -> str:
"""Get the ID of a page from its identifier.
Args:
page_identifier: The identifier of the page to get. Can be a page's ID or title.
Returns:
The ID of the page
"""
if page_identifier.isdigit():
page_id = page_identifier
else:
page = await self.get_page_by_title(page_identifier)
page_id = page.get("page", {}).get("id")
if not page_id:
raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'")
return page_id
async def get_space_id(self, space_identifier: str) -> str:
"""Get the ID of a space from its identifier.
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or title.
"""
if space_identifier.isdigit():
space_id = space_identifier
else:
space = await self.get_space_by_key(space_identifier)
space_id = space.get("space", {}).get("id")
return space_id
def create_space_tree(self, space: dict) -> dict:
"""Create the initial tree structure for a space.
Args:
space: The transformed space object
Returns:
A dictionary representing the root of the space hierarchy tree without any children
"""
space_internal = space.get("space", {})
return {
"key": space_internal.get("key"),
"id": space_internal.get("id"),
"type": "space",
"url": space_internal.get("url"),
"children": [],
}
def convert_root_pages_to_tree_nodes(self, pages: list) -> list:
"""Convert root pages of a space to tree nodes.
Args:
pages: List of page objects from the API
Returns:
A list of tree nodes representing the root pages
"""
return [
{
"title": page.get("title"),
"id": page.get("id"),
"type": "page",
"url": page.get("url"),
"children": [],
}
for page in pages
]
async def process_page_descendants(self, root_children: list, base_url: str) -> None:
"""Process descendants for each page and build the hierarchy.
Args:
root_children: The root children of the space
base_url: The base URL for the Confluence space
Returns:
None (modifies root_children in place)
"""
descendent_params = {"limit": 250, "depth": 5}
for i, child in enumerate(root_children):
page_id = child["id"]
descendants = await self.get(f"pages/{page_id}/descendants", params=descendent_params)
# Transform descendants into our desired format
transformed_children = []
for descendant in descendants.get("results", []):
transformed_child = {
"title": descendant.get("title"),
"id": descendant.get("id"),
"type": descendant.get("type"),
"parent_id": page_id
if descendant.get("parentId") is None
else descendant.get("parentId"),
"parent_type": descendant.get("parentType", "TODO"),
"url": build_child_url(base_url, descendant),
"children": [],
"status": descendant.get("status"),
}
transformed_children.append(transformed_child)
# Build the hierarchy for the current root page
build_hierarchy(transformed_children, page_id, root_children[i])