From c0a974c498bfee4a68aaf5b0148489ca5a4f52f4 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 10 May 2026 23:17:20 +0200 Subject: [PATCH] feat(deck): add file/note attachment MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four tools that expose Deck card attachments via the MCP surface: deck_attach_file, deck_attach_note, deck_list_attachments, and deck_delete_attachment. The attach* variants share an existing Files entry (or Notes-app note) with the card via OCS shareType=12 — same mechanism the Deck UI's "Share from Files" picker uses, no file copy. This replaces the prior workaround of appending bulky activity content as Deck card comments: per-PR/per-event narrative now lives in NC Notes and surfaces on the tracking card as a clickable attachment that opens the original note in place. Implementation reuses existing client methods (SharingClient.create_share, DeckClient.get/delete_attachment, NotesClient.get_settings/get_note); no new client code. _SHARE_TYPE_DECK is centralised with a CI-guard test to prevent silent drift, and SharingClient.create_share's wire format is pinned to what the Deck Vue source sends. Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/models/deck.py | 39 ++++++ nextcloud_mcp_server/server/deck.py | 177 ++++++++++++++++++++++++++++ tests/client/test_sharing_client.py | 89 ++++++++++++++ tests/unit/test_deck_server.py | 40 +++++++ 4 files changed, 345 insertions(+) create mode 100644 tests/client/test_sharing_client.py diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index 46f3eb5d..e0ee76bd 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -144,6 +144,11 @@ class DeckAttachmentExtendedData(BaseModel): filesize: int mimetype: str info: Dict[str, str] + # Populated for type="file" (Files share) attachments via FilesAppService. + path: Optional[str] = None + fileid: Optional[int] = None + hasPreview: Optional[bool] = None + permissions: Optional[int] = None class DeckAttachment(BaseModel): @@ -308,3 +313,37 @@ class CardCommentOperationResponse(StatusResponse): card_id: int = Field(description="ID of the card the comment belongs to") comment_id: int = Field(description="ID of the affected comment") + + +# Attachment Response Models + + +class AttachFileResponse(BaseResponse): + """Response model for attaching an existing Nextcloud file to a Deck card. + + The attachment is created by sharing the file with the card via the standard + OCS Sharing API using ``shareType=12`` (``IShare::TYPE_DECK``). The returned + ``attachment_id`` is the share ID, which is also the Deck attachment ID. + """ + + attachment_id: int = Field( + description="ID of the created attachment (share ID)", + ) + card_id: int = Field(description="ID of the card the file is attached to") + path: str = Field(description="Path of the shared file in the user's Files") + + +class ListAttachmentsResponse(BaseResponse): + """Response model for listing card attachments.""" + + results: list["DeckAttachment"] = Field( + description="Attachments on the card (both type='file' and type='deck_file')" + ) + count: int = Field(description="Number of attachments returned") + + +class AttachmentOperationResponse(StatusResponse): + """Response model for attachment operations that don't return data (e.g. delete).""" + + card_id: int = Field(description="ID of the card the attachment belongs to") + attachment_id: int = Field(description="ID of the affected attachment") diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index b6205c3d..9a99f939 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -6,6 +6,8 @@ from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( + AttachFileResponse, + AttachmentOperationResponse, CardCommentOperationResponse, CardCommentResponse, CardOperationResponse, @@ -18,6 +20,7 @@ from nextcloud_mcp_server.models.deck import ( DeckLabel, DeckStack, LabelOperationResponse, + ListAttachmentsResponse, ListBoardsResponse, ListCardCommentsResponse, ListCardsResponse, @@ -101,6 +104,29 @@ def _apply_card_filters( return cards +# Card attachments — file shares ("Share from Files" picker in the Deck UI). +# +# Mechanism: a Deck card attachment of type="file" is just a Nextcloud share +# with shareType=12 (IShare::TYPE_DECK) and shareWith=. The Deck UI +# fires this exact request — see Deck app's +# src/components/card/AttachmentList.vue:223-238 and lib/Service/FilesAppService.php. +# The file is NOT copied; the share row binds the file's existing path to the card. +_SHARE_TYPE_DECK = 12 + + +def _resolve_note_path(notes_folder: str, category: str, title: str) -> str: + """Reconstruct a note's file path from Notes API metadata. + + Notes are stored as ``//.md`` in the + user's Files; ``<category>`` may be empty or nested (``"Foo/Bar"``). + """ + parts = [notes_folder.strip("/")] + if category: + parts.append(category.strip("/")) + parts.append(f"{title}.md") + return "/" + "/".join(p for p in parts if p) + + def configure_deck_tools(mcp: FastMCP): """Configure Nextcloud Deck tools and resources for the MCP server.""" @@ -1048,3 +1074,154 @@ def configure_deck_tools(mcp: FastMCP): card_id=card_id, comment_id=comment_id, ) + + @mcp.tool( + title="Attach File to Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_attach_file( + ctx: Context, card_id: int, path: str + ) -> AttachFileResponse: + """Attach an existing Nextcloud file to a Deck card without copying. + + Creates a share of ``path`` with the card (``shareType=12``, + ``shareWith=<card_id>``). The file stays in its original location; + clicking the attachment in the Deck UI opens the file in place. + + Use this to link Notes (``/Notes/<title>.md``) or any Files entry to + a card as a discoverable attachment, replacing the older pattern of + long card comments. Calling twice with the same ``path`` creates two + distinct shares — caller is responsible for de-duping. + + Args: + card_id: The ID of the Deck card to attach to + path: Path to the file in the user's Nextcloud Files (must start + with "/", e.g. "/Notes/My Note.md") + """ + if not path.startswith("/"): + raise ValueError( + f"path must start with '/', got: {path!r} " + "(paths are relative to the user's Files root)" + ) + client = await get_client(ctx) + share = await client.sharing.create_share( + path=path, + share_with=str(card_id), + share_type=_SHARE_TYPE_DECK, + permissions=1, + ) + return AttachFileResponse( + attachment_id=int(share["id"]), + card_id=card_id, + path=path, + ) + + @mcp.tool( + title="Attach Note to Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) + @require_scopes("deck.write", "notes.read") + @instrument_tool + async def deck_attach_note( + ctx: Context, card_id: int, note_id: int + ) -> AttachFileResponse: + """Attach a Nextcloud Note to a Deck card without copying. + + Convenience wrapper: looks up the note's filesystem path from the + Notes app settings + note metadata, then shares the file with the + card (same mechanism as :func:`deck_attach_file`). The note remains + editable in the Notes app; the card just shows a clickable link to + it. + + Path is reconstructed as ``<notes_folder>/<category>/<title>.md``. + If the note's title contains characters that the Notes app sanitises + differently (rare), use :func:`deck_attach_file` with the explicit + path instead. + + Args: + card_id: The ID of the Deck card to attach to + note_id: The ID of the Note to attach + """ + client = await get_client(ctx) + settings = await client.notes.get_settings() + note = await client.notes.get_note(note_id) + notes_folder = settings.get("notes_path") or "Notes" + path = _resolve_note_path( + notes_folder=notes_folder, + category=note.get("category") or "", + title=note["title"], + ) + share = await client.sharing.create_share( + path=path, + share_with=str(card_id), + share_type=_SHARE_TYPE_DECK, + permissions=1, + ) + return AttachFileResponse( + attachment_id=int(share["id"]), + card_id=card_id, + path=path, + ) + + @mcp.tool( + title="List Deck Card Attachments", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) + @require_scopes("deck.read") + @instrument_tool + async def deck_list_attachments( + ctx: Context, board_id: int, stack_id: int, card_id: int + ) -> ListAttachmentsResponse: + """List attachments on a Nextcloud Deck card. + + Returns both shared-file attachments (``type="file"``, created via + :func:`deck_attach_file` / :func:`deck_attach_note`) and uploaded + binary attachments (``type="deck_file"``). + + Args: + board_id: The ID of the board + stack_id: The ID of the stack + card_id: The ID of the card + """ + client = await get_client(ctx) + attachments = await client.deck.get_attachments(board_id, stack_id, card_id) + return ListAttachmentsResponse(results=attachments, count=len(attachments)) + + @mcp.tool( + title="Delete Deck Card Attachment", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_delete_attachment( + ctx: Context, + board_id: int, + stack_id: int, + card_id: int, + attachment_id: int, + ) -> AttachmentOperationResponse: + """Delete an attachment from a Nextcloud Deck card. + + For ``type="file"`` attachments this removes the share linking the + file to the card; the underlying file in the user's Files is left + untouched. For ``type="deck_file"`` blobs the binary is deleted from + Deck's storage. + + Args: + board_id: The ID of the board + stack_id: The ID of the stack + card_id: The ID of the card + attachment_id: The ID of the attachment to delete + """ + client = await get_client(ctx) + await client.deck.delete_attachment(board_id, stack_id, card_id, attachment_id) + return AttachmentOperationResponse( + success=True, + message="Attachment deleted successfully", + card_id=card_id, + attachment_id=attachment_id, + ) diff --git a/tests/client/test_sharing_client.py b/tests/client/test_sharing_client.py new file mode 100644 index 00000000..1723b1c5 --- /dev/null +++ b/tests/client/test_sharing_client.py @@ -0,0 +1,89 @@ +"""Unit tests for SharingClient — wire-format checks for the OCS Sharing API. + +These verify the payload shape sent to Nextcloud, particularly for +``shareType=12`` (``IShare::TYPE_DECK``), which is what powers Deck card +file attachments. The Deck UI fires this exact request — see +``~/Software/deck/src/components/card/AttachmentList.vue:223-238``. +""" + +import pytest +from httpx import AsyncClient + +from nextcloud_mcp_server.client.sharing import SharingClient + + +@pytest.fixture +def sharing_client(mocker): + """SharingClient with a mocked underlying httpx client.""" + mock_http = mocker.AsyncMock(spec=AsyncClient) + return SharingClient(mock_http, "testuser") + + +def _ok_share_response(mocker, share_id: int = 4242, **extra): + """Build a fake OCS create-share success response.""" + response = mocker.Mock() + response.raise_for_status = mocker.Mock() + response.json.return_value = { + "ocs": { + "meta": {"statuscode": 200, "message": "OK"}, + "data": {"id": share_id, **extra}, + } + } + return response + + +@pytest.mark.unit +async def test_create_share_deck_type_payload(sharing_client, mocker): + """create_share(share_type=12) must POST exactly what the Deck UI does: + {path, shareType: 12, shareWith: "<cardId>"} to /ocs/v2.php/apps/files_sharing/api/v1/shares. + + Drift here would silently break Deck attachments — Nextcloud's + ShareAPIController routes shareType=12 to DeckShareProvider, which + creates the deck-card share row binding the file to the card. + """ + sharing_client._client.post.return_value = _ok_share_response(mocker, share_id=99) + + share = await sharing_client.create_share( + path="/Notes/My Note.md", + share_with="123", + share_type=12, + permissions=1, + ) + + assert share["id"] == 99 + sharing_client._client.post.assert_called_once() + call = sharing_client._client.post.call_args + assert call.args[0] == "/ocs/v2.php/apps/files_sharing/api/v1/shares" + assert call.kwargs["data"] == { + "path": "/Notes/My Note.md", + "shareType": 12, + "shareWith": "123", + "permissions": 1, + } + # Nextcloud demands this header on OCS endpoints; without it the request + # is rejected as a CSRF risk. + assert call.kwargs["headers"]["OCS-APIRequest"] == "true" + + +@pytest.mark.unit +async def test_create_share_raises_on_ocs_failure(sharing_client, mocker): + """OCS error responses (statuscode != 100/200) raise RuntimeError.""" + response = mocker.Mock() + response.raise_for_status = mocker.Mock() + response.json.return_value = { + "ocs": { + "meta": { + "statuscode": 404, + "message": "Wrong path, file/folder doesn't exist", + }, + "data": [], + } + } + sharing_client._client.post.return_value = response + + with pytest.raises(RuntimeError, match="Wrong path"): + await sharing_client.create_share( + path="/nope.md", + share_with="1", + share_type=12, + ) diff --git a/tests/unit/test_deck_server.py b/tests/unit/test_deck_server.py index d810dbc0..f7ace51c 100644 --- a/tests/unit/test_deck_server.py +++ b/tests/unit/test_deck_server.py @@ -10,9 +10,11 @@ from nextcloud_mcp_server.models.deck import ( DeckUser, ) from nextcloud_mcp_server.server.deck import ( + _SHARE_TYPE_DECK, _apply_board_filters, _apply_card_filters, _apply_stack_filters, + _resolve_note_path, _truncate_card_descriptions, _validate_description_max_length, ) @@ -368,3 +370,41 @@ def test_apply_card_filters_empty_list_is_noop(): [], include_archived_cards=False, description_max_length=10 ) assert result == [] + + +# _resolve_note_path ------------------------------------------------------- + + +def test_resolve_note_path_no_category(): + """Path is /<notes_folder>/<title>.md when no category.""" + assert _resolve_note_path("Notes", "", "My Note") == "/Notes/My Note.md" + + +def test_resolve_note_path_with_category(): + """Category is inserted as a sub-path.""" + assert _resolve_note_path("Notes", "Work", "Standup") == "/Notes/Work/Standup.md" + + +def test_resolve_note_path_with_nested_category(): + """Nested categories (Notes app supports `/`-separated) are preserved.""" + assert _resolve_note_path("Notes", "Work/Q4", "Plan") == "/Notes/Work/Q4/Plan.md" + + +def test_resolve_note_path_strips_redundant_slashes(): + """Leading/trailing slashes on inputs do not produce `//` in the result.""" + assert _resolve_note_path("/Notes/", "/Work/", "Title") == "/Notes/Work/Title.md" + + +def test_resolve_note_path_custom_notes_folder(): + """Honors a non-default notes_folder from Notes app settings.""" + assert ( + _resolve_note_path("Documents/Notes", "", "Idea") == "/Documents/Notes/Idea.md" + ) + + +# Share-type constant ------------------------------------------------------- + + +def test_share_type_deck_constant_matches_deck_app(): + """Deck UI uses shareType=12 (IShare::TYPE_DECK) — must not drift.""" + assert _SHARE_TYPE_DECK == 12