feat(deck): add file/note attachment MCP tools
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
bfbb294d86
commit
c0a974c498
@@ -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")
|
||||
|
||||
@@ -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=<cardId>. 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 ``<notes_folder>/<category>/<title>.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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user