Skip to content

Commit 6ab219e

Browse files
committed
fix(confluence): use v1 REST endpoint for attachment downloads on Cloud
Confluence Cloud removed the legacy /download/attachments/{id}/{file} endpoint (CHANGE-2735) that an attachment's _links.download still points to; it now returns 401 for API-token / scoped-token auth while metadata endpoints keep working. This breaks confluence_get_page_images and the attachment download tools (0 downloaded, all "Fetch failed"). Resolve attachment download URLs to the v1 REST endpoint /rest/api/content/{cid}/child/attachment/{aid}/download, which still authenticates correctly. Controlled by CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1: unset = auto (v1 on Cloud, legacy link on Server/DC), true = always v1, false = always legacy.
1 parent d8bc786 commit 6ab219e

8 files changed

Lines changed: 217 additions & 9 deletions

File tree

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ MCP_VERY_VERBOSE=true # Enables DEBUG level logging (equivalent to 'mcp-atlass
183183
# Optional: Comma-separated list of Jira project keys to limit searches and other operations to.
184184
#JIRA_PROJECTS_FILTER=PROJ,DEVOPS
185185

186+
# --- Confluence Cloud attachment download workaround ---
187+
# Confluence Cloud removed the legacy /download/attachments/... endpoint that an
188+
# attachment's `_links.download` still points to; it now returns 401 for
189+
# API-token / scoped-token auth (metadata endpoints keep working). Controls
190+
# whether attachment/image downloads use the v1 REST endpoint
191+
# (/rest/api/content/{id}/child/attachment/{aid}/download) instead:
192+
# unset (default) -> auto: v1 on Cloud, legacy link on Server/DC
193+
# true -> always v1
194+
# false -> always the legacy link
195+
#CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1=true
196+
186197
# --- SLA Metrics Configuration ---
187198
# Configure how SLA metrics are calculated for Jira issues.
188199
# Default metrics to calculate (comma-separated).

docs/configuration.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ This guide covers IDE integration, environment variables, and advanced configura
150150
| `MCP_VERBOSE` | Enable verbose logging (`true`/`false`) |
151151
| `MCP_VERY_VERBOSE` | Enable debug logging (`true`/`false`) |
152152
| `MCP_LOGGING_STDOUT` | Log to stdout instead of stderr (`true`/`false`) |
153+
| `CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1` | Download Confluence attachments via the v1 REST endpoint instead of the legacy `/download/` link (removed on Cloud). Unset = auto (v1 on Cloud, legacy on Server/DC); `true`/`false` to force. |
153154
| `ATLASSIAN_OAUTH_PROXY_ENABLE` | Enable OAuth proxy + DCR + `/.well-known/*` routes (`true`/`false`) |
154155
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery metadata |
155156
| `ATLASSIAN_OAUTH_ALLOWED_CLIENT_REDIRECT_URIS` | Comma-separated allowed redirect URI patterns for DCR clients |

docs/tools/confluence-attachments.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Confluence API returns generic MIME types — use the `mediaTypeDescription` fie
8282

8383
Download an attachment from Confluence as an embedded resource.
8484

85+
<Note>On Confluence Cloud, attachment downloads automatically use the v1 REST endpoint (the legacy `/download/` link was removed and returns 401 for API/scoped tokens). Override with `CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1`.</Note>
86+
8587
**Parameters:**
8688

8789
| Parameter | Type | Required | Description |
@@ -120,6 +122,8 @@ Permanently delete an attachment from Confluence.
120122

121123
Get all images attached to a Confluence page as inline image content.
122124

125+
<Note>On Confluence Cloud, images are fetched via the v1 REST endpoint automatically (the legacy `/download/` link was removed and returns 401 for API/scoped tokens). Override with `CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1`.</Note>
126+
123127
**Parameters:**
124128

125129
| Parameter | Type | Required | Description |

docs/troubleshooting.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ Header values containing sensitive information are automatically masked in logs.
141141
```
142142
</Accordion>
143143

144+
<Accordion title="401 Unauthorized — Confluence attachment / image download (Cloud)">
145+
**Symptom:** Reading pages works, but `confluence_get_page_images` returns
146+
`downloaded: 0` (every image `"Fetch failed"`) and `confluence_download_attachment` /
147+
`confluence_download_content_attachments` fail. Logs show
148+
`401 ... /wiki/download/attachments/...`.
149+
150+
**Cause:** Confluence Cloud removed the legacy `/download/attachments/...` endpoint
151+
(changelog [CHANGE-2735](https://developer.atlassian.com/cloud/confluence/changelog/) /
152+
["Deprecation of /download/attachments/ APIs"](https://community.developer.atlassian.com/t/deprecation-of-download-attachments-apis/94448)).
153+
It now returns 401 for API-token / scoped-token auth, while metadata endpoints keep working.
154+
155+
**Fix:** On Cloud this is handled automatically — downloads use the v1 REST endpoint
156+
`/rest/api/content/{id}/child/attachment/{aid}/download`. To force the behaviour, set
157+
`CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1=true` (or `false` to keep the legacy link).
158+
</Accordion>
159+
144160
<Accordion title="401 Unauthorized — Personal Access Token (PAT)">
145161
**Cause:** Invalid PAT or PAT doesn't have required permissions.
146162

src/mcp_atlassian/confluence/attachments.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
import re
56
from pathlib import Path
67
from typing import Any
78

@@ -32,6 +33,70 @@ def _v2_adapter(self) -> ConfluenceV2Adapter | None:
3233
)
3334
return None
3435

36+
def _resolve_attachment_download_url(
37+
self,
38+
download_url: str | None,
39+
attachment_id: str | None = None,
40+
content_id: str | None = None,
41+
) -> str:
42+
"""Resolve an attachment's download URL to an absolute URL.
43+
44+
Confluence Cloud removed the legacy ``/download/attachments/{cid}/{file}``
45+
endpoint that the attachment's ``_links.download`` still points to; it now
46+
returns 401 for API-token / scoped-token auth (while metadata endpoints
47+
keep working). Depending on ``config.attachment_download_use_v1`` this
48+
rewrites the link to the v1 REST endpoint
49+
``/rest/api/content/{cid}/child/attachment/{aid}/download`` (which still
50+
authenticates correctly):
51+
52+
- ``None`` (default): auto — v1 on Cloud, the legacy link on Server/DC.
53+
- ``True``: always use v1.
54+
- ``False``: always use the legacy link.
55+
56+
Args:
57+
download_url: The (possibly relative) download link from the API.
58+
attachment_id: The attachment ID (e.g. ``att123``); required for v1.
59+
content_id: The parent content ID. If omitted, it is parsed from the
60+
legacy download link.
61+
62+
Returns:
63+
An absolute URL to fetch the attachment binary from.
64+
"""
65+
resolved = (
66+
resolve_relative_url(download_url, self.config.url) if download_url else ""
67+
)
68+
use_v1 = self.config.attachment_download_use_v1
69+
if use_v1 is None:
70+
# Auto: Cloud removed the legacy endpoint, so default to v1 there;
71+
# Server/DC keeps the legacy link.
72+
use_v1 = self.config.is_cloud
73+
if not (use_v1 and attachment_id and download_url):
74+
return resolved
75+
76+
if not content_id:
77+
match = re.search(r"/download/attachments/([^/]+)/", download_url)
78+
if not match:
79+
logger.debug(
80+
"Could not derive content_id from download URL %s; "
81+
"falling back to the original link",
82+
download_url,
83+
)
84+
return resolved
85+
content_id = match.group(1)
86+
87+
base = self.config.url.rstrip("/")
88+
v1_url = (
89+
f"{base}/rest/api/content/{content_id}"
90+
f"/child/attachment/{attachment_id}/download"
91+
)
92+
# Preserve only the documented ``version`` query param (if present) so a
93+
# version-specific link keeps that version, without forwarding the other
94+
# legacy params (api / cacheVersion / ...) the v1 endpoint doesn't document.
95+
version_match = re.search(r"[?&]version=([^&]+)", download_url)
96+
if version_match:
97+
v1_url = f"{v1_url}?version={version_match.group(1)}"
98+
return v1_url
99+
35100
def upload_attachment(
36101
self,
37102
content_id: str,
@@ -321,9 +386,11 @@ def download_content_attachments(
321386
safe_filename = Path(attachment.title).name
322387
file_path = target_path / safe_filename
323388

324-
# Prepend base URL if download URL is relative
325-
download_url = resolve_relative_url(
326-
attachment.download_url, self.config.url
389+
# Resolve to an absolute URL (and optionally the v1 endpoint)
390+
download_url = self._resolve_attachment_download_url(
391+
attachment.download_url,
392+
attachment_id=attachment.id,
393+
content_id=content_id,
327394
)
328395

329396
# Download the attachment

src/mcp_atlassian/confluence/config.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass
66
from typing import Literal
77

8-
from ..utils.env import get_custom_headers, is_env_ssl_verify
8+
from ..utils.env import get_custom_headers, is_env_ssl_verify, is_env_truthy
99
from ..utils.oauth import (
1010
BYOAccessTokenOAuthConfig,
1111
OAuthConfig,
@@ -40,6 +40,14 @@ class ConfluenceConfig:
4040
client_key: str | None = None # Client private key file path (.pem)
4141
client_key_password: str | None = None # Password for encrypted private key
4242
timeout: int = 75 # Connection timeout in seconds
43+
# Cloud removed the legacy /download/attachments/... endpoint (CHANGE-2735);
44+
# it now 401s for API-token / scoped-token auth. Controls whether attachment
45+
# downloads use the v1 REST endpoint
46+
# (/rest/api/content/{id}/child/attachment/{aid}/download) instead:
47+
# None -> auto: v1 on Cloud, legacy link on Server/DC (default)
48+
# True -> always v1
49+
# False -> always the legacy link
50+
attachment_download_use_v1: bool | None = None
4351

4452
@property
4553
def is_cloud(self) -> bool:
@@ -187,6 +195,13 @@ def from_env(cls) -> "ConfluenceConfig":
187195
):
188196
timeout = int(os.getenv("CONFLUENCE_TIMEOUT", "75"))
189197

198+
_use_v1_raw = os.getenv("CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1")
199+
attachment_download_use_v1 = (
200+
None
201+
if _use_v1_raw is None
202+
else is_env_truthy("CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1")
203+
)
204+
190205
return cls(
191206
url=url or "",
192207
auth_type=auth_type,
@@ -205,6 +220,7 @@ def from_env(cls) -> "ConfluenceConfig":
205220
client_key=client_key,
206221
client_key_password=client_key_password,
207222
timeout=timeout,
223+
attachment_download_use_v1=attachment_download_use_v1,
208224
)
209225

210226
def is_auth_configured(self) -> bool:

src/mcp_atlassian/servers/confluence.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
fetch_and_encode_attachment,
2222
is_image_attachment,
2323
)
24-
from mcp_atlassian.utils.urls import resolve_relative_url
2524

2625
logger = logging.getLogger(__name__)
2726

@@ -1626,7 +1625,9 @@ async def download_attachment(
16261625
),
16271626
)
16281627

1629-
download_url = resolve_relative_url(download_url, confluence_fetcher.config.url)
1628+
download_url = confluence_fetcher._resolve_attachment_download_url(
1629+
download_url, attachment_id=attachment_id
1630+
)
16301631

16311632
filename = attachment_data.get("title") or attachment_id
16321633
mime_type = (
@@ -1814,8 +1815,10 @@ async def download_content_attachments(
18141815
)
18151816
continue
18161817

1817-
download_url = resolve_relative_url(
1818-
attachment.download_url, confluence_fetcher.config.url
1818+
download_url = confluence_fetcher._resolve_attachment_download_url(
1819+
attachment.download_url,
1820+
attachment_id=attachment.id,
1821+
content_id=content_id,
18191822
)
18201823

18211824
encoded, mime_type, fetched_bytes = fetch_and_encode_attachment(
@@ -2028,7 +2031,11 @@ async def get_page_images(
20282031
failed.append({"filename": filename, "error": "No download URL"})
20292032
continue
20302033

2031-
download_url = resolve_relative_url(download_url, confluence_fetcher.config.url)
2034+
download_url = confluence_fetcher._resolve_attachment_download_url(
2035+
download_url,
2036+
attachment_id=attachment.id,
2037+
content_id=content_id,
2038+
)
20322039

20332040
encoded, _, fetched_bytes = fetch_and_encode_attachment(
20342041
fetch_fn=confluence_fetcher.fetch_attachment_content,

tests/unit/confluence/test_attachments.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from mcp.types import EmbeddedResource, TextContent
1010

1111
from mcp_atlassian.confluence.attachments import AttachmentsMixin
12+
from mcp_atlassian.confluence.config import ConfluenceConfig
1213

1314
# Test scenarios for AttachmentsMixin
1415
#
@@ -1487,3 +1488,88 @@ def test_download_content_attachments_absolute_escape(
14871488
"""download_content_attachments rejects directory escape."""
14881489
with pytest.raises(ValueError, match="Path traversal detected"):
14891490
confluence_mixin.download_content_attachments("12345", "/etc")
1491+
1492+
1493+
class TestResolveAttachmentDownloadUrl:
1494+
"""Tests for AttachmentsMixin._resolve_attachment_download_url.
1495+
1496+
Covers the CONFLUENCE_ATTACHMENT_DOWNLOAD_USE_V1 Cloud workaround: when
1497+
enabled, the (removed) legacy /download/attachments/... link is rewritten to
1498+
the v1 REST endpoint; otherwise the original link is preserved.
1499+
"""
1500+
1501+
def _make_mixin(
1502+
self, *, use_v1: bool | None, url: str = "https://example.atlassian.net/wiki"
1503+
) -> AttachmentsMixin:
1504+
with patch(
1505+
"mcp_atlassian.confluence.attachments.ConfluenceClient.__init__",
1506+
return_value=None,
1507+
):
1508+
mixin = AttachmentsMixin()
1509+
config = ConfluenceConfig(url=url, auth_type="basic")
1510+
config.attachment_download_use_v1 = use_v1
1511+
mixin.config = config
1512+
return mixin
1513+
1514+
def test_v1_enabled_builds_rest_endpoint(self) -> None:
1515+
mixin = self._make_mixin(use_v1=True)
1516+
url = mixin._resolve_attachment_download_url(
1517+
"/download/attachments/123/foo.png?version=1&api=v2",
1518+
attachment_id="att999",
1519+
)
1520+
assert url == (
1521+
"https://example.atlassian.net/wiki"
1522+
"/rest/api/content/123/child/attachment/att999/download?version=1"
1523+
)
1524+
1525+
def test_v1_preserves_only_version_param(self) -> None:
1526+
# The v1 download endpoint documents only ``version``; keep that (so a
1527+
# version-specific link doesn't fall back to latest) and drop the other
1528+
# legacy query params (cacheVersion / api / ...).
1529+
mixin = self._make_mixin(use_v1=True)
1530+
url = mixin._resolve_attachment_download_url(
1531+
"/download/attachments/123/foo.png?version=3&cacheVersion=1&api=v2",
1532+
attachment_id="att999",
1533+
)
1534+
assert url.endswith("/att999/download?version=3")
1535+
assert "cacheVersion" not in url
1536+
assert "api=v2" not in url
1537+
1538+
def test_v1_enabled_uses_explicit_content_id(self) -> None:
1539+
mixin = self._make_mixin(use_v1=True)
1540+
url = mixin._resolve_attachment_download_url(
1541+
"/download/attachments/123/foo.png",
1542+
attachment_id="att999",
1543+
content_id="555",
1544+
)
1545+
assert "/rest/api/content/555/child/attachment/att999/download" in url
1546+
1547+
def test_disabled_returns_legacy_link(self) -> None:
1548+
mixin = self._make_mixin(use_v1=False)
1549+
url = mixin._resolve_attachment_download_url(
1550+
"/download/attachments/123/foo.png", attachment_id="att999"
1551+
)
1552+
assert "/download/attachments/123/" in url
1553+
assert "/child/attachment/" not in url
1554+
1555+
def test_missing_attachment_id_falls_back_to_legacy(self) -> None:
1556+
mixin = self._make_mixin(use_v1=True)
1557+
url = mixin._resolve_attachment_download_url(
1558+
"/download/attachments/123/foo.png", attachment_id=None
1559+
)
1560+
assert "/download/attachments/123/" in url
1561+
assert "/child/attachment/" not in url
1562+
1563+
def test_auto_cloud_uses_v1(self) -> None:
1564+
mixin = self._make_mixin(use_v1=None)
1565+
url = mixin._resolve_attachment_download_url(
1566+
"/download/attachments/123/foo.png", attachment_id="att999"
1567+
)
1568+
assert "/rest/api/content/123/child/attachment/att999/download" in url
1569+
1570+
def test_auto_server_dc_returns_legacy(self) -> None:
1571+
mixin = self._make_mixin(use_v1=None, url="https://confluence.example.com")
1572+
url = mixin._resolve_attachment_download_url(
1573+
"/download/attachments/123/foo.png", attachment_id="att999"
1574+
)
1575+
assert "/child/attachment/" not in url

0 commit comments

Comments
 (0)