Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daeb95f3c3 | |||
| 36d44d1781 | |||
| 949fb7124b | |||
| 6c4f071d2b | |||
| 53b11f7fbb | |||
| 336bc45637 | |||
| 6c587bb265 | |||
| 6b1f5c12c8 | |||
| f8dc1f060b |
@@ -1,3 +1,26 @@
|
|||||||
|
## v0.8.2 (2025-08-31)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **notes**: Remove note contents from responses to reduce token usage
|
||||||
|
|
||||||
|
## v0.8.1 (2025-08-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **model**: Serialize timestamps in RFC3339 format
|
||||||
|
|
||||||
|
## v0.8.0 (2025-08-30)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||||
|
- **server**: Add structured output to all tool/resource output
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Use _make_request where available
|
||||||
|
|
||||||
## v0.7.2 (2025-08-30)
|
## v0.7.2 (2025-08-30)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: nextcloud:31.0.8@sha256:3eaddb0a9c56e6cf81ad258a5d05b78f747f6434b974f9a44e3f0dd91311b6ef
|
image: nextcloud:31.0.8@sha256:fcf637074755bb1d27644441b938bf39b27dd6c0a8c2326a5752e1b3d5014366
|
||||||
#user: www-data:www-data
|
#user: www-data:www-data
|
||||||
restart: always
|
restart: always
|
||||||
#post_start:
|
#post_start:
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
"""Base Pydantic models for common response patterns."""
|
"""Base Pydantic models for common response patterns."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_serializer
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
"""Generate UTC timestamp for responses."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class BaseResponse(BaseModel):
|
class BaseResponse(BaseModel):
|
||||||
"""Base response model for all MCP tool responses."""
|
"""Base response model for all MCP tool responses."""
|
||||||
|
|
||||||
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
|
|
||||||
|
|
||||||
success: bool = Field(
|
success: bool = Field(
|
||||||
default=True, description="Whether the operation was successful"
|
default=True, description="Whether the operation was successful"
|
||||||
)
|
)
|
||||||
timestamp: datetime = Field(
|
timestamp: datetime = Field(
|
||||||
default_factory=datetime.now, description="Response timestamp"
|
default_factory=_utc_now, description="Response timestamp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_serializer("timestamp")
|
||||||
|
def serialize_timestamp(self, timestamp: datetime) -> str:
|
||||||
|
"""Serialize timestamp to RFC3339 format for MCP compliance."""
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
# If somehow we get a naive datetime, assume UTC
|
||||||
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||||
|
# Use isoformat() which produces RFC3339 compliant format
|
||||||
|
# For UTC times, replace '+00:00' with 'Z' as preferred by many systems
|
||||||
|
iso_string = timestamp.isoformat()
|
||||||
|
if iso_string.endswith("+00:00"):
|
||||||
|
return iso_string[:-6] + "Z"
|
||||||
|
return iso_string
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseResponse):
|
class ErrorResponse(BaseResponse):
|
||||||
"""Response model for error cases."""
|
"""Response model for error cases."""
|
||||||
|
|||||||
@@ -48,13 +48,16 @@ class NotesSettings(BaseModel):
|
|||||||
class CreateNoteResponse(IdResponse):
|
class CreateNoteResponse(IdResponse):
|
||||||
"""Response model for note creation."""
|
"""Response model for note creation."""
|
||||||
|
|
||||||
note: Note = Field(description="The created note")
|
title: str = Field(description="The created note title")
|
||||||
|
category: str = Field(description="The created note category")
|
||||||
|
|
||||||
|
|
||||||
class UpdateNoteResponse(BaseResponse):
|
class UpdateNoteResponse(BaseResponse):
|
||||||
"""Response model for note updates."""
|
"""Response model for note updates."""
|
||||||
|
|
||||||
note: Note = Field(description="The updated note")
|
id: int = Field(description="The updated note ID")
|
||||||
|
title: str = Field(description="The updated note title")
|
||||||
|
category: str = Field(description="The updated note category")
|
||||||
|
|
||||||
|
|
||||||
class DeleteNoteResponse(StatusResponse):
|
class DeleteNoteResponse(StatusResponse):
|
||||||
@@ -66,7 +69,9 @@ class DeleteNoteResponse(StatusResponse):
|
|||||||
class AppendContentResponse(BaseResponse):
|
class AppendContentResponse(BaseResponse):
|
||||||
"""Response model for appending content to a note."""
|
"""Response model for appending content to a note."""
|
||||||
|
|
||||||
note: Note = Field(description="The updated note after appending content")
|
id: int = Field(description="The updated note ID")
|
||||||
|
title: str = Field(description="The updated note title")
|
||||||
|
category: str = Field(description="The updated note category")
|
||||||
|
|
||||||
|
|
||||||
class SearchNotesResponse(BaseResponse):
|
class SearchNotesResponse(BaseResponse):
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
category=category,
|
category=category,
|
||||||
)
|
)
|
||||||
note = Note(**note_data)
|
note = Note(**note_data)
|
||||||
return CreateNoteResponse(id=note.id, note=note)
|
return CreateNoteResponse(
|
||||||
|
id=note.id, title=note.title, category=note.category
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
@@ -128,7 +130,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
category=category,
|
category=category,
|
||||||
)
|
)
|
||||||
note = Note(**note_data)
|
note = Note(**note_data)
|
||||||
return UpdateNoteResponse(note=note)
|
return UpdateNoteResponse(
|
||||||
|
id=note.id, title=note.title, category=note.category
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
return ErrorResponse(error=f"Note {note_id} not found")
|
return ErrorResponse(error=f"Note {note_id} not found")
|
||||||
@@ -151,7 +155,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
note_id: int, content: str, ctx: Context
|
note_id: int, content: str, ctx: Context
|
||||||
) -> AppendContentResponse | ErrorResponse:
|
) -> AppendContentResponse | ErrorResponse:
|
||||||
"""Append content to an existing note with a clear separator"""
|
"""Append content to an existing note with a clear separator. The tool automatically adds separators between existing and new content - do not include separators in your content."""
|
||||||
logger.info("Appending content to note %s", note_id)
|
logger.info("Appending content to note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
try:
|
try:
|
||||||
@@ -159,7 +163,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
note_id=note_id, content=content
|
note_id=note_id, content=content
|
||||||
)
|
)
|
||||||
note = Note(**note_data)
|
note = Note(**note_data)
|
||||||
return AppendContentResponse(note=note)
|
return AppendContentResponse(
|
||||||
|
id=note.id, title=note.title, category=note.category
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
return ErrorResponse(error=f"Note {note_id} not found")
|
return ErrorResponse(error=f"Note {note_id} not found")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.7.2"
|
version = "0.8.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Unit tests for Pydantic models and serialization."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.base import BaseResponse, SuccessResponse
|
||||||
|
|
||||||
|
|
||||||
|
def test_timestamp_format_validation():
|
||||||
|
"""Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation.
|
||||||
|
|
||||||
|
This test should initially fail, demonstrating the timestamp validation error
|
||||||
|
seen in MCP inspector. MCP expects RFC3339 format with timezone information.
|
||||||
|
"""
|
||||||
|
# Create a response object
|
||||||
|
response = SuccessResponse(message="Test message")
|
||||||
|
|
||||||
|
# Serialize to JSON (mimics what MCP inspector sees)
|
||||||
|
json_str = response.model_dump_json()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
|
||||||
|
timestamp_str = data["timestamp"]
|
||||||
|
|
||||||
|
# RFC3339 regex pattern (what MCP expects)
|
||||||
|
# Format: YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]
|
||||||
|
rfc3339_pattern = (
|
||||||
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# This assertion should FAIL with current implementation
|
||||||
|
assert re.match(rfc3339_pattern, timestamp_str), (
|
||||||
|
f"Timestamp '{timestamp_str}' is not RFC3339 compliant. "
|
||||||
|
f"MCP expects format like '2025-08-30T19:22:58.862377Z' or '2025-08-30T19:22:58.862377+00:00'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_response_timestamp_is_utc():
|
||||||
|
"""Test that BaseResponse timestamps are in UTC timezone."""
|
||||||
|
response = BaseResponse()
|
||||||
|
|
||||||
|
# The timestamp should be timezone-aware and in UTC
|
||||||
|
assert response.timestamp.tzinfo is not None, (
|
||||||
|
"Timestamp should have timezone information"
|
||||||
|
)
|
||||||
|
assert response.timestamp.tzinfo == timezone.utc, (
|
||||||
|
"Timestamp should be in UTC timezone"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialized_timestamp_ends_with_z_or_offset():
|
||||||
|
"""Test that serialized timestamps have proper timezone suffix."""
|
||||||
|
response = BaseResponse()
|
||||||
|
json_str = response.model_dump_json()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
|
||||||
|
timestamp_str = data["timestamp"]
|
||||||
|
|
||||||
|
# Should end with 'Z' (UTC) or timezone offset like '+00:00'
|
||||||
|
assert timestamp_str.endswith("Z") or re.search(
|
||||||
|
r"[+-]\d{2}:\d{2}$", timestamp_str
|
||||||
|
), (
|
||||||
|
f"Timestamp '{timestamp_str}' should end with 'Z' or timezone offset like '+00:00'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_broken_format():
|
||||||
|
"""Test showing the current broken timestamp format that causes MCP validation errors."""
|
||||||
|
# This demonstrates what the current code produces
|
||||||
|
current_naive_dt = datetime.now()
|
||||||
|
current_format = current_naive_dt.isoformat()
|
||||||
|
|
||||||
|
# Show that current format lacks timezone info
|
||||||
|
assert "Z" not in current_format
|
||||||
|
assert "+" not in current_format
|
||||||
|
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
|
||||||
|
|
||||||
|
print(f"Current broken format: {current_format}")
|
||||||
|
print(
|
||||||
|
"This format causes MCP validation errors because it lacks timezone information"
|
||||||
|
)
|
||||||
@@ -505,7 +505,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.7.2"
|
version = "0.8.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|||||||
Reference in New Issue
Block a user