Skip to content

Commit 7c4c028

Browse files
authored
Merge pull request #140 from cbcoutinho/feature/etag
fix(notes): Include ETags in responses to avoid accidently updates
2 parents daeb95f + 892340f commit 7c4c028

File tree

9 files changed

+221
-104
lines changed

9 files changed

+221
-104
lines changed

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,19 @@ Each Nextcloud app has a corresponding server module that:
102102
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
103103

104104
#### Testing Best Practices
105-
- **Always restart MCP server** after code changes with `docker-compose up --build -d mcp`
105+
- **MANDATORY: Always run tests after implementing features or fixing bugs**
106+
- Run tests to completion before considering any task complete
107+
- If tests require modifications to pass, ask for permission before proceeding
108+
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
106109
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
107110
- `nc_mcp_client` - MCP client session for tool/resource testing
108111
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
109112
- `temporary_note` - Creates and cleans up test notes automatically
110113
- `temporary_addressbook` - Creates and cleans up test address books
111114
- `temporary_contact` - Creates and cleans up test contacts
115+
- **Test specific functionality** after changes:
116+
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
117+
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
112118
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
113119

114120
### Configuration Files

nextcloud_mcp_server/models/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
# Base models
44
from .base import (
55
BaseResponse,
6-
ErrorResponse,
7-
SuccessResponse,
86
IdResponse,
97
StatusResponse,
108
)
@@ -82,8 +80,6 @@
8280
__all__ = [
8381
# Base models
8482
"BaseResponse",
85-
"ErrorResponse",
86-
"SuccessResponse",
8783
"IdResponse",
8884
"StatusResponse",
8985
# Notes models

nextcloud_mcp_server/models/base.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base Pydantic models for common response patterns."""
22

33
from datetime import datetime, timezone
4-
from typing import Any, Dict, Optional, Union
4+
from typing import Optional, Union
55

66
from pydantic import BaseModel, Field, field_serializer
77

@@ -35,24 +35,6 @@ def serialize_timestamp(self, timestamp: datetime) -> str:
3535
return iso_string
3636

3737

38-
class ErrorResponse(BaseResponse):
39-
"""Response model for error cases."""
40-
41-
success: bool = Field(default=False, description="Always False for error responses")
42-
error: str = Field(description="Error message")
43-
error_code: Optional[str] = Field(None, description="Optional error code")
44-
details: Optional[Dict[str, Any]] = Field(
45-
None, description="Additional error details"
46-
)
47-
48-
49-
class SuccessResponse(BaseResponse):
50-
"""Generic success response."""
51-
52-
message: Optional[str] = Field(None, description="Optional success message")
53-
data: Optional[Dict[str, Any]] = Field(None, description="Optional response data")
54-
55-
5638
class IdResponse(BaseResponse):
5739
"""Response model for operations that return a new ID."""
5840

nextcloud_mcp_server/models/notes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Note(BaseModel):
1919
favorite: bool = Field(
2020
default=False, description="Whether note is marked as favorite"
2121
)
22-
etag: Optional[str] = Field(None, description="ETag for versioning")
22+
etag: str = Field(description="ETag for versioning")
2323
readonly: bool = Field(default=False, description="Whether note is read-only")
2424

2525
@property
@@ -50,6 +50,7 @@ class CreateNoteResponse(IdResponse):
5050

5151
title: str = Field(description="The created note title")
5252
category: str = Field(description="The created note category")
53+
etag: str = Field(description="Current ETag for the created note")
5354

5455

5556
class UpdateNoteResponse(BaseResponse):
@@ -58,6 +59,7 @@ class UpdateNoteResponse(BaseResponse):
5859
id: int = Field(description="The updated note ID")
5960
title: str = Field(description="The updated note title")
6061
category: str = Field(description="The updated note category")
62+
etag: str = Field(description="Current ETag for the updated note")
6163

6264

6365
class DeleteNoteResponse(StatusResponse):
@@ -72,6 +74,7 @@ class AppendContentResponse(BaseResponse):
7274
id: int = Field(description="The updated note ID")
7375
title: str = Field(description="The updated note title")
7476
category: str = Field(description="The updated note category")
77+
etag: str = Field(description="Current ETag for the updated note")
7578

7679

7780
class SearchNotesResponse(BaseResponse):

nextcloud_mcp_server/server/notes.py

Lines changed: 94 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import logging
22
from httpx import HTTPStatusError
3+
from mcp.shared.exceptions import McpError
4+
from mcp.types import ErrorData
35

46
from mcp.server.fastmcp import Context, FastMCP
57

68
from nextcloud_mcp_server.client import NextcloudClient
7-
from nextcloud_mcp_server.models.base import ErrorResponse
89
from nextcloud_mcp_server.models.notes import (
910
Note,
1011
NotesSettings,
@@ -54,8 +55,6 @@ async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
5455
@mcp.resource("nc://Notes/{note_id}")
5556
async def nc_get_note(note_id: int):
5657
"""Get user note using note id"""
57-
from mcp.shared.exceptions import McpError
58-
from mcp.types import ErrorData
5958

6059
ctx: Context = mcp.get_context()
6160
client: NextcloudClient = ctx.request_context.lifespan_context.client
@@ -80,7 +79,7 @@ async def nc_get_note(note_id: int):
8079
@mcp.tool()
8180
async def nc_notes_create_note(
8281
title: str, content: str, category: str, ctx: Context
83-
) -> CreateNoteResponse | ErrorResponse:
82+
) -> CreateNoteResponse:
8483
"""Create a new note"""
8584
client: NextcloudClient = ctx.request_context.lifespan_context.client
8685
try:
@@ -91,22 +90,31 @@ async def nc_notes_create_note(
9190
)
9291
note = Note(**note_data)
9392
return CreateNoteResponse(
94-
id=note.id, title=note.title, category=note.category
93+
id=note.id, title=note.title, category=note.category, etag=note.etag
9594
)
9695
except HTTPStatusError as e:
9796
if e.response.status_code == 403:
98-
return ErrorResponse(
99-
error="Access denied: insufficient permissions to create notes"
97+
raise McpError(
98+
ErrorData(
99+
code=-1,
100+
message="Access denied: insufficient permissions to create notes",
101+
)
100102
)
101103
elif e.response.status_code == 413:
102-
return ErrorResponse(error="Note content too large")
104+
raise McpError(ErrorData(code=-1, message="Note content too large"))
103105
elif e.response.status_code == 409:
104-
return ErrorResponse(
105-
error=f"A note with title '{title}' already exists in this category"
106+
raise McpError(
107+
ErrorData(
108+
code=-1,
109+
message=f"A note with title '{title}' already exists in this category",
110+
)
106111
)
107112
else:
108-
return ErrorResponse(
109-
error=f"Failed to create note: server error ({e.response.status_code})"
113+
raise McpError(
114+
ErrorData(
115+
code=-1,
116+
message=f"Failed to create note: server error ({e.response.status_code})",
117+
)
110118
)
111119

112120
@mcp.tool()
@@ -117,8 +125,13 @@ async def nc_notes_update_note(
117125
content: str | None,
118126
category: str | None,
119127
ctx: Context,
120-
) -> UpdateNoteResponse | ErrorResponse:
121-
"""Update an existing note's title, content, or category"""
128+
) -> UpdateNoteResponse:
129+
"""Update an existing note's title, content, or category.
130+
131+
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
132+
Get the current ETag by first retrieving the note using nc://Notes/{note_id} resource.
133+
If the note has been modified by someone else since you retrieved it,
134+
the update will fail with a 412 error."""
122135
logger.info("Updating note %s", note_id)
123136
client: NextcloudClient = ctx.request_context.lifespan_context.client
124137
try:
@@ -131,31 +144,44 @@ async def nc_notes_update_note(
131144
)
132145
note = Note(**note_data)
133146
return UpdateNoteResponse(
134-
id=note.id, title=note.title, category=note.category
147+
id=note.id, title=note.title, category=note.category, etag=note.etag
135148
)
136149
except HTTPStatusError as e:
137150
if e.response.status_code == 404:
138-
return ErrorResponse(error=f"Note {note_id} not found")
151+
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
139152
elif e.response.status_code == 412:
140-
return ErrorResponse(
141-
error=f"Note {note_id} has been modified by someone else. Please refresh and try again."
153+
raise McpError(
154+
ErrorData(
155+
code=-1,
156+
message=f"Note {note_id} has been modified by someone else. Please refresh and try again.",
157+
)
142158
)
143159
elif e.response.status_code == 403:
144-
return ErrorResponse(
145-
error=f"Access denied: insufficient permissions to update note {note_id}"
160+
raise McpError(
161+
ErrorData(
162+
code=-1,
163+
message=f"Access denied: insufficient permissions to update note {note_id}",
164+
)
146165
)
147166
elif e.response.status_code == 413:
148-
return ErrorResponse(error="Updated note content is too large")
167+
raise McpError(
168+
ErrorData(code=-1, message="Updated note content is too large")
169+
)
149170
else:
150-
return ErrorResponse(
151-
error=f"Failed to update note {note_id}: server error ({e.response.status_code})"
171+
raise McpError(
172+
ErrorData(
173+
code=-1,
174+
message=f"Failed to update note {note_id}: server error ({e.response.status_code})",
175+
)
152176
)
153177

154178
@mcp.tool()
155179
async def nc_notes_append_content(
156180
note_id: int, content: str, ctx: Context
157-
) -> AppendContentResponse | ErrorResponse:
158-
"""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."""
181+
) -> AppendContentResponse:
182+
"""Append content to an existing note. The tool adds a `\n---\n`
183+
between the note and what will be appended."""
184+
159185
logger.info("Appending content to note %s", note_id)
160186
client: NextcloudClient = ctx.request_context.lifespan_context.client
161187
try:
@@ -164,28 +190,35 @@ async def nc_notes_append_content(
164190
)
165191
note = Note(**note_data)
166192
return AppendContentResponse(
167-
id=note.id, title=note.title, category=note.category
193+
id=note.id, title=note.title, category=note.category, etag=note.etag
168194
)
169195
except HTTPStatusError as e:
170196
if e.response.status_code == 404:
171-
return ErrorResponse(error=f"Note {note_id} not found")
197+
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
172198
elif e.response.status_code == 403:
173-
return ErrorResponse(
174-
error=f"Access denied: insufficient permissions to modify note {note_id}"
199+
raise McpError(
200+
ErrorData(
201+
code=-1,
202+
message=f"Access denied: insufficient permissions to modify note {note_id}",
203+
)
175204
)
176205
elif e.response.status_code == 413:
177-
return ErrorResponse(
178-
error="Content to append would make the note too large"
206+
raise McpError(
207+
ErrorData(
208+
code=-1,
209+
message="Content to append would make the note too large",
210+
)
179211
)
180212
else:
181-
return ErrorResponse(
182-
error=f"Failed to append content to note {note_id}: server error ({e.response.status_code})"
213+
raise McpError(
214+
ErrorData(
215+
code=-1,
216+
message=f"Failed to append content to note {note_id}: server error ({e.response.status_code})",
217+
)
183218
)
184219

185220
@mcp.tool()
186-
async def nc_notes_search_notes(
187-
query: str, ctx: Context
188-
) -> SearchNotesResponse | ErrorResponse:
221+
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
189222
"""Search notes by title or content, returning only id, title, and category."""
190223
client: NextcloudClient = ctx.request_context.lifespan_context.client
191224
try:
@@ -207,20 +240,26 @@ async def nc_notes_search_notes(
207240
)
208241
except HTTPStatusError as e:
209242
if e.response.status_code == 403:
210-
return ErrorResponse(
211-
error="Access denied: insufficient permissions to search notes"
243+
raise McpError(
244+
ErrorData(
245+
code=-1,
246+
message="Access denied: insufficient permissions to search notes",
247+
)
212248
)
213249
elif e.response.status_code == 400:
214-
return ErrorResponse(error="Invalid search query format")
250+
raise McpError(
251+
ErrorData(code=-1, message="Invalid search query format")
252+
)
215253
else:
216-
return ErrorResponse(
217-
error=f"Search failed: server error ({e.response.status_code})"
254+
raise McpError(
255+
ErrorData(
256+
code=-1,
257+
message=f"Search failed: server error ({e.response.status_code})",
258+
)
218259
)
219260

220261
@mcp.tool()
221-
async def nc_notes_delete_note(
222-
note_id: int, ctx: Context
223-
) -> DeleteNoteResponse | ErrorResponse:
262+
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
224263
"""Delete a note permanently"""
225264
logger.info("Deleting note %s", note_id)
226265
client: NextcloudClient = ctx.request_context.lifespan_context.client
@@ -233,12 +272,18 @@ async def nc_notes_delete_note(
233272
)
234273
except HTTPStatusError as e:
235274
if e.response.status_code == 404:
236-
return ErrorResponse(error=f"Note {note_id} not found")
275+
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
237276
elif e.response.status_code == 403:
238-
return ErrorResponse(
239-
error=f"Access denied: insufficient permissions to delete note {note_id}"
277+
raise McpError(
278+
ErrorData(
279+
code=-1,
280+
message=f"Access denied: insufficient permissions to delete note {note_id}",
281+
)
240282
)
241283
else:
242-
return ErrorResponse(
243-
error=f"Failed to delete note {note_id}: server error ({e.response.status_code})"
284+
raise McpError(
285+
ErrorData(
286+
code=-1,
287+
message=f"Failed to delete note {note_id}: server error ({e.response.status_code})",
288+
)
244289
)

nextcloud_mcp_server/server/webdav.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ async def nc_webdav_read_file(path: str, ctx: Context):
4343
Examples:
4444
# Read a text file
4545
result = await nc_webdav_read_file("Documents/readme.txt")
46-
print(result['content']) # Decoded text content
46+
logger.info(result['content']) # Decoded text content
4747
4848
# Read a binary file
4949
result = await nc_webdav_read_file("Images/photo.jpg")
50-
print(result['encoding']) # 'base64'
50+
logger.info(result['encoding']) # 'base64'
5151
"""
5252
client: NextcloudClient = ctx.request_context.lifespan_context.client
5353
content, content_type = await client.webdav.read_file(path)

0 commit comments

Comments
 (0)