fix: wrap raw list returns in response models to produce single TextContent 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>
This commit is contained in:
Chris Coutinho
2026-02-20 09:22:16 +01:00
parent 39d160ce48
commit 8887aa241a
7 changed files with 141 additions and 55 deletions
@@ -78,8 +78,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
# Response is a ListBoardsResponse with a "boards" field
if isinstance(response_data, dict) and "boards" in response_data:
response_data = response_data["boards"]
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 {len(response_data)} boards: {board_ids}")
@@ -98,8 +100,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
# Response is a ListBoardsResponse with a "boards" field
if isinstance(response_data, dict) and "boards" in response_data:
response_data = response_data["boards"]
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"Diana can see {len(response_data)} boards")
@@ -313,8 +317,10 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
# Response is a ListBoardsResponse with a "boards" field
if isinstance(response_data, dict) and "boards" in response_data:
response_data = response_data["boards"]
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}")
@@ -332,8 +338,10 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
# Response is a ListBoardsResponse with a "boards" field
if isinstance(response_data, dict) and "boards" in response_data:
response_data = response_data["boards"]
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}")
+15 -21
View File
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
f"MCP list events failed: {list_result.content}"
)
events_data = json.loads(list_result.content[0].text)
events_response = json.loads(list_result.content[0].text)
# Debug output to understand what nc_calendar_list_events returns
logger.info(f"list_events result type: {type(events_data)}")
logger.info(f"list_events result content: {events_data}")
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(events_data, dict):
# Single event returned as dict instead of list
events_data = [events_data]
logger.info(f"list_events result type: {type(events_response)}")
logger.info(f"list_events result content: {events_response}")
# Response is now a ListEventsResponse with an "events" field
assert isinstance(events_response, dict), "Expected response dict"
events_data = events_response.get("events", [])
assert isinstance(events_data, list), "Expected events list"
# Our created event should be in the list
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
assert found_event is not None, (
f"Created event {event_uid} not found in events list"
)
assert found_event["title"] == test_event_title
assert found_event["summary"] == test_event_title
# 6. Test list events across all calendars
logger.info("Testing nc_calendar_list_events across all calendars")
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
f"MCP list all events failed: {all_list_result.content}"
)
all_events_data = json.loads(all_list_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(all_events_data, dict):
# Single event returned as dict instead of list
all_events_data = [all_events_data]
all_events_response = json.loads(all_list_result.content[0].text)
# Response is now a ListEventsResponse with an "events" field
assert isinstance(all_events_response, dict), "Expected response dict"
all_events_data = all_events_response.get("events", [])
assert isinstance(all_events_data, list), "Expected events list"
# Our event should still be found when searching all calendars
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
f"MCP upcoming events failed: {upcoming_result.content}"
)
upcoming_events = json.loads(upcoming_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as other tools)
if isinstance(upcoming_events, dict):
# Single event returned as dict instead of list
upcoming_events = [upcoming_events]
upcoming_response = json.loads(upcoming_result.content[0].text)
# Response is now an UpcomingEventsResponse with an "events" field
assert isinstance(upcoming_response, dict), "Expected response dict"
upcoming_events = upcoming_response.get("events", [])
assert isinstance(upcoming_events, list), "Expected upcoming events list"
# 10. Delete event via MCP