feat(server): Add structured output to all tool/resource output

BREAKING CHANGE
This commit is contained in:
Chris Coutinho
2025-08-30 18:27:32 +02:00
parent 938376425b
commit 9b00530e8e
15 changed files with 1255 additions and 53 deletions
+146
View File
@@ -0,0 +1,146 @@
"""Pydantic models for structured MCP server responses."""
# Base models
from .base import (
BaseResponse,
ErrorResponse,
SuccessResponse,
PaginatedResponse,
IdResponse,
StatusResponse,
)
# Notes models
from .notes import (
Note,
NoteSearchResult,
NotesSettings,
CreateNoteResponse,
UpdateNoteResponse,
DeleteNoteResponse,
AppendContentResponse,
SearchNotesResponse,
)
# Calendar models
from .calendar import (
Calendar,
CalendarEvent,
CalendarEventSummary,
CreateEventResponse,
UpdateEventResponse,
DeleteEventResponse,
ListEventsResponse,
ListCalendarsResponse,
AvailabilitySlot,
FindAvailabilityResponse,
BulkOperationResult,
BulkOperationResponse,
CreateMeetingResponse,
UpcomingEventsResponse,
ManageCalendarResponse,
)
# Contacts models
from .contacts import (
AddressBook,
Contact,
ContactField,
ListAddressBooksResponse,
ListContactsResponse,
CreateContactResponse,
UpdateContactResponse,
DeleteContactResponse,
CreateAddressBookResponse,
DeleteAddressBookResponse,
)
# Tables models
from .tables import (
Table,
TableColumn,
TableRow,
TableView,
TableSchema,
ListTablesResponse,
GetSchemaResponse,
ReadTableResponse,
CreateRowResponse,
UpdateRowResponse,
DeleteRowResponse,
)
# WebDAV models
from .webdav import (
FileInfo,
DirectoryListing,
ReadFileResponse,
WriteFileResponse,
CreateDirectoryResponse,
DeleteResourceResponse,
)
__all__ = [
# Base models
"BaseResponse",
"ErrorResponse",
"SuccessResponse",
"PaginatedResponse",
"IdResponse",
"StatusResponse",
# Notes models
"Note",
"NoteSearchResult",
"NotesSettings",
"CreateNoteResponse",
"UpdateNoteResponse",
"DeleteNoteResponse",
"AppendContentResponse",
"SearchNotesResponse",
# Calendar models
"Calendar",
"CalendarEvent",
"CalendarEventSummary",
"CreateEventResponse",
"UpdateEventResponse",
"DeleteEventResponse",
"ListEventsResponse",
"ListCalendarsResponse",
"AvailabilitySlot",
"FindAvailabilityResponse",
"BulkOperationResult",
"BulkOperationResponse",
"CreateMeetingResponse",
"UpcomingEventsResponse",
"ManageCalendarResponse",
# Contacts models
"AddressBook",
"Contact",
"ContactField",
"ListAddressBooksResponse",
"ListContactsResponse",
"CreateContactResponse",
"UpdateContactResponse",
"DeleteContactResponse",
"CreateAddressBookResponse",
"DeleteAddressBookResponse",
# Tables models
"Table",
"TableColumn",
"TableRow",
"TableView",
"TableSchema",
"ListTablesResponse",
"GetSchemaResponse",
"ReadTableResponse",
"CreateRowResponse",
"UpdateRowResponse",
"DeleteRowResponse",
# WebDAV models
"FileInfo",
"DirectoryListing",
"ReadFileResponse",
"WriteFileResponse",
"CreateDirectoryResponse",
"DeleteResourceResponse",
]
+70
View File
@@ -0,0 +1,70 @@
"""Base Pydantic models for common response patterns."""
from datetime import datetime
from typing import Any, Dict, Generic, List, Optional, TypeVar, Union
from pydantic import BaseModel, Field
class BaseResponse(BaseModel):
"""Base response model for all MCP tool responses."""
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
success: bool = Field(
default=True, description="Whether the operation was successful"
)
timestamp: datetime = Field(
default_factory=datetime.now, description="Response timestamp"
)
class ErrorResponse(BaseResponse):
"""Response model for error cases."""
success: bool = Field(default=False, description="Always False for error responses")
error: str = Field(description="Error message")
error_code: Optional[str] = Field(None, description="Optional error code")
details: Optional[Dict[str, Any]] = Field(
None, description="Additional error details"
)
class SuccessResponse(BaseResponse):
"""Generic success response."""
message: Optional[str] = Field(None, description="Optional success message")
data: Optional[Dict[str, Any]] = Field(None, description="Optional response data")
T = TypeVar("T")
class PaginatedResponse(BaseResponse, Generic[T]):
"""Generic paginated response model."""
items: List[T] = Field(description="List of items")
total_count: Optional[int] = Field(
None, description="Total number of items available"
)
page: Optional[int] = Field(None, description="Current page number")
page_size: Optional[int] = Field(None, description="Number of items per page")
has_more: Optional[bool] = Field(
None, description="Whether more items are available"
)
cursor: Optional[str] = Field(
None, description="Cursor for next page (if using cursor-based pagination)"
)
class IdResponse(BaseResponse):
"""Response model for operations that return a new ID."""
id: Union[int, str] = Field(description="ID of the created or affected resource")
class StatusResponse(BaseResponse):
"""Response model for operations that return just a status."""
status_code: Optional[int] = Field(None, description="HTTP status code")
message: Optional[str] = Field(None, description="Status message")
+182
View File
@@ -0,0 +1,182 @@
"""Pydantic models for Calendar app responses."""
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class Calendar(BaseModel):
"""Model for a Nextcloud calendar."""
name: str = Field(description="Calendar name/ID")
display_name: str = Field(description="Calendar display name")
description: Optional[str] = Field(None, description="Calendar description")
color: Optional[str] = Field(None, description="Calendar color")
href: Optional[str] = Field(None, description="Calendar DAV href")
timezone: Optional[str] = Field(None, description="Calendar timezone")
enabled: bool = Field(default=True, description="Whether calendar is enabled")
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
class CalendarEventSummary(BaseModel):
"""Model for calendar event summary (for lists)."""
uid: str = Field(description="Event UID")
summary: str = Field(description="Event summary/title")
start: str = Field(description="Event start datetime (ISO format)")
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
all_day: bool = Field(default=False, description="Whether event is all-day")
location: Optional[str] = Field(None, description="Event location")
description: Optional[str] = Field(None, description="Event description")
categories: List[str] = Field(default_factory=list, description="Event categories")
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
class CalendarEvent(CalendarEventSummary):
"""Model for a complete calendar event."""
created: Optional[str] = Field(None, description="Event creation datetime")
last_modified: Optional[str] = Field(None, description="Last modification datetime")
recurring: bool = Field(default=False, description="Whether event is recurring")
recurrence_rule: Optional[str] = Field(None, description="RFC5545 recurrence rule")
recurrence_end: Optional[str] = Field(None, description="Recurrence end date")
attendees: List[str] = Field(
default_factory=list, description="List of attendee email addresses"
)
organizer: Optional[str] = Field(None, description="Event organizer")
priority: Optional[int] = Field(None, description="Event priority (1-9)")
privacy: Optional[str] = Field(None, description="Event privacy level")
url: Optional[str] = Field(None, description="Event URL")
duration_minutes: Optional[int] = Field(
None, description="Event duration in minutes"
)
reminder_minutes: Optional[int] = Field(
None, description="Reminder time in minutes before event"
)
reminder_email: bool = Field(
default=False, description="Whether to send email reminder"
)
color: Optional[str] = Field(None, description="Event color")
etag: Optional[str] = Field(None, description="ETag for versioning")
class CreateEventResponse(BaseResponse):
"""Response model for event creation."""
event: CalendarEvent = Field(description="The created event")
calendar_name: str = Field(
description="Name of the calendar the event was created in"
)
class UpdateEventResponse(BaseResponse):
"""Response model for event updates."""
event: CalendarEvent = Field(description="The updated event")
calendar_name: str = Field(description="Name of the calendar the event belongs to")
class DeleteEventResponse(StatusResponse):
"""Response model for event deletion."""
deleted_uid: str = Field(description="UID of the deleted event")
calendar_name: str = Field(
description="Name of the calendar the event was deleted from"
)
class ListEventsResponse(BaseResponse):
"""Response model for listing events."""
events: List[CalendarEventSummary] = Field(description="List of events")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
start_date: Optional[str] = Field(None, description="Start date filter applied")
end_date: Optional[str] = Field(None, description="End date filter applied")
total_found: int = Field(description="Total number of events found")
class ListCalendarsResponse(BaseResponse):
"""Response model for listing calendars."""
calendars: List[Calendar] = Field(description="List of available calendars")
total_count: int = Field(description="Total number of calendars")
class AvailabilitySlot(BaseModel):
"""Model for an available time slot."""
start: str = Field(description="Slot start datetime (ISO format)")
end: str = Field(description="Slot end datetime (ISO format)")
duration_minutes: int = Field(description="Slot duration in minutes")
date: str = Field(description="Date of the slot (YYYY-MM-DD)")
class FindAvailabilityResponse(BaseResponse):
"""Response model for finding availability."""
available_slots: List[AvailabilitySlot] = Field(
description="List of available time slots"
)
duration_requested: int = Field(description="Requested duration in minutes")
date_range_start: str = Field(description="Start date of search range")
date_range_end: str = Field(description="End date of search range")
attendees_checked: List[str] = Field(
default_factory=list, description="Attendees checked for availability"
)
business_hours_only: bool = Field(
description="Whether search was limited to business hours"
)
class BulkOperationResult(BaseModel):
"""Model for bulk operation results."""
operation: str = Field(description="Operation performed (update, delete, move)")
events_processed: int = Field(description="Number of events processed")
events_successful: int = Field(
description="Number of events successfully processed"
)
events_failed: int = Field(description="Number of events that failed processing")
failed_events: List[str] = Field(
default_factory=list, description="UIDs of events that failed"
)
errors: List[str] = Field(default_factory=list, description="Error messages")
class BulkOperationResponse(BaseResponse):
"""Response model for bulk operations."""
result: BulkOperationResult = Field(description="Bulk operation result")
class CreateMeetingResponse(CreateEventResponse):
"""Response model for meeting creation (same as event creation)."""
pass
class UpcomingEventsResponse(BaseResponse):
"""Response model for upcoming events."""
events: List[CalendarEventSummary] = Field(description="List of upcoming events")
days_ahead: int = Field(description="Number of days ahead searched")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
class ManageCalendarResponse(BaseResponse):
"""Response model for calendar management operations."""
action: str = Field(description="Action performed (create, delete, update, list)")
calendar: Optional[Calendar] = Field(None, description="Calendar that was affected")
calendars: Optional[List[Calendar]] = Field(
None, description="List of calendars (for list action)"
)
message: str = Field(description="Success message")
+130
View File
@@ -0,0 +1,130 @@
"""Pydantic models for Contacts app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class AddressBook(BaseModel):
"""Model for a Nextcloud address book."""
uri: str = Field(description="Address book URI")
displayname: str = Field(description="Address book display name")
description: Optional[str] = Field(None, description="Address book description")
ctag: Optional[str] = Field(
None, description="Address book tag for synchronization"
)
class ContactField(BaseModel):
"""Model for a contact field (email, phone, etc.)."""
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
value: str = Field(description="Field value")
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
preferred: bool = Field(
default=False, description="Whether this is the preferred field of this type"
)
class Contact(BaseModel):
"""Model for a Nextcloud contact."""
uid: str = Field(description="Contact UID")
fn: str = Field(description="Full name (formatted name)")
given_name: Optional[str] = Field(None, description="Given name")
family_name: Optional[str] = Field(None, description="Family name")
organization: Optional[str] = Field(None, description="Organization")
title: Optional[str] = Field(None, description="Job title")
emails: List[ContactField] = Field(
default_factory=list, description="Email addresses"
)
phones: List[ContactField] = Field(
default_factory=list, description="Phone numbers"
)
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
urls: List[ContactField] = Field(default_factory=list, description="URLs")
note: Optional[str] = Field(None, description="Notes")
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
categories: List[str] = Field(
default_factory=list, description="Contact categories"
)
custom_fields: Dict[str, Any] = Field(
default_factory=dict, description="Custom fields"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@property
def primary_email(self) -> Optional[str]:
"""Get the primary email address."""
if not self.emails:
return None
# Return preferred email if available, otherwise first email
preferred = next(
(email.value for email in self.emails if email.preferred), None
)
return preferred or self.emails[0].value
@property
def primary_phone(self) -> Optional[str]:
"""Get the primary phone number."""
if not self.phones:
return None
# Return preferred phone if available, otherwise first phone
preferred = next(
(phone.value for phone in self.phones if phone.preferred), None
)
return preferred or self.phones[0].value
class ListAddressBooksResponse(BaseResponse):
"""Response model for listing address books."""
addressbooks: List[AddressBook] = Field(
description="List of available address books"
)
total_count: int = Field(description="Total number of address books")
class ListContactsResponse(BaseResponse):
"""Response model for listing contacts."""
contacts: List[Contact] = Field(description="List of contacts")
addressbook: str = Field(description="Address book name")
total_count: int = Field(description="Total number of contacts")
class CreateContactResponse(BaseResponse):
"""Response model for contact creation."""
contact: Contact = Field(description="The created contact")
addressbook: str = Field(description="Address book the contact was created in")
class UpdateContactResponse(BaseResponse):
"""Response model for contact updates."""
contact: Contact = Field(description="The updated contact")
addressbook: str = Field(description="Address book the contact belongs to")
class DeleteContactResponse(StatusResponse):
"""Response model for contact deletion."""
deleted_uid: str = Field(description="UID of the deleted contact")
addressbook: str = Field(description="Address book the contact was deleted from")
class CreateAddressBookResponse(BaseResponse):
"""Response model for address book creation."""
addressbook: AddressBook = Field(description="The created address book")
class DeleteAddressBookResponse(StatusResponse):
"""Response model for address book deletion."""
deleted_name: str = Field(description="Name of the deleted address book")
+77
View File
@@ -0,0 +1,77 @@
"""Pydantic models for Notes app responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Note(BaseModel):
"""Model for a Nextcloud note."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
content: str = Field(description="Note content in markdown")
category: str = Field(default="", description="Note category")
modified: int = Field(description="Unix timestamp of last modification")
favorite: bool = Field(
default=False, description="Whether note is marked as favorite"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
readonly: bool = Field(default=False, description="Whether note is read-only")
@property
def modified_datetime(self) -> datetime:
"""Convert Unix timestamp to datetime."""
return datetime.fromtimestamp(self.modified)
class NoteSearchResult(BaseModel):
"""Model for note search results (limited fields)."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
category: str = Field(default="", description="Note category")
score: Optional[float] = Field(None, description="Search relevance score")
class NotesSettings(BaseModel):
"""Model for Notes app settings."""
notesPath: str = Field(description="Path to notes directory")
fileSuffix: str = Field(description="File suffix for notes")
noteMode: str = Field(description="Note mode setting")
class CreateNoteResponse(IdResponse):
"""Response model for note creation."""
note: Note = Field(description="The created note")
class UpdateNoteResponse(BaseResponse):
"""Response model for note updates."""
note: Note = Field(description="The updated note")
class DeleteNoteResponse(StatusResponse):
"""Response model for note deletion."""
deleted_id: int = Field(description="ID of the deleted note")
class AppendContentResponse(BaseResponse):
"""Response model for appending content to a note."""
note: Note = Field(description="The updated note after appending content")
class SearchNotesResponse(BaseResponse):
"""Response model for note search."""
results: List[NoteSearchResult] = Field(description="Search results")
query: str = Field(description="The search query used")
total_found: int = Field(description="Total number of notes found")
+142
View File
@@ -0,0 +1,142 @@
"""Pydantic models for Tables app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class TableColumn(BaseModel):
"""Model for a table column definition."""
id: int = Field(description="Column ID")
title: str = Field(description="Column title")
type: str = Field(description="Column type (text, number, datetime, etc.)")
subtype: Optional[str] = Field(None, description="Column subtype")
mandatory: bool = Field(default=False, description="Whether column is mandatory")
description: Optional[str] = Field(None, description="Column description")
text_default: Optional[str] = Field(None, description="Default text value")
text_allowed_pattern: Optional[str] = Field(
None, description="Allowed text pattern"
)
text_max_length: Optional[int] = Field(None, description="Maximum text length")
number_default: Optional[float] = Field(None, description="Default number value")
number_min: Optional[float] = Field(None, description="Minimum number value")
number_max: Optional[float] = Field(None, description="Maximum number value")
number_decimals: Optional[int] = Field(None, description="Number of decimal places")
datetime_default: Optional[str] = Field(None, description="Default datetime value")
selection_options: List[str] = Field(
default_factory=list, description="Selection options"
)
selection_default: Optional[str] = Field(
None, description="Default selection value"
)
class TableRow(BaseModel):
"""Model for a table row."""
id: int = Field(description="Row ID")
created_by: Optional[str] = Field(None, description="User who created the row")
created_at: Optional[str] = Field(None, description="Row creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the row"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
data: Dict[int, Any] = Field(description="Row data keyed by column ID")
class TableView(BaseModel):
"""Model for a table view."""
id: int = Field(description="View ID")
title: str = Field(description="View title")
emoji: Optional[str] = Field(None, description="View emoji")
description: Optional[str] = Field(None, description="View description")
columns: List[int] = Field(
default_factory=list, description="List of column IDs in this view"
)
sort: List[Dict[str, Any]] = Field(
default_factory=list, description="Sort configuration"
)
filter: List[Dict[str, Any]] = Field(
default_factory=list, description="Filter configuration"
)
class Table(BaseModel):
"""Model for a Nextcloud table."""
id: int = Field(description="Table ID")
title: str = Field(description="Table title")
emoji: Optional[str] = Field(None, description="Table emoji")
ownership: str = Field(description="Table ownership")
owner_display_name: str = Field(description="Display name of table owner")
created_by: Optional[str] = Field(None, description="User who created the table")
created_at: Optional[str] = Field(None, description="Table creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the table"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
row_count: int = Field(default=0, description="Number of rows in the table")
has_shares: bool = Field(default=False, description="Whether table is shared")
archived: bool = Field(default=False, description="Whether table is archived")
is_shared: bool = Field(
default=False, description="Whether table is shared with current user"
)
on_share_permissions: Optional[Dict[str, Any]] = Field(
None, description="Share permissions"
)
class TableSchema(BaseModel):
"""Model for complete table schema including columns and views."""
table: Table = Field(description="Table information")
columns: List[TableColumn] = Field(description="Table columns")
views: List[TableView] = Field(description="Table views")
class ListTablesResponse(BaseResponse):
"""Response model for listing tables."""
tables: List[Table] = Field(description="List of available tables")
total_count: int = Field(description="Total number of tables")
class GetSchemaResponse(BaseResponse):
"""Response model for getting table schema."""
table_schema: TableSchema = Field(description="Table schema information")
class ReadTableResponse(BaseResponse):
"""Response model for reading table rows."""
rows: List[TableRow] = Field(description="Table rows")
table_id: int = Field(description="Table ID")
total_count: Optional[int] = Field(
None, description="Total number of rows (if known)"
)
offset: Optional[int] = Field(None, description="Offset used for pagination")
limit: Optional[int] = Field(None, description="Limit used for pagination")
class CreateRowResponse(IdResponse):
"""Response model for row creation."""
row: TableRow = Field(description="The created row")
table_id: int = Field(description="Table ID the row was created in")
class UpdateRowResponse(BaseResponse):
"""Response model for row updates."""
row: TableRow = Field(description="The updated row")
class DeleteRowResponse(StatusResponse):
"""Response model for row deletion."""
deleted_id: int = Field(description="ID of the deleted row")
+88
View File
@@ -0,0 +1,88 @@
"""Pydantic models for WebDAV responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class FileInfo(BaseModel):
"""Model for file/directory information."""
name: str = Field(description="File/directory name")
path: str = Field(description="Full path")
is_directory: bool = Field(description="Whether this is a directory")
size: Optional[int] = Field(
None, description="File size in bytes (None for directories)"
)
content_type: Optional[str] = Field(None, description="MIME content type")
last_modified: Optional[str] = Field(
None, description="Last modification time (ISO format)"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@property
def last_modified_datetime(self) -> Optional[datetime]:
"""Convert last modified string to datetime."""
if not self.last_modified:
return None
try:
return datetime.fromisoformat(self.last_modified.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
class DirectoryListing(BaseResponse):
"""Response model for directory listings."""
path: str = Field(description="Directory path")
items: List[FileInfo] = Field(description="Files and directories in the path")
total_count: int = Field(description="Total number of items")
directories_count: int = Field(description="Number of directories")
files_count: int = Field(description="Number of files")
total_size: int = Field(default=0, description="Total size of all files in bytes")
class ReadFileResponse(BaseResponse):
"""Response model for reading file contents."""
path: str = Field(description="File path")
content: str = Field(description="File content (text or base64 for binary)")
content_type: str = Field(description="MIME content type")
size: int = Field(description="File size in bytes")
encoding: Optional[str] = Field(
None, description="Encoding used (e.g., 'base64' for binary files)"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
last_modified: Optional[str] = Field(None, description="Last modification time")
class WriteFileResponse(StatusResponse):
"""Response model for writing files."""
path: str = Field(description="File path that was written")
size: Optional[int] = Field(None, description="Size of the written file")
created: bool = Field(description="Whether a new file was created (vs overwritten)")
class CreateDirectoryResponse(StatusResponse):
"""Response model for directory creation."""
path: str = Field(description="Directory path that was created")
created: bool = Field(
description="Whether directory was created or already existed"
)
class DeleteResourceResponse(StatusResponse):
"""Response model for resource deletion."""
path: str = Field(description="Path that was deleted")
was_directory: bool = Field(
description="Whether the deleted resource was a directory"
)
items_deleted: Optional[int] = Field(
None, description="Number of items deleted (for directories)"
)