fix: address PR #574 review comments
Restore contact email/birthday/nickname data and per-event calendar source that were silently dropped during response model wrapping. Remove dead elif branches in OAuth deck tests, add regression tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8887aa241a
commit
76e305006c
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
|||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||||
)
|
)
|
||||||
|
calendar_name: Optional[str] = Field(
|
||||||
|
None, description="Calendar containing this event"
|
||||||
|
)
|
||||||
|
calendar_display_name: Optional[str] = Field(
|
||||||
|
None, description="Display name of calendar containing this event"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarEvent(CalendarEventSummary):
|
class CalendarEvent(CalendarEventSummary):
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
|||||||
description=event.get("description") or None,
|
description=event.get("description") or None,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
status=event.get("status"),
|
status=event.get("status"),
|
||||||
|
calendar_name=event.get("calendar_name"),
|
||||||
|
calendar_display_name=event.get("calendar_display_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -470,6 +472,9 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
for event in cal_events:
|
||||||
|
event["calendar_name"] = calendar["name"]
|
||||||
|
event["calendar_display_name"] = calendar["display_name"]
|
||||||
all_events.extend(cal_events)
|
all_events.extend(cal_events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nextcloud_mcp_server.context import get_client
|
|||||||
from nextcloud_mcp_server.models.contacts import (
|
from nextcloud_mcp_server.models.contacts import (
|
||||||
AddressBook,
|
AddressBook,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactField,
|
||||||
ListAddressBooksResponse,
|
ListAddressBooksResponse,
|
||||||
ListContactsResponse,
|
ListContactsResponse,
|
||||||
)
|
)
|
||||||
@@ -52,14 +53,34 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
contacts = [
|
contacts = []
|
||||||
Contact(
|
for c in contacts_data:
|
||||||
uid=c["vcard_id"],
|
contact_info = c.get("contact", {})
|
||||||
fn=c.get("contact", {}).get("fullname", ""),
|
|
||||||
etag=c.get("getetag"),
|
# Convert email field (str, list, or None) to list[ContactField]
|
||||||
|
raw_email = contact_info.get("email")
|
||||||
|
emails: list[ContactField] = []
|
||||||
|
if isinstance(raw_email, list):
|
||||||
|
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||||
|
elif isinstance(raw_email, str) and raw_email:
|
||||||
|
emails = [ContactField(type="email", value=raw_email)]
|
||||||
|
|
||||||
|
# Nickname goes into custom_fields (no dedicated model field)
|
||||||
|
custom_fields: dict[str, str] = {}
|
||||||
|
nickname = contact_info.get("nickname")
|
||||||
|
if nickname:
|
||||||
|
custom_fields["nickname"] = nickname
|
||||||
|
|
||||||
|
contacts.append(
|
||||||
|
Contact(
|
||||||
|
uid=c["vcard_id"],
|
||||||
|
fn=contact_info.get("fullname", ""),
|
||||||
|
etag=c.get("getetag"),
|
||||||
|
birthday=contact_info.get("birthday"),
|
||||||
|
emails=emails,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for c in contacts_data
|
|
||||||
]
|
|
||||||
return ListContactsResponse(
|
return ListContactsResponse(
|
||||||
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,12 +79,9 @@ async def test_deck_board_view_permissions(
|
|||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# Response is a ListBoardsResponse with a "boards" field
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if isinstance(response_data, dict) and "boards" in response_data:
|
board_list = response_data.get("boards", [])
|
||||||
response_data = response_data["boards"]
|
board_ids = [b["id"] for b in board_list]
|
||||||
elif not isinstance(response_data, list):
|
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
|
||||||
response_data = [response_data] if response_data else []
|
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
|
||||||
|
|
||||||
# Bob should see the shared board
|
# Bob should see the shared board
|
||||||
if board_id in board_ids:
|
if board_id in board_ids:
|
||||||
@@ -101,12 +98,9 @@ async def test_deck_board_view_permissions(
|
|||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# Response is a ListBoardsResponse with a "boards" field
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if isinstance(response_data, dict) and "boards" in response_data:
|
board_list = response_data.get("boards", [])
|
||||||
response_data = response_data["boards"]
|
board_ids = [b["id"] for b in board_list]
|
||||||
elif not isinstance(response_data, list):
|
logger.info(f"Diana can see {len(board_list)} boards")
|
||||||
response_data = [response_data] if response_data else []
|
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Diana can see {len(response_data)} boards")
|
|
||||||
|
|
||||||
# Diana should NOT see the board
|
# Diana should NOT see the board
|
||||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
assert board_id not in board_ids, "Diana should not see board without ACL"
|
||||||
@@ -318,11 +312,8 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
|||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# Response is a ListBoardsResponse with a "boards" field
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if isinstance(response_data, dict) and "boards" in response_data:
|
board_list = response_data.get("boards", [])
|
||||||
response_data = response_data["boards"]
|
board_ids = [b["id"] for b in board_list]
|
||||||
elif not isinstance(response_data, list):
|
|
||||||
response_data = [response_data] if response_data else []
|
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Alice can see boards: {board_ids}")
|
logger.info(f"Alice can see boards: {board_ids}")
|
||||||
|
|
||||||
# Alice should NOT see Bob's board
|
# Alice should NOT see Bob's board
|
||||||
@@ -339,11 +330,8 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
|||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# Response is a ListBoardsResponse with a "boards" field
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if isinstance(response_data, dict) and "boards" in response_data:
|
board_list = response_data.get("boards", [])
|
||||||
response_data = response_data["boards"]
|
board_ids = [b["id"] for b in board_list]
|
||||||
elif not isinstance(response_data, list):
|
|
||||||
response_data = [response_data] if response_data else []
|
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Bob can see boards: {board_ids}")
|
logger.info(f"Bob can see boards: {board_ids}")
|
||||||
|
|
||||||
# Bob should NOT see Alice's board
|
# Bob should NOT see Alice's board
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.contacts import (
|
||||||
|
Contact,
|
||||||
|
ContactField,
|
||||||
|
ListContactsResponse,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
CreateNoteResponse,
|
CreateNoteResponse,
|
||||||
Note,
|
Note,
|
||||||
@@ -267,3 +272,125 @@ def test_sampling_search_response_serialization():
|
|||||||
assert data["model_used"] == "claude-3-5-sonnet"
|
assert data["model_used"] == "claude-3-5-sonnet"
|
||||||
assert data["stop_reason"] == "maxTokens"
|
assert data["stop_reason"] == "maxTokens"
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def _map_contact(raw: dict) -> Contact:
|
||||||
|
"""Replicate the mapping logic from server/contacts.py for testing."""
|
||||||
|
contact_info = raw.get("contact", {})
|
||||||
|
|
||||||
|
raw_email = contact_info.get("email")
|
||||||
|
emails: list[ContactField] = []
|
||||||
|
if isinstance(raw_email, list):
|
||||||
|
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||||
|
elif isinstance(raw_email, str) and raw_email:
|
||||||
|
emails = [ContactField(type="email", value=raw_email)]
|
||||||
|
|
||||||
|
custom_fields: dict[str, str] = {}
|
||||||
|
nickname = contact_info.get("nickname")
|
||||||
|
if nickname:
|
||||||
|
custom_fields["nickname"] = nickname
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
uid=raw["vcard_id"],
|
||||||
|
fn=contact_info.get("fullname", ""),
|
||||||
|
etag=raw.get("getetag"),
|
||||||
|
birthday=contact_info.get("birthday"),
|
||||||
|
emails=emails,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_preserves_email_birthday_nickname():
|
||||||
|
"""Test that list_contacts mapping preserves email, birthday, and nickname.
|
||||||
|
|
||||||
|
Regression test for PR #574: the original mapping only kept uid, fn, etag
|
||||||
|
and silently dropped email, birthday, and nickname.
|
||||||
|
"""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "abc-123",
|
||||||
|
"getetag": '"etag-val"',
|
||||||
|
"contact": {
|
||||||
|
"fullname": "Jane Doe",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"birthday": "1990-05-15",
|
||||||
|
"nickname": "JD",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert contact.uid == "abc-123"
|
||||||
|
assert contact.fn == "Jane Doe"
|
||||||
|
assert contact.etag == '"etag-val"'
|
||||||
|
assert contact.birthday == "1990-05-15"
|
||||||
|
assert len(contact.emails) == 1
|
||||||
|
assert contact.emails[0].value == "jane@example.com"
|
||||||
|
assert contact.emails[0].type == "email"
|
||||||
|
assert contact.custom_fields["nickname"] == "JD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_multiple_emails():
|
||||||
|
"""Test that multiple emails are mapped correctly."""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "def-456",
|
||||||
|
"contact": {
|
||||||
|
"fullname": "John Smith",
|
||||||
|
"email": ["john@work.com", "john@home.com"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert len(contact.emails) == 2
|
||||||
|
assert contact.emails[0].value == "john@work.com"
|
||||||
|
assert contact.emails[1].value == "john@home.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_missing_optional_fields():
|
||||||
|
"""Test mapping when email, birthday, and nickname are absent."""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "ghi-789",
|
||||||
|
"contact": {"fullname": "No Details"},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert contact.uid == "ghi-789"
|
||||||
|
assert contact.fn == "No Details"
|
||||||
|
assert contact.birthday is None
|
||||||
|
assert contact.emails == []
|
||||||
|
assert contact.custom_fields == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_contacts_response_wraps_contacts():
|
||||||
|
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
|
||||||
|
contacts = [
|
||||||
|
_map_contact(
|
||||||
|
{
|
||||||
|
"vcard_id": "a",
|
||||||
|
"getetag": '"e1"',
|
||||||
|
"contact": {
|
||||||
|
"fullname": "Alice",
|
||||||
|
"email": "alice@test.com",
|
||||||
|
"birthday": "2000-01-01",
|
||||||
|
"nickname": "Ali",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = ListContactsResponse(
|
||||||
|
contacts=contacts, addressbook="personal", total_count=1
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.model_dump()
|
||||||
|
assert data["total_count"] == 1
|
||||||
|
assert len(data["contacts"]) == 1
|
||||||
|
c = data["contacts"][0]
|
||||||
|
assert c["birthday"] == "2000-01-01"
|
||||||
|
assert c["emails"][0]["value"] == "alice@test.com"
|
||||||
|
assert c["custom_fields"]["nickname"] == "Ali"
|
||||||
|
|||||||
Reference in New Issue
Block a user