Skip to content

Commit 8552aa5

Browse files
author
Dmitry Volodchenkov
committed
feat: add work item attachment tools
Adds four MCP tools wrapping the Plane work-item attachment API: - list_work_item_attachments - create_work_item_attachment - update_work_item_attachment - delete_work_item_attachment The tools follow the existing pattern in plane_mcp/tools/work_item_links.py (flat params at the MCP boundary, Pydantic models from plane-sdk). Closes makeplane#118. Two-step upload flow Plane attachments use a two-step asset flow: 1. POST metadata (name, size, type) -> server returns an attachment record plus an S3 multipart-POST policy in 'upload_data'. 2. Caller posts the file as multipart/form-data to upload_data['url'] using upload_data['fields'] plus a 'file' part. 3. PATCH is_uploaded=true on the attachment to mark it ready. create_work_item_attachment returns a WorkItemAttachmentCreated wrapper exposing both the attachment record and the upload_data so the agent can perform the upload itself, then call update_work_item_attachment with is_uploaded=True. Workarounds for plane-sdk bugs (see makeplane/plane-python-sdk#34) The current pinned plane-sdk (0.2.10) has two attachment bugs we work around in this module: - WorkItemAttachments.create() validates the API wrapper response as a flat WorkItemAttachment, raising ValidationError. We bypass via the inherited _post and validate against our local WorkItemAttachmentCreated wrapper. - WorkItemAttachments.update() calls model_validate on the API's 204 No Content response. We bypass via _patch and then list + filter by id to return the updated record. Both workarounds are documented inline. When the upstream SDK fix lands (PR makeplane/plane-python-sdk#34) and is bumped here, the workarounds can collapse to direct SDK calls. retrieve_work_item_attachment is intentionally not exposed: Plane has no metadata-by-id endpoint for attachments (the GET on a single attachment URL serves a download redirect to S3). Agents that need metadata can use list_work_item_attachments and filter. Tests Extends run_integration_test in tests/test_integration.py with the attachment lifecycle (create -> mark uploaded -> list -> delete) on work_item_1, before the epic step. EXPECTED_TOOLS is updated to include the four attachment tools. Verified end-to-end against a live self-hosted Plane instance: all four attachment operations pass.
1 parent bfd0603 commit 8552aa5

3 files changed

Lines changed: 276 additions & 21 deletions

File tree

plane_mcp/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from plane_mcp.tools.states import register_state_tools
1515
from plane_mcp.tools.users import register_user_tools
1616
from plane_mcp.tools.work_item_activities import register_work_item_activity_tools
17+
from plane_mcp.tools.work_item_attachments import register_work_item_attachment_tools
1718
from plane_mcp.tools.work_item_comments import register_work_item_comment_tools
1819
from plane_mcp.tools.work_item_links import register_work_item_link_tools
1920
from plane_mcp.tools.work_item_properties import register_work_item_property_tools
@@ -29,6 +30,7 @@ def register_tools(mcp: FastMCP) -> None:
2930
register_project_tools(mcp)
3031
register_work_item_tools(mcp)
3132
register_work_item_activity_tools(mcp)
33+
register_work_item_attachment_tools(mcp)
3234
register_work_item_comment_tools(mcp)
3335
register_work_item_link_tools(mcp)
3436
register_work_item_relation_tools(mcp)
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Work item attachment-related tools for Plane MCP Server."""
2+
3+
from typing import Any
4+
5+
from fastmcp import FastMCP
6+
from plane.models.work_items import (
7+
UpdateWorkItemAttachment,
8+
WorkItemAttachment,
9+
WorkItemAttachmentUploadRequest,
10+
)
11+
from pydantic import BaseModel, ConfigDict
12+
13+
from plane_mcp.client import get_plane_client_context
14+
15+
16+
class WorkItemAttachmentCreated(BaseModel):
17+
"""Wrapper response from create_work_item_attachment.
18+
19+
Plane's POST .../issue-attachments/ returns a wrapper containing both the
20+
attachment record and the S3 multipart-POST policy needed to upload the
21+
file bytes. The caller posts the file as multipart/form-data to
22+
``upload_data["url"]`` with the ``upload_data["fields"]`` plus a ``file``
23+
part, then calls ``update_work_item_attachment`` with ``is_uploaded=True``.
24+
"""
25+
26+
model_config = ConfigDict(extra="allow")
27+
28+
attachment: WorkItemAttachment
29+
upload_data: dict[str, Any]
30+
asset_id: str | None = None
31+
asset_url: str | None = None
32+
33+
34+
def register_work_item_attachment_tools(mcp: FastMCP) -> None:
35+
"""Register all work item attachment-related tools with the MCP server."""
36+
37+
@mcp.tool()
38+
def list_work_item_attachments(
39+
project_id: str,
40+
work_item_id: str,
41+
params: dict[str, Any] | None = None,
42+
) -> list[WorkItemAttachment]:
43+
"""
44+
List attachments for a work item.
45+
46+
Args:
47+
project_id: UUID of the project
48+
work_item_id: UUID of the work item
49+
params: Optional query parameters as a dictionary
50+
51+
Returns:
52+
List of WorkItemAttachment objects
53+
"""
54+
client, workspace_slug = get_plane_client_context()
55+
return client.work_items.attachments.list(
56+
workspace_slug=workspace_slug,
57+
project_id=project_id,
58+
work_item_id=work_item_id,
59+
params=params,
60+
)
61+
62+
@mcp.tool()
63+
def create_work_item_attachment(
64+
project_id: str,
65+
work_item_id: str,
66+
name: str,
67+
size: int,
68+
type: str | None = None,
69+
external_id: str | None = None,
70+
external_source: str | None = None,
71+
) -> WorkItemAttachmentCreated:
72+
"""
73+
Register an attachment for a work item and get a presigned upload URL.
74+
75+
Plane attachments use a two-step asset flow. This tool creates the
76+
attachment record on the server and returns:
77+
78+
* ``attachment`` — the created ``WorkItemAttachment`` record (with
79+
``id``, ``asset`` storage path, ``is_uploaded=False``, etc.)
80+
* ``upload_data`` — an S3 multipart-POST policy. The caller posts the
81+
file as ``multipart/form-data`` to ``upload_data["url"]`` with the
82+
``upload_data["fields"]`` plus a ``file`` part.
83+
* ``asset_id`` and ``asset_url`` — convenience identifiers.
84+
85+
After the upload completes, call ``update_work_item_attachment`` with
86+
``is_uploaded=True`` to mark the attachment ready.
87+
88+
Note: this tool calls the underlying ``_post`` directly because the
89+
plane-sdk ``WorkItemAttachments.create()`` method incorrectly
90+
validates the wrapper response as ``WorkItemAttachment``. See
91+
plane-python-sdk for the upstream fix.
92+
93+
Args:
94+
project_id: UUID of the project
95+
work_item_id: UUID of the work item
96+
name: Original filename of the asset
97+
size: File size in bytes
98+
type: MIME type of the file
99+
external_id: External identifier for the asset
100+
external_source: External source system
101+
102+
Returns:
103+
WorkItemAttachmentCreated wrapper with ``attachment`` record and
104+
``upload_data`` S3 policy.
105+
"""
106+
client, workspace_slug = get_plane_client_context()
107+
108+
data = WorkItemAttachmentUploadRequest(
109+
name=name,
110+
size=size,
111+
type=type,
112+
external_id=external_id,
113+
external_source=external_source,
114+
)
115+
116+
raw = client.work_items.attachments._post(
117+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/attachments",
118+
data.model_dump(exclude_none=True),
119+
)
120+
return WorkItemAttachmentCreated.model_validate(raw)
121+
122+
@mcp.tool()
123+
def update_work_item_attachment(
124+
project_id: str,
125+
work_item_id: str,
126+
attachment_id: str,
127+
is_uploaded: bool,
128+
) -> WorkItemAttachment:
129+
"""
130+
Update an attachment for a work item.
131+
132+
Typically used to confirm a successful binary upload by setting
133+
``is_uploaded=True`` after the caller has POSTed the file bytes to
134+
the presigned URL returned by ``create_work_item_attachment``.
135+
136+
Note: Plane responds to attachment PATCH with ``204 No Content`` and
137+
exposes no metadata-by-id endpoint (the GET on a single attachment
138+
URL is a download redirect). To return the updated record this tool
139+
follows the PATCH with a list call and filters by id, mirroring
140+
plane-python-sdk PR #34. Once that PR lands and the SDK pin is
141+
bumped here, this can switch to calling
142+
``client.work_items.attachments.update`` directly.
143+
144+
Args:
145+
project_id: UUID of the project
146+
work_item_id: UUID of the work item
147+
attachment_id: UUID of the attachment
148+
is_uploaded: Mark attachment as uploaded
149+
150+
Returns:
151+
Updated WorkItemAttachment object.
152+
"""
153+
if is_uploaded is not True:
154+
raise ValueError(
155+
"Only is_uploaded=True is currently supported (Plane lists/exposes only uploaded attachments)."
156+
)
157+
158+
client, workspace_slug = get_plane_client_context()
159+
160+
data = UpdateWorkItemAttachment(is_uploaded=True)
161+
162+
client.work_items.attachments._patch(
163+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/attachments/{attachment_id}",
164+
data.model_dump(exclude_none=True),
165+
)
166+
for attachment in client.work_items.attachments.list(
167+
workspace_slug=workspace_slug,
168+
project_id=project_id,
169+
work_item_id=work_item_id,
170+
):
171+
if attachment.id == attachment_id:
172+
return attachment
173+
raise ValueError(
174+
f"Attachment {attachment_id} not found after update; Plane only lists attachments with is_uploaded=True."
175+
)
176+
177+
@mcp.tool()
178+
def delete_work_item_attachment(
179+
project_id: str,
180+
work_item_id: str,
181+
attachment_id: str,
182+
) -> None:
183+
"""
184+
Delete an attachment from a work item.
185+
186+
Args:
187+
project_id: UUID of the project
188+
work_item_id: UUID of the work item
189+
attachment_id: UUID of the attachment
190+
"""
191+
client, workspace_slug = get_plane_client_context()
192+
client.work_items.attachments.delete(
193+
workspace_slug=workspace_slug,
194+
project_id=project_id,
195+
work_item_id=work_item_id,
196+
attachment_id=attachment_id,
197+
)

tests/test_integration.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def get_config():
2222
mcp_url = os.getenv("PLANE_TEST_MCP_URL", "http://localhost:8211")
2323

2424
if not api_key or not workspace_slug:
25-
raise RuntimeError(
26-
"Missing required env vars: PLANE_TEST_API_KEY, PLANE_TEST_WORKSPACE_SLUG"
27-
)
25+
raise RuntimeError("Missing required env vars: PLANE_TEST_API_KEY, PLANE_TEST_WORKSPACE_SLUG")
2826

2927
return {
3028
"api_key": api_key,
@@ -54,20 +52,20 @@ async def run_integration_test():
5452
Full integration test:
5553
1. Create a project
5654
2. Create work item 1
57-
3. Create work item 2
58-
4. Update work item 2 with work item 1 as parent
59-
5. Create epic with work item 1 as the underlying work item
60-
6. Update work item 2 to be under the epic
61-
7. List all epics
55+
3. Create work item 2
56+
4. Update work item 2 with work item 1 as parent
57+
5. Create epic with work item 1 as the underlying work item
58+
6. Update work item 2 to be under the epic
59+
7. List all epics
6260
8. Create a milestone and associate it with the project and work items
6361
9. Update the milestone to change its name and description
6462
10. List all milestones in the project
6563
11. Delete the milestone
6664
12. Delete the epic
67-
13. Delete work items
68-
14. Delete project
69-
"""
70-
config = get_config()
65+
13. Delete work items
66+
14. Delete project
67+
"""
68+
config = get_config()
7169
unique_id = uuid.uuid4().hex[:6]
7270

7371
transport = StreamableHttpTransport(
@@ -131,6 +129,59 @@ async def run_integration_test():
131129
)
132130
print("Set work item 1 as parent of work item 2")
133131

132+
# Work item attachment lifecycle (create metadata → list → retrieve → mark uploaded → delete)
133+
print("Creating work item attachment...")
134+
attachment_result = await client.call_tool(
135+
"create_work_item_attachment",
136+
{
137+
"project_id": project_id,
138+
"work_item_id": work_item_1_id,
139+
"name": f"test-{unique_id}.txt",
140+
"size": 12,
141+
"type": "text/plain",
142+
},
143+
)
144+
attachment_wrapper = extract_result(attachment_result)
145+
# create_work_item_attachment returns WorkItemAttachmentCreated wrapper:
146+
# {attachment, upload_data, asset_id, asset_url}
147+
attachment_id = attachment_wrapper["attachment"]["id"]
148+
print(f"Created attachment: {attachment_id}")
149+
150+
# Plane filters list/retrieve to is_uploaded=True only — mark first.
151+
print("Marking attachment as uploaded...")
152+
await client.call_tool(
153+
"update_work_item_attachment",
154+
{
155+
"project_id": project_id,
156+
"work_item_id": work_item_1_id,
157+
"attachment_id": attachment_id,
158+
"is_uploaded": True,
159+
},
160+
)
161+
print("Marked attachment as uploaded")
162+
163+
print("Listing work item attachments...")
164+
attachments_list_result = await client.call_tool(
165+
"list_work_item_attachments",
166+
{
167+
"project_id": project_id,
168+
"work_item_id": work_item_1_id,
169+
},
170+
)
171+
attachments_list = extract_result(attachments_list_result)
172+
print(f"Attachments on work item 1: {[a['id'] for a in attachments_list if isinstance(a, dict) and 'id' in a]}")
173+
174+
print("Deleting attachment...")
175+
await client.call_tool(
176+
"delete_work_item_attachment",
177+
{
178+
"project_id": project_id,
179+
"work_item_id": work_item_1_id,
180+
"attachment_id": attachment_id,
181+
},
182+
)
183+
print("Deleted attachment")
184+
134185
# 5. Create epic with work item 1 as the underlying work item
135186
print("Creating epic...")
136187

@@ -178,7 +229,7 @@ async def run_integration_test():
178229
{
179230
"project_id": project_id,
180231
"name": f"Milestone {unique_id}",
181-
"description": "Integration test milestone",
232+
"description": "Integration test milestone",
182233
"associated_work_item_ids": [epic_id, work_item_1_id, work_item_2_id],
183234
},
184235
)
@@ -199,18 +250,18 @@ async def run_integration_test():
199250
print(f"Work items associated with milestone: {[wi['id'] for wi in milestone_work_items]}")
200251

201252
print(f"Created milestone: {milestone_id}")
202-
253+
203254
# 9. Update the milestone to change its name and description
204255
print("Updating milestone...")
205256
await client.call_tool(
206-
"update_milestone",
207-
{
208-
"project_id": project_id,
209-
"milestone_id": milestone_id,
210-
"name": f"Updated Milestone {unique_id}",
211-
"description": "Updated description for integration test milestone"
257+
"update_milestone",
258+
{
259+
"project_id": project_id,
260+
"milestone_id": milestone_id,
261+
"name": f"Updated Milestone {unique_id}",
262+
"description": "Updated description for integration test milestone",
212263
},
213-
)
264+
)
214265

215266
print("Updated milestone")
216267

@@ -283,6 +334,11 @@ def test_full_integration():
283334
# Work item activity tools
284335
"list_work_item_activities",
285336
"retrieve_work_item_activity",
337+
# Work item attachment tools
338+
"list_work_item_attachments",
339+
"create_work_item_attachment",
340+
"update_work_item_attachment",
341+
"delete_work_item_attachment",
286342
# Work item comment tools
287343
"list_work_item_comments",
288344
"retrieve_work_item_comment",

0 commit comments

Comments
 (0)