Skip to main content
Glama
cbcoutinho

Nextcloud MCP Server

by cbcoutinho
ADR-017-mcp-tool-annotations.md16.7 kB
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX ## Status Implemented ## Context The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in: - Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note") - No behavioral hints for clients about read-only, destructive, or idempotent operations - Missing parameter descriptions for better auto-completion and inline help - Clients cannot optimize caching, warn before destructive operations, or retry safely ### Available MCP Annotations The MCP SDK provides three types of annotations: #### 1. Tool Decorator Parameters ```python @mcp.tool( title="Human-Readable Name", description="Tool description", # Can also come from docstring annotations=ToolAnnotations(...), icons=[Icon(...)] # Optional visual icons ) ``` #### 2. ToolAnnotations Behavioral Hints ```python from mcp.types import ToolAnnotations ToolAnnotations( title="Alternative Title", # Decorator title takes precedence readOnlyHint=True, # Tool doesn't modify data destructiveHint=True, # Tool may delete/overwrite data idempotentHint=True, # Repeated calls with same args are safe openWorldHint=True # Interacts with external entities ) ``` #### 3. Parameter Descriptions ```python from pydantic import Field async def tool( param: str = Field(description="What this parameter does"), ctx: Context ): ``` ### Idempotency Analysis **Important**: Idempotency means calling with **the same inputs** produces the same result. **NOT Idempotent** (different inputs each call): - **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def" - Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch) - Different input (stale etag) → different result (error) - **Creates**: `create_note(title="X")` → creates note 1 - Second call → creates note 2 (different result) - **Append operations**: `append_content(id=1, text="X")` → adds X once - Second call → adds X again (different result) **Idempotent**: - **Deletes**: `delete_note(id=1)` → note deleted - Second call → 404 or success (same end state: note doesn't exist) - Note: May return different status code, but end state is identical - **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello" - Second call → file still has "Hello" (same end state) - Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control - **Set operations**: `set_property(id=1, value="X")` → property = X - Second call → property still = X (same result) - Note: Nextcloud updates with etags use version control, so not idempotent **Read-Only** (always idempotent, never destructive): - All list, search, get operations ## Decision Add annotations to all 101 tools in three phases: ### Phase 1: Titles (Quick Win) Add human-readable titles to all tools: ```python @mcp.tool(title="Create Note") async def nc_notes_create_note(...): ``` **Effort**: 2-3 hours **Impact**: Immediate UX improvement ### Phase 2: ToolAnnotations (Behavioral Hints) Add annotations based on corrected categorization: ```python # Read-only tools @mcp.tool( title="Search Notes", annotations=ToolAnnotations( readOnlyHint=True, openWorldHint=True # Nextcloud is external to MCP server ) ) # Delete tools (idempotent: same end state) @mcp.tool( title="Delete Note", annotations=ToolAnnotations( destructiveHint=True, idempotentHint=True, # Deleting deleted item = same end state openWorldHint=True ) ) # Create tools (not idempotent: creates multiple items) @mcp.tool( title="Create Note", annotations=ToolAnnotations( idempotentHint=False, openWorldHint=True ) ) # Update tools with etag (not idempotent: etag changes) @mcp.tool( title="Update Note", annotations=ToolAnnotations( idempotentHint=False, # Etag required = different inputs each time openWorldHint=True ) ) # Append operations (not idempotent: adds content each time) @mcp.tool( title="Append to Note", annotations=ToolAnnotations( idempotentHint=False, openWorldHint=True ) ) ``` **Effort**: 4-6 hours **Impact**: Better client behavior (caching, warnings, retry logic) ### Phase 3: Parameter Descriptions Add Field() descriptions to parameters: ```python from pydantic import Field @mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False)) async def nc_notes_create_note( title: str = Field(description="The title of the note"), content: str = Field(description="Markdown content of the note"), category: str = Field(description="Category or folder name for organizing"), ctx: Context ) -> CreateNoteResponse: ``` **Effort**: 6-8 hours **Impact**: Better auto-completion and inline help ## Tool Categorization ### Read-Only Tools (~40 tools) **Pattern**: List, search, get operations **Annotations**: `readOnlyHint=True`, `openWorldHint=True` Examples: - `nc_notes_search_notes` → "Search Notes" - `nc_webdav_list_directory` → "List Files and Directories" - `nc_calendar_list_calendars` → "List Calendars" - `nc_contacts_get_contact` → "Get Contact" - `nc_semantic_search` → "Semantic Search" - `check_logged_in` → "Check Server Login Status" ### Create Tools (~20 tools) **Pattern**: Create new resources **Annotations**: `idempotentHint=False`, `openWorldHint=True` Examples: - `nc_notes_create_note` → "Create Note" - `nc_calendar_create_event` → "Create Calendar Event" - `nc_contacts_create_contact` → "Create Contact" - `deck_create_card` → "Create Kanban Card" - `nc_tables_create_row` → "Create Table Row" ### Update Tools (~25 tools) **Pattern**: Modify existing resources with etag **Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True` Examples: - `nc_notes_update_note` → "Update Note" - `nc_calendar_update_event` → "Update Calendar Event" - `nc_contacts_update_contact` → "Update Contact" - `deck_update_card` → "Update Kanban Card" **Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent. ### Append/Accumulate Tools (~5 tools) **Pattern**: Add content without replacing **Annotations**: `idempotentHint=False`, `openWorldHint=True` Examples: - `nc_notes_append_content` → "Append to Note" **Rationale**: Each call adds content, changing the result = NOT idempotent. ### Delete Tools (~10 tools) **Pattern**: Remove resources **Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True` Examples: - `nc_notes_delete_note` → "Delete Note" - `nc_webdav_delete_resource` → "Delete File or Directory" - `nc_calendar_delete_event` → "Delete Calendar Event" - `nc_contacts_delete_contact` → "Delete Contact" **Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical. ### Special Cases #### OAuth Provisioning Tools ```python # Not read-only but requires user interaction @mcp.tool( title="Grant Server Access to Nextcloud", annotations=ToolAnnotations( readOnlyHint=False, idempotentHint=False, # Creates new OAuth session each time openWorldHint=True ) ) async def provision_nextcloud_access(ctx: Context): ``` #### Semantic Search (Closed World) ```python @mcp.tool( title="Semantic Search", annotations=ToolAnnotations( readOnlyHint=True, openWorldHint=False # Searches only indexed Nextcloud data ) ) async def nc_semantic_search(query: str, ctx: Context): ``` **Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would. ## Tool Priority Matrix ### Critical Priority (~2 tools) OAuth tools required for server functionality: - `provision_nextcloud_access` → "Grant Server Access to Nextcloud" - `check_logged_in` → "Check Server Login Status" ### High Priority (~50 tools) Most commonly used modules: - **Notes** (14 tools): Create, read, update, delete notes - **WebDAV** (13 tools): File operations - **Calendar** (15 tools): Events and todos - **Semantic Search** (6 tools): AI-powered search - **Contacts** (9 tools): Address book operations ### Medium Priority (~35 tools) Secondary functionality: - **Deck** (9 tools): Kanban boards - **Tables** (7 tools): Structured data - **Sharing** (5 tools): File sharing ### Low Priority (~14 tools) Less frequently used: - **Cookbook** (8 tools): Recipe management - **News** (6 tools): RSS feeds ## Implementation Plan ### Week 1: Phase 1 - Titles - Add human-readable titles to all 101 tools - Update tool name mapping in documentation - Manual test in MCP inspector ### Week 2: Phase 2 - ToolAnnotations (High Priority) - Add annotations to Critical and High priority tools (~52 tools) - Focus on Notes, WebDAV, Calendar, Semantic, OAuth - Add unit tests validating annotation presence ### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority) - Complete remaining tools (~49 tools) - Deck, Tables, Contacts, Cookbook, News - Update tool listings in README ### Week 4: Phase 3 - Parameter Descriptions - Add Field() descriptions to Critical/High priority tools - Start with OAuth, Notes, WebDAV modules - Incremental completion over time ## Benefits ### For Users - **Clearer UI**: "Create Note" vs "nc_notes_create_note" - **Safety**: Warnings before destructive operations - **Better help**: Parameter descriptions in auto-completion - **Confidence**: Know which operations are safe to retry ### For MCP Clients - **Caching**: Cache results from read-only tools - **Safety prompts**: Warn before destructiveHint=true - **Retry logic**: Safely retry idempotent operations - **UI organization**: Group by behavior (reads vs writes vs deletes) - **Performance**: Optimize based on hints ### For Developers - **Self-documenting**: Behavior is explicit - **Consistency**: Standard patterns across codebase - **Testing**: Validate annotations match implementation - **Maintenance**: Clear expectations for new tools ## Consequences ### Positive - Immediate UX improvement with minimal effort - Clients can make smarter decisions - Self-documenting code - Follows MCP best practices ### Negative - Initial effort to add annotations (12-15 hours total) - Must maintain annotations when adding new tools - Risk of incorrect annotations misleading clients ### Neutral - Annotations are hints, not guarantees - Clients may ignore annotations - Backward compatible (additive change) ### Mitigations - **Incorrect annotations**: Add tests validating behavior matches hints - **Maintenance burden**: Add to code review checklist and tool template - **Documentation**: Update CLAUDE.md with annotation guidelines ## Examples ### Complete Annotated Tool (Delete) ```python from mcp.types import ToolAnnotations from pydantic import Field @mcp.tool( title="Delete Note", annotations=ToolAnnotations( destructiveHint=True, # Deletes data permanently idempotentHint=True, # Same end state (note doesn't exist) openWorldHint=True # Nextcloud is external ) ) @require_scopes("notes:write") @instrument_tool async def nc_notes_delete_note( note_id: int = Field(description="The ID of the note to delete permanently"), ctx: Context ) -> DeleteNoteResponse: """Delete a note permanently (requires notes:write scope)""" client = await get_client(ctx) # ... implementation ... ``` ### Complete Annotated Tool (Update) ```python @mcp.tool( title="Update Note", annotations=ToolAnnotations( idempotentHint=False, # NOT idempotent: etag changes each update openWorldHint=True ) ) @require_scopes("notes:write") @instrument_tool async def nc_notes_update_note( note_id: int = Field(description="The ID of the note to update"), title: str | None = Field( default=None, description="New title (omit to keep current)" ), content: str | None = Field( default=None, description="New markdown content (omit to keep current)" ), category: str | None = Field( default=None, description="New category/folder (omit to keep current)" ), etag: str = Field( description="ETag from get_note (prevents concurrent modification)" ), ctx: Context ) -> UpdateNoteResponse: """Update an existing note's title, content, or category. The etag parameter is required to prevent overwriting concurrent changes. Get the current ETag by first calling nc_notes_get_note. If the note has been modified since you retrieved it, the update will fail. """ client = await get_client(ctx) # ... implementation ... ``` ### Complete Annotated Tool (Read-Only) ```python @mcp.tool( title="Search Notes", annotations=ToolAnnotations( readOnlyHint=True, # Doesn't modify data openWorldHint=True # Queries Nextcloud ) ) @require_scopes("notes:read") @instrument_tool async def nc_notes_search_notes( query: str = Field(description="Search term to match in note titles or content"), ctx: Context ) -> SearchNotesResponse: """Search notes by title or content, returning id, title, and category. This is a read-only operation that searches across all user notes. Use nc_notes_get_note to retrieve the full content of matching notes. """ client = await get_client(ctx) # ... implementation ... ``` ## Testing Strategy ### Unit Tests Add tests validating annotation presence and correctness: ```python def test_notes_tools_have_annotations(): """Verify all notes tools have appropriate annotations.""" tools = get_registered_tools(mcp) # Check create tool create_tool = tools["nc_notes_create_note"] assert create_tool.title == "Create Note" assert create_tool.annotations.idempotentHint is False # Check delete tool delete_tool = tools["nc_notes_delete_note"] assert delete_tool.title == "Delete Note" assert delete_tool.annotations.destructiveHint is True assert delete_tool.annotations.idempotentHint is True # Check read-only tool search_tool = tools["nc_notes_search_notes"] assert search_tool.title == "Search Notes" assert search_tool.annotations.readOnlyHint is True ``` ### Integration Tests - Verify existing tests pass with annotations - Manual testing in MCP inspector/client ### Documentation Updates - Update README tool listings with new titles - Add annotation guidelines to CLAUDE.md - Include examples in developer documentation ## Resolved Questions 1. **WebDAV write_file idempotency** (Resolved: 2025-12-11) - **Decision**: Mark as `idempotentHint=True` - **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics. 2. **Semantic search openWorldHint** (Resolved: 2025-12-11) - **Decision**: Mark as `openWorldHint=True` - **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries. 3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true? - **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content. ## Future Considerations 1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR) 2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work) ## References - MCP Python SDK: `/home/chris/Software/python-sdk/` - ToolAnnotations spec: `src/mcp/types.py:1247` - FastMCP decorator: `src/mcp/server/fastmcp/server.py:444` - Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py` ## Decision Timeline - **Proposed**: 2025-12-11 - **Reviewed**: 2025-12-11 (Self-review during implementation) - **Accepted**: 2025-12-11 - **Implemented**: 2025-12-11 (Phase 1 & 2 complete)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cbcoutinho/nextcloud-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server