mcp-atlassian-attachments
by alyiox
README.md
# MCP Atlassian Attachments
[](https://github.com/alyiox/mcp-atlassian-attachments/actions/workflows/ci.yml)
[](https://pypi.org/project/mcp-atlassian-attachments/)
[](https://www.python.org/downloads/)
[](LICENSE)
<!-- mcp-name: io.github.alyiox/mcp-atlassian-attachments -->
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for downloading Jira Cloud attachments by attachment ID to local disk, and uploading local files as attachments to a Jira issue.
> **Supplement to the official Atlassian MCP server.**
> The [official Atlassian MCP server](https://mcp.atlassian.com/v1/mcp) covers search, issue management, and content operations but does not move attachment files between Jira and local disk. This server fills that gap.
**Requirements:** Python 3.13+, an Atlassian Cloud account, and an API token with at least the `read:jira-work` scope. Uploading also needs `write:jira-work`.
## Authentication
Scoped tokens are recommended to limit access to exactly the permissions needed.
> **Note:** The granular `read:attachment:jira` scope is not sufficient — Jira's attachment metadata endpoint (`/rest/api/3/attachment/{id}`) requires `read:jira-work` to resolve issue-level permissions. A classic (unscoped) API token also works.
### Create an API token
1. Go to [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
2. Click **Create API token**
3. Choose **"Create API token with scopes"** and select `read:jira-work` (add `write:jira-work` to upload), **or** choose **"Classic API token"** for full access
4. Copy the generated token
## Quick start
The fastest way to try the server is with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). Set the three required environment variables and run:
**From the published package** (no clone needed):
```bash
ATLASSIAN_SITE_URL=https://yourorg.atlassian.net \
ATLASSIAN_EMAIL=your.email@example.com \
ATLASSIAN_API_TOKEN=your-api-token \
npx -y @modelcontextprotocol/inspector uvx mcp-atlassian-attachments
```
**From a local clone:**
```bash
ATLASSIAN_SITE_URL=https://yourorg.atlassian.net \
ATLASSIAN_EMAIL=your.email@example.com \
ATLASSIAN_API_TOKEN=your-api-token \
npx -y @modelcontextprotocol/inspector uv run mcp-atlassian-attachments
```
## Configuration
Set environment variables or create a config file. Environment variables take priority.
**Environment variables:**
```bash
export ATLASSIAN_SITE_URL="https://yourorg.atlassian.net"
export ATLASSIAN_EMAIL="your.email@example.com"
export ATLASSIAN_API_TOKEN="your-api-token"
```
**Config file** (`~/.config/mcp-atlassian-attachments/config.json`):
```json
{
"site_url": "https://yourorg.atlassian.net",
"email": "your.email@example.com",
"api_token": "your-api-token"
}
```
## Tools
| Tool | Description | Required params |
|------|-------------|-----------------|
| **`download_jira_attachment`** | Download a Jira attachment by ID. | `attachment_id`, `output_dir` |
| **`upload_jira_attachment`** | Upload a local file as an attachment on a Jira issue. | `issue_key`, `file_path` |
| **`get_jira_attachment_reference`** | Resolve an existing attachment into an ADF media node. | `attachment_id` |
| **`delete_jira_attachment`** | Permanently delete an attachment by ID. | `attachment_id` |
### `download_jira_attachment`
| Parameter | Type | Description |
|-----------|------|-------------|
| `attachment_id` | string | Jira attachment ID |
| `output_dir` | string | Local directory for the saved file. Created automatically if it does not exist. |
| `filename` | string (optional) | Override filename. Uses metadata filename when omitted. |
| `overwrite` | bool (optional) | Replace an existing file. Defaults to `false`. |
Returns:
```json
{
"product": "jira",
"attachmentId": "439535",
"filename": "screenshot.png",
"mimeType": "image/png",
"size": 496724,
"path": "/your/output/dir/screenshot.png",
"attachmentUrl": "https://yourorg.atlassian.net/rest/api/3/attachment/content/439535"
}
```
### `upload_jira_attachment`
| Parameter | Type | Description |
|-----------|------|-------------|
| `issue_key` | string | Jira issue key or ID, for example `PROJ-123` |
| `file_path` | string | Path to the local file to upload. Must exist. |
| `filename` | string (optional) | Name to store in Jira. Uses the local filename when omitted. |
Returns:
```json
{
"product": "jira",
"issueKey": "PROJ-123",
"attachmentId": "600100",
"filename": "report.png",
"mimeType": "image/png",
"size": 24581,
"path": "/your/local/dir/report.png",
"attachmentUrl": "https://yourorg.atlassian.net/rest/api/3/attachment/content/600100",
"mediaId": "6df94659-caa7-4561-bbe3-6384ec534bba"
}
```
Each call adds a new attachment; Jira does not replace a same-named file. Uploads larger than the site attachment size limit fail with a `413`.
### `get_jira_attachment_reference`
| Parameter | Type | Description |
|-----------|------|-------------|
| `attachment_id` | string | Jira attachment ID |
Read-only. Returns `mediaId` for an attachment that is **already** on an issue:
```json
{
"product": "jira",
"attachmentId": "439535",
"filename": "screenshot.png",
"mimeType": "image/png",
"size": 496724,
"attachmentUrl": "https://yourorg.atlassian.net/rest/api/3/attachment/content/439535",
"mediaId": "6df94659-caa7-4561-bbe3-6384ec534bba"
}
```
After an upload you do **not** need this tool — `upload_jira_attachment` already returns `mediaId`, so embedding costs no extra call.
### `delete_jira_attachment`
| Parameter | Type | Description |
|-----------|------|-------------|
| `attachment_id` | string | Jira attachment ID to delete permanently |
Needs `write:jira-work`. Metadata is read before the delete, so the result reports what was removed and a missing ID fails without attempting anything:
```json
{
"product": "jira",
"attachmentId": "481718",
"filename": "delete-me.txt",
"mimeType": "text/plain",
"size": 31,
"deleted": true
}
```
> **There is no undo.** Jira deletes the file outright, and a second call to the same ID fails with `404`.
>
> **Deleting does not clean up references.** If the attachment was embedded in a description or comment, the ADF media node stays exactly where it was and becomes a dangling reference — verified against a live issue. Remove the node yourself if you delete a file that was referenced.
## Referencing an attachment in a description or comment
Uploading a file attaches it, but nothing appears inline in the description or a comment. Rendering it requires an [ADF](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) media node — and that node identifies the file by its **Media Services UUID**, not by the numeric attachment ID:
```json
{
"type": "mediaSingle",
"attrs": { "layout": "center" },
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "6df94659-caa7-4561-bbe3-6384ec534bba",
"collection": "",
"alt": "report.png"
}
}
]
}
```
No Jira attachment metadata endpoint exposes that UUID, which is why this server resolves it: `GET /rest/api/3/attachment/content/{id}` answers with a `303` to `https://api.media.atlassian.com/file/{mediaId}/binary`, so reading the `Location` header without following the redirect yields the UUID at no download cost. The UUID exists as soon as the file is attached — it does not depend on the attachment being referenced anywhere.
Both tools therefore return `mediaId`. Writing the node into the issue is deliberately left to a Jira content tool such as the official Atlassian MCP server, whose `addCommentToJiraIssue` and `editJiraIssue` accept `contentFormat: "adf"` — this server moves files and does not edit issue content:
1. `upload_jira_attachment` → take `mediaId` from the result
2. Post a comment with an ADF body wrapping a media node built from it:
```json
{
"version": 1,
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Latest run:" }] },
{ "type": "mediaSingle", "attrs": { "layout": "center" }, "content": [ "...the media node..." ] }
]
}
```
For a description, read the existing ADF, append the node, and write the whole document back — `PUT /rest/api/3/issue/{key}` replaces the field rather than appending to it.
Notes:
- A bare `media` node is not rendered; it must be wrapped in `mediaSingle` (single file), `mediaGroup` (file-card list), or `mediaInline` (inline chip).
- `collection` is `""` for Jira issue attachments.
- Optional `width`/`height` on the media node set the intrinsic pixel size, and are only honoured inside `mediaSingle`.
- `mediaId` is best-effort: if the redirect cannot be read it comes back `null`, and the upload itself still succeeds.
### Simpler alternative: v2 wiki markup
The older v2 endpoints still accept wiki markup and convert it to ADF server-side, resolving the attachment **by filename** — so no media UUID is needed:
```bash
POST /rest/api/2/issue/PROJ-123/comment
{ "body": "See attached: !report.png|thumbnail!" }
```
Jira stores that as a proper `media` node with the correct UUID filled in. Confirmed working on Jira Cloud as of August 2026.
Two behaviours to know about, both verified against a live issue:
- **Dimensions are a fixed placeholder, not the real ones.** The conversion always writes `width: 200, height: 183` regardless of the image — a 64x48 and an 8x8 PNG both came back as 200x183. `!file|width=300!` sets the width but leaves height at 183, so the aspect ratio is wrong unless you write the ADF yourself.
- **Duplicate filenames resolve to the oldest.** With two attachments both named `ambiguous.png`, `!ambiguous.png!` resolved to the *first* one uploaded. Re-uploading under the same name to "update" an image will keep showing the old file.
Prefer `mediaId` when you want to be explicit: it is unambiguous with duplicate names, it lets you set true dimensions, it works with v3/ADF (which is what the official Atlassian MCP server writes), and it does not depend on a legacy path Atlassian may eventually retire.
## MCP host configuration
Add the following to your MCP host's config file. The JSON is the same for Cursor (`.cursor/mcp.json`), Claude Desktop (`claude_desktop_config.json`), and Claude Code (`.claude.json`).
```json
{
"mcpServers": {
"atlassian": {
"command": "uvx",
"args": ["mcp-atlassian-attachments"],
"env": {
"ATLASSIAN_SITE_URL": "https://yourorg.atlassian.net",
"ATLASSIAN_EMAIL": "your.email@example.com",
"ATLASSIAN_API_TOKEN": "your-api-token"
}
}
}
}
```
## Security
- `ATLASSIAN_API_TOKEN` is never logged or included in error messages.
- The computed `Authorization` header is never exposed in tool output or errors.
- File writes are confined to the provided `output_dir`.
- Filenames are sanitized to prevent path traversal, both on download and on the name sent to Jira.
- Uploads read exactly the file at `file_path` and send nothing else.
## Tests
```bash
uv run pytest tests/ -v
```
## Roadmap
- **`download_confluence_attachment_tool`** — Confluence Cloud uses a different API (`/wiki/api/v2/`) and a different identifier model. Planned for a future release.
## License
MIT. See [LICENSE](LICENSE).
TDQS
A3.5/5.0
Scored across 1 tool
Disambiguation5/5
With only one tool, there is no possibility of confusion between tools.
Naming Consistency5/5
The single tool uses a clear verb_noun pattern (download_jira_attachment), consistent with good naming conventions.
Tool Count2/5
The server is named 'attachments' but provides only a download tool; this is far too few for the implied scope.
Completeness2/5
Missing fundamental operations like upload, delete, or list attachments, leaving agents with a dead-end workflow.
Maintenance
ActivityMaintained
ResponsivenessNo issues