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:
Chris Coutinho
2026-05-10 23:17:20 +02:00
co-authored by Claude Opus 4.7
parent bfbb294d86
commit c0a974c498
4 changed files with 345 additions and 0 deletions
+89
View File
@@ -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,
)
+40
View File
@@ -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