fix: wrap raw list returns in response models (#568)#574
Conversation
…ontent block MCP tools returning raw lists caused FastMCP's _convert_to_content() to create one TextContent block per element. Most MCP clients only read content[0], so they saw a single result instead of the full list. Wrapped 9 tool functions in proper response objects: - deck: deck_get_boards, deck_get_stacks, deck_get_cards, deck_get_labels - calendar: nc_calendar_list_events, nc_calendar_get_upcoming_events - contacts: nc_contacts_list_addressbooks, nc_contacts_list_contacts - tables: nc_tables_list_tables Closes #568 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis PR correctly addresses the core issue from #568 — wrapping raw list returns in proper response models. The approach is sound and aligns with the pattern documented in CLAUDE.md. That said, there are a few issues worth addressing before merging. Bugs / Regressions1. Contacts list loses almost all contact data (Major) In ```python The The mapping should also populate at least 2. The original code explicitly annotated each event with which calendar it came from: ```python Original (removed in this PR)event["calendar_name"] = calendar["name"] This was the only way to know which calendar an event belongs to when fetching across all calendars. The Inconsistencies3. Count field naming is inconsistent across response models The PR introduces new response models with different naming for the count field:
4.
Test Coverage5. OAuth deck tests use a fragile fallback pattern The updated test code: ```python The ```python This appears three times in 6. No unit/integration test coverage for new contacts mapping The PR description mentions Minor / Nits7. The helper defensively handles 8.
SummaryThe core direction is correct and the approach aligns with project conventions. The two functional issues (contacts data loss and missing calendar source info in upcoming events) should be addressed before merging. The rest are improvements or cleanup. |
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>
Code ReviewThis PR correctly addresses the core issue — FastMCP was mangling raw What works well
Issues1. Naming inconsistency:
|
- Enrich single-calendar event dicts with calendar_name before mapping to CalendarEventSummary (list_events and upcoming_events paths) - Extract _raw_contact_to_model() from inline mapping in contacts.py, fix custom_fields type annotation to dict[str, Any] - Add unit tests for _event_dict_to_summary covering categories parsing, falsy coercion, and calendar name passthrough - Replace duplicated test helper with import of production function Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix raw list returns (#574)This is a solid, well-motivated fix. The CLAUDE.md convention is explicit about this: "Never return raw Bug:
|
| Model | Field |
|---|---|
ListCardsResponse / ListLabelsResponse |
total |
ListContactsResponse / ListAddressBooksResponse |
total_count |
ListEventsResponse |
total_found |
Existing models in the codebase (e.g. SearchNotesResponse) use total_found. Picking one name consistently (or at minimum matching the existing convention) makes the API easier to work with programmatically.
calendar_display_name missing in single-calendar paths
In both nc_calendar_list_events and nc_calendar_get_upcoming_events, when fetching from a specific named calendar the code enriches events with calendar_name but not calendar_display_name:
for event in events:
event["calendar_name"] = calendar_name # set
# calendar_display_name never set → always None in summariesThe all-calendars path sets both fields. The asymmetry means single-calendar calls return "calendar_display_name": null even when the display name is known. This is a pre-existing limitation of the single-calendar path, but it is now surfaced in the API contract; a follow-up issue or a TODO comment would be useful.
Minor: start defaults to "" on missing start_datetime
start=event.get("start_datetime", ""),Events without a start_datetime key silently produce an empty-string timestamp. If start is a required semantic field, a more explicit fallback (or a log warning) would make debugging easier when upstream data is malformed. Not a blocker, but worth noting.
What's working well
- The core fix is correct: wrapping in response objects eliminates the FastMCP numeric-key mangling.
deck_get_cardsalready guards againstNonewithstack.cards or [].- The unit tests for
_event_dict_to_summarycover the edge cases well (comma-separated categories, falsy location/description, missing fields). - Removing the
isinstance(data, dict)→[data]workarounds from the integration tests is a clean simplification. - The
found_event["title"] → found_event["summary"]fix in the integration test is correct and necessary.
- Guard board.labels against None in deck_get_labels and resource - Add TODO comments for calendar_display_name in single-calendar paths - Document _raw_contact_to_model scope limitation (maps only what the client returns; expanding requires changes to vCard parsing) - Log debug warning when event has no start_datetime - Verified Table model is safe with extra fields (Pydantic v2 ignores) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Wrap Raw List Returns in Response ModelsGood direction overall — this correctly fixes the FastMCP serialization bug (issue #568) where raw lists were mangled into dicts with numeric string keys. The test coverage for the new mapping helpers is solid. Here are some observations: Issues1.
|
| Domain | Field |
|---|---|
| Deck | total |
| Contacts, Tables | total_count |
| Calendar | total_found |
This forces clients to use different keys for a semantically identical concept. Since all the affected models are being touched in this PR, it's a reasonable time to standardise on one name (e.g. total_count). The deck models (ListBoardsResponse, ListStacksResponse) are pre-existing, so this would require a separate pass — but at minimum the two new deck models (ListCardsResponse, ListLabelsResponse) should match the existing naming rather than introduce a third variant.
3. calendar_display_name inconsistency between single- and multi-calendar paths
In nc_calendar_list_events:
- All-calendars path: events have both
calendar_nameandcalendar_display_name(populated byget_calendar_events_across_calendars). - Single-calendar path: only
calendar_nameis injected;calendar_display_namestaysNone.
The comment acknowledges this, but clients that conditionally display calendar_display_name will get different shapes depending on how the tool was called. The same asymmetry exists in nc_calendar_get_upcoming_events. Since calendar_display_name is on the summary-level field (not just the envelope), silently omitting it for the single-calendar case is a latent bug. Consider either:
- Making a single
list_calendars()call to resolve the display name, or - Falling back to
calendar_namewhencalendar_display_nameis absent (document the omission only at the model level).
4. uri mapping in nc_contacts_list_addressbooks
AddressBook(
uri=ab["name"], # ← is "name" actually the URI?
displayname=ab.get("display_name", ab["name"]),
ctag=ab.get("getctag"),
)The AddressBook model has a uri field (presumably a CardDAV path like /remote.php/dav/addressbooks/users/admin/contacts/), but the client's list_addressbooks() returns a name key. If name is just a short identifier rather than the full URI, clients relying on uri for subsequent CardDAV operations will get a wrong value. Worth verifying what the contacts client actually returns for that key.
5. Table(**t) is fragile
tables = [Table(**t) for t in tables_data]This relies on the raw API dict keys matching the Table Pydantic field names exactly. If the API ever returns an unexpected key and the model isn't configured with extra='ignore', this will raise a ValidationError at runtime. The pattern used elsewhere (explicit field mapping, as in _raw_contact_to_model) is more robust and self-documenting.
6. Testing internal helpers directly
tests/unit/test_response_models.py imports private functions:
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
from nextcloud_mcp_server.server.contacts import _raw_contact_to_modelTesting private functions directly couples the test suite to implementation details. These helpers could be moved to a _mapping.py or utils.py module alongside their respective server files, making the import intentional and the boundary clearer. Alternatively, testing them through the public tool interfaces (integration-style) avoids the coupling entirely.
Positives
- The
board.labels or []/stack.cards or []fixes are correct and preventNoneTypeerrors. - The
_event_dict_to_summaryand_raw_contact_to_modelhelpers are well-documented with their limitations. - Unit tests for the mapping functions are thorough and cover edge cases (multi-email, missing fields, comma-separated categories, falsy location/description).
- The test updates cleanly remove the workaround (
isinstance(data, dict)) that was papering over the original bug.
Overall this is a solid fix for the serialization issue. The naming inconsistency (point 2) and the uri mapping question (point 4) are the items I'd most want resolved before merging.
- Use lowercase generics (list[...]) in new deck response models - Add clarifying comment on AddressBook.uri slug semantics - Fall back calendar_display_name to calendar_name when absent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis PR correctly addresses the FastMCP list-mangling issue documented in CLAUDE.md. The approach is sound and the test coverage is thorough. A few observations: What's Well Done
Minor Issues / Suggestions1. Mapping helpers in the server layer
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
from nextcloud_mcp_server.server.contacts import _raw_contact_to_modelTesting private functions from the server module is pragmatic here (the logic is complex enough to warrant unit tests), but if these helpers grow or are needed elsewhere, they'd be better placed in a 2. Unnecessary def _map_contact(raw: dict) -> Contact:
"""Thin wrapper around the production mapping function for test readability."""
return _raw_contact_to_model(raw)This is a no-op indirection. Tests could call 3. AddressBook(
uri=ab["name"], # short slug like "contacts", not a full CardDAV URI
...
)The inline comment explains this, but a field named 4. CLAUDE.md says response models should include a 5. Pre-existing: private method access events = client.calendar._apply_event_filters(events, filters)This was present before the PR, but it's worth noting — accessing a private method from outside the class is a code smell. Not a PR issue to fix here. Correctness Checks
Overall this is a clean, well-tested fix for a real serialization bug. The suggestions above are minor and mostly stylistic. |
Summary
list[PydanticModel]in proper response objects, so each tool now produces a single JSONTextContentblock instead of one block per elementListCardsResponse,ListLabelsResponse) inmodels/deck.py; all other models already existedAffected tools:
deck_get_boards,deck_get_stacks,deck_get_cards,deck_get_labels,nc_calendar_list_events,nc_calendar_get_upcoming_events,nc_contacts_list_addressbooks,nc_contacts_list_contacts,nc_tables_list_tablesTest plan
uv run ruff check && uv run ruff format— passuv run ty check -- nextcloud_mcp_server— passuv run pytest tests/unit/ -v— 334 passed (10 pre-existing failures unrelated)docker-compose up --build -d mcp && uv run pytest -m smoke -v— 6 passeduv run pytest -m "integration and not oauth" -v -k "calendar or deck or contacts or tables"— 69 passed, 0 failedCloses #568
This PR was generated with the help of AI, and reviewed by a Human
🤖 Generated with Claude Code