diff --git a/docs/ADR-017-mcp-tool-annotations.md b/docs/ADR-017-mcp-tool-annotations.md new file mode 100644 index 0000000..ada5f7f --- /dev/null +++ b/docs/ADR-017-mcp-tool-annotations.md @@ -0,0 +1,492 @@ +# ADR-017: Add MCP Tool Annotations for Enhanced Client UX + +## Status + +Proposed + +## 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**: Would be idempotent (we don't have these) +- **Set operations**: `set_property(id=1, value="X")` → property = X + - Second call → property still = X (same result) + - Note: Nextcloud updates use etags, so not applicable + +**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 + +## Open Questions + +1. **Icons**: Should we add visual icons for tools? (Requires design work) +2. **Semantic search openWorldHint**: False (internal data only) or True (Nextcloud is external)? +3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true? + +## 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**: TBD +- **Accepted**: TBD +- **Implemented**: TBD diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 53fa2ba..b54ae82 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -3,6 +3,7 @@ import logging from typing import Optional from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -19,7 +20,10 @@ logger = logging.getLogger(__name__) def configure_calendar_tools(mcp: FastMCP): # Calendar tools - @mcp.tool() + @mcp.tool( + title="List Calendars", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("calendar:read") @instrument_tool async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse: @@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP): calendars = [Calendar(**cal_data) for cal_data in calendars_data] return ListCalendarsResponse(calendars=calendars, total_count=len(calendars)) - @mcp.tool() + @mcp.tool( + title="Create Calendar Event", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_create_event( @@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_event(calendar_name, event_data) - @mcp.tool() + @mcp.tool( + title="List Calendar Events", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("calendar:read") @instrument_tool async def nc_calendar_list_events( @@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP): return events - @mcp.tool() + @mcp.tool( + title="Get Calendar Event", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("calendar:read") @instrument_tool async def nc_calendar_get_event( @@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP): event_data, etag = await client.calendar.get_event(calendar_name, event_uid) return event_data - @mcp.tool() + @mcp.tool( + title="Update Calendar Event", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_update_event( @@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP): calendar_name, event_uid, event_data, etag ) - @mcp.tool() + @mcp.tool( + title="Delete Calendar Event", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_delete_event( @@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP): client = await get_client(ctx) return await client.calendar.delete_event(calendar_name, event_uid) - @mcp.tool() + @mcp.tool( + title="Create Meeting", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_create_meeting( @@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_event(calendar_name, event_data) - @mcp.tool() + @mcp.tool( + title="Get Upcoming Events", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("calendar:read") @instrument_tool async def nc_calendar_get_upcoming_events( @@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP): all_events.sort(key=lambda x: x.get("start_datetime", "")) return all_events[:limit] - @mcp.tool() + @mcp.tool( + title="Find Availability", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("calendar:read") @instrument_tool async def nc_calendar_find_availability( @@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP): constraints=constraints, ) - @mcp.tool() + @mcp.tool( + title="Bulk Calendar Operations", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_bulk_operations( @@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP): "results": results, } - @mcp.tool() + @mcp.tool( + title="Manage Calendar", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("calendar:write") @instrument_tool async def nc_calendar_manage_calendar( @@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP): # ============= Todo/Task Tools ============= - @mcp.tool() + @mcp.tool( + title="List Todo Tasks", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("todo:read", "calendar:read") @instrument_tool async def nc_calendar_list_todos( @@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP): todos=todos, calendar_name=calendar_name, total_count=len(todos) ) - @mcp.tool() + @mcp.tool( + title="Create Todo Task", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("todo:write", "calendar:read") @instrument_tool async def nc_calendar_create_todo( @@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_todo(calendar_name, todo_data) - @mcp.tool() + @mcp.tool( + title="Update Todo Task", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("todo:write", "calendar:read") @instrument_tool async def nc_calendar_update_todo( @@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.update_todo(calendar_name, todo_uid, todo_data) - @mcp.tool() + @mcp.tool( + title="Delete Todo Task", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("todo:write", "calendar:read") @instrument_tool async def nc_calendar_delete_todo( @@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP): client = await get_client(ctx) return await client.calendar.delete_todo(calendar_name, todo_uid) - @mcp.tool() + @mcp.tool( + title="Search Todo Tasks", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("todo:read", "calendar:read") @instrument_tool async def nc_calendar_search_todos( diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index 64657ec..9f5eafa 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -1,6 +1,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -11,7 +12,10 @@ logger = logging.getLogger(__name__) def configure_contacts_tools(mcp: FastMCP): # Contacts tools - @mcp.tool() + @mcp.tool( + title="List Address Books", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("contacts:read") @instrument_tool async def nc_contacts_list_addressbooks(ctx: Context): @@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP): client = await get_client(ctx) return await client.contacts.list_addressbooks() - @mcp.tool() + @mcp.tool( + title="List Contacts", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("contacts:read") @instrument_tool async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str): @@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP): client = await get_client(ctx) return await client.contacts.list_contacts(addressbook=addressbook) - @mcp.tool() + @mcp.tool( + title="Create Address Book", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("contacts:write") @instrument_tool async def nc_contacts_create_addressbook( @@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP): name=name, display_name=display_name ) - @mcp.tool() + @mcp.tool( + title="Delete Address Book", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("contacts:write") @instrument_tool async def nc_contacts_delete_addressbook(ctx: Context, *, name: str): @@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP): client = await get_client(ctx) return await client.contacts.delete_addressbook(name=name) - @mcp.tool() + @mcp.tool( + title="Create Contact", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("contacts:write") @instrument_tool async def nc_contacts_create_contact( @@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP): addressbook=addressbook, uid=uid, contact_data=contact_data ) - @mcp.tool() + @mcp.tool( + title="Delete Contact", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("contacts:write") @instrument_tool async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str): @@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP): client = await get_client(ctx) return await client.contacts.delete_contact(addressbook=addressbook, uid=uid) - @mcp.tool() + @mcp.tool( + title="Update Contact", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("contacts:write") @instrument_tool async def nc_contacts_update_contact( diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index f83271c..79e8e88 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -3,7 +3,7 @@ import logging from httpx import HTTPStatusError, RequestError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError -from mcp.types import ErrorData +from mcp.types import ErrorData, ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -71,7 +71,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Import Recipe from URL", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse: @@ -129,7 +132,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="List Recipes", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse: @@ -155,7 +161,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Recipe", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe: @@ -181,7 +190,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Create Recipe", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_create_recipe( @@ -261,7 +273,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Update Recipe", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_update_recipe( @@ -351,7 +366,12 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Delete Recipe", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_delete_recipe( @@ -387,7 +407,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Search Recipes", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_search_recipes( @@ -424,7 +447,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="List Recipe Categories", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse: @@ -452,7 +478,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Recipes in Category", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_get_recipes_in_category( @@ -489,7 +518,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="List Recipe Keywords", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse: @@ -515,7 +547,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Recipes with Keywords", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("cookbook:read") @instrument_tool async def nc_cookbook_get_recipes_with_keywords( @@ -550,7 +585,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Set Cookbook Configuration", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_set_config( @@ -594,7 +632,10 @@ def configure_cookbook_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Reindex Recipes", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("cookbook:write") @instrument_tool async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse: diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 51e5c22..65759c9 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -2,6 +2,7 @@ import logging from typing import Optional from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -117,7 +118,10 @@ def configure_deck_tools(mcp: FastMCP): # Read Tools (converted from resources) - @mcp.tool() + @mcp.tool( + title="List Deck Boards", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_boards(ctx: Context) -> list[DeckBoard]: @@ -126,7 +130,10 @@ def configure_deck_tools(mcp: FastMCP): boards = await client.deck.get_boards() return boards - @mcp.tool() + @mcp.tool( + title="Get Deck Board", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: @@ -135,7 +142,10 @@ def configure_deck_tools(mcp: FastMCP): board = await client.deck.get_board(board_id) return board - @mcp.tool() + @mcp.tool( + title="List Deck Stacks", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: @@ -144,7 +154,10 @@ def configure_deck_tools(mcp: FastMCP): stacks = await client.deck.get_stacks(board_id) return stacks - @mcp.tool() + @mcp.tool( + title="Get Deck Stack", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: @@ -153,7 +166,10 @@ def configure_deck_tools(mcp: FastMCP): stack = await client.deck.get_stack(board_id, stack_id) return stack - @mcp.tool() + @mcp.tool( + title="List Deck Cards", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_cards( @@ -166,7 +182,10 @@ def configure_deck_tools(mcp: FastMCP): return stack.cards return [] - @mcp.tool() + @mcp.tool( + title="Get Deck Card", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_card( @@ -177,7 +196,10 @@ def configure_deck_tools(mcp: FastMCP): card = await client.deck.get_card(board_id, stack_id, card_id) return card - @mcp.tool() + @mcp.tool( + title="List Deck Labels", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: @@ -186,7 +208,10 @@ def configure_deck_tools(mcp: FastMCP): board = await client.deck.get_board(board_id) return board.labels - @mcp.tool() + @mcp.tool( + title="Get Deck Label", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("deck:read") @instrument_tool async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: @@ -197,7 +222,10 @@ def configure_deck_tools(mcp: FastMCP): # Create/Update/Delete Tools - @mcp.tool() + @mcp.tool( + title="Create Deck Board", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_create_board( @@ -215,7 +243,10 @@ def configure_deck_tools(mcp: FastMCP): # Stack Tools - @mcp.tool() + @mcp.tool( + title="Create Deck Stack", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_create_stack( @@ -232,7 +263,10 @@ def configure_deck_tools(mcp: FastMCP): stack = await client.deck.create_stack(board_id, title, order) return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) - @mcp.tool() + @mcp.tool( + title="Update Deck Stack", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_update_stack( @@ -259,7 +293,12 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Delete Deck Stack", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("deck:write") @instrument_tool async def deck_delete_stack( @@ -281,7 +320,10 @@ def configure_deck_tools(mcp: FastMCP): ) # Card Tools - @mcp.tool() + @mcp.tool( + title="Create Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_create_card( @@ -316,7 +358,10 @@ def configure_deck_tools(mcp: FastMCP): stackId=card.stackId, ) - @mcp.tool() + @mcp.tool( + title="Update Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_update_card( @@ -370,7 +415,12 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Delete Deck Card", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("deck:write") @instrument_tool async def deck_delete_card( @@ -393,7 +443,10 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Archive Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_archive_card( @@ -416,7 +469,10 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Unarchive Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_unarchive_card( @@ -439,7 +495,10 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Reorder/Move Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_reorder_card( @@ -472,7 +531,10 @@ def configure_deck_tools(mcp: FastMCP): ) # Label Tools - @mcp.tool() + @mcp.tool( + title="Create Deck Label", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_create_label( @@ -489,7 +551,10 @@ def configure_deck_tools(mcp: FastMCP): label = await client.deck.create_label(board_id, title, color) return CreateLabelResponse(id=label.id, title=label.title, color=label.color) - @mcp.tool() + @mcp.tool( + title="Update Deck Label", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_update_label( @@ -516,7 +581,12 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Delete Deck Label", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("deck:write") @instrument_tool async def deck_delete_label( @@ -538,7 +608,10 @@ def configure_deck_tools(mcp: FastMCP): ) # Card-Label Assignment Tools - @mcp.tool() + @mcp.tool( + title="Assign Label to Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_assign_label_to_card( @@ -562,7 +635,10 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Remove Label from Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_remove_label_from_card( @@ -587,7 +663,10 @@ def configure_deck_tools(mcp: FastMCP): ) # Card-User Assignment Tools - @mcp.tool() + @mcp.tool( + title="Assign User to Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_assign_user_to_card( @@ -611,7 +690,10 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) - @mcp.tool() + @mcp.tool( + title="Unassign User from Deck Card", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("deck:write") @instrument_tool async def deck_unassign_user_from_card( diff --git a/nextcloud_mcp_server/server/news.py b/nextcloud_mcp_server/server/news.py index 6c688c3..c392f44 100644 --- a/nextcloud_mcp_server/server/news.py +++ b/nextcloud_mcp_server/server/news.py @@ -5,7 +5,7 @@ import logging from httpx import HTTPStatusError, RequestError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError -from mcp.types import ErrorData +from mcp.types import ErrorData, ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.client.news import NewsItemType @@ -30,7 +30,10 @@ logger = logging.getLogger(__name__) def configure_news_tools(mcp: FastMCP): """Configure News app MCP tools.""" - @mcp.tool() + @mcp.tool( + title="List News Folders", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse: @@ -52,7 +55,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="List News Feeds", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse: @@ -82,7 +88,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="List News Items", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_list_items( @@ -153,7 +162,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get News Item", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse: @@ -188,7 +200,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Starred News Items", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_get_starred_items( @@ -238,7 +253,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Unread News Items", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_get_unread_items( @@ -288,7 +306,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get News Feed Health", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse: @@ -332,7 +353,10 @@ def configure_news_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get News App Status", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("news:read") @instrument_tool async def nc_news_get_status(ctx: Context) -> GetStatusResponse: diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 2a393cd..5088fc2 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -3,7 +3,7 @@ import logging from httpx import HTTPStatusError, RequestError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError -from mcp.types import ErrorData +from mcp.types import ErrorData, ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -85,7 +85,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Create Note", + annotations=ToolAnnotations( + idempotentHint=False, # Multiple calls create multiple notes + openWorldHint=True, + ), + ) @require_scopes("notes:write") @instrument_tool async def nc_notes_create_note( @@ -132,7 +138,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Update Note", + annotations=ToolAnnotations( + idempotentHint=False, # Requires etag which changes = not idempotent + openWorldHint=True, + ), + ) @require_scopes("notes:write") @instrument_tool async def nc_notes_update_note( @@ -198,7 +210,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Append to Note", + annotations=ToolAnnotations( + idempotentHint=False, # Each call adds content = not idempotent + openWorldHint=True, + ), + ) @require_scopes("notes:write") @instrument_tool async def nc_notes_append_content( @@ -249,7 +267,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Search Notes", + annotations=ToolAnnotations( + readOnlyHint=True, # Search doesn't modify data + openWorldHint=True, + ), + ) @require_scopes("notes:read") @instrument_tool async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: @@ -296,7 +320,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Note", + annotations=ToolAnnotations( + readOnlyHint=True, # Read operation only + openWorldHint=True, + ), + ) @require_scopes("notes:read") @instrument_tool async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: @@ -326,7 +356,13 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Get Note Attachment", + annotations=ToolAnnotations( + readOnlyHint=True, # Read operation only + openWorldHint=True, + ), + ) @require_scopes("notes:read") @instrument_tool async def nc_notes_get_attachment( @@ -373,7 +409,14 @@ def configure_notes_tools(mcp: FastMCP): ) ) - @mcp.tool() + @mcp.tool( + title="Delete Note", + annotations=ToolAnnotations( + destructiveHint=True, # Permanently deletes data + idempotentHint=True, # Deleting deleted note = same end state + openWorldHint=True, + ), + ) @require_scopes("notes:write") @instrument_tool async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 3d64351..c3e3763 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -15,6 +15,7 @@ import httpx from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import AccessToken from mcp.server.fastmcp import Context +from mcp.types import ToolAnnotations from pydantic import BaseModel, Field from nextcloud_mcp_server.auth import require_scopes @@ -684,11 +685,16 @@ def register_oauth_tools(mcp): @mcp.tool( name="provision_nextcloud_access", + title="Grant Server Access to Nextcloud", description=( "Provision offline access to Nextcloud resources. " "This is required before using Nextcloud tools. " "You'll need to complete an OAuth authorization in your browser." ), + annotations=ToolAnnotations( + idempotentHint=False, # Creates new OAuth session each time + openWorldHint=True, + ), ) @require_scopes("openid") async def tool_provision_access( @@ -699,7 +705,13 @@ def register_oauth_tools(mcp): @mcp.tool( name="revoke_nextcloud_access", + title="Revoke Server Access to Nextcloud", description="Revoke offline access to Nextcloud resources.", + annotations=ToolAnnotations( + destructiveHint=True, # Removes stored access tokens + idempotentHint=True, # Revoking revoked access = same end state + openWorldHint=True, + ), ) @require_scopes("openid") async def tool_revoke_access( @@ -709,7 +721,12 @@ def register_oauth_tools(mcp): @mcp.tool( name="check_provisioning_status", + title="Check Provisioning Status", description="Check whether Nextcloud access is provisioned.", + annotations=ToolAnnotations( + readOnlyHint=True, # Only checks status, doesn't modify + openWorldHint=True, + ), ) @require_scopes("openid") async def tool_check_status( @@ -719,10 +736,15 @@ def register_oauth_tools(mcp): @mcp.tool( name="check_logged_in", + title="Check Server Login Status", description=( "Check if you are logged in to Nextcloud. " "If not logged in, this tool will prompt you to complete the login flow." ), + annotations=ToolAnnotations( + readOnlyHint=True, # Checking status doesn't modify state + openWorldHint=True, + ), ) @require_scopes("openid") async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str: diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index f3114a5..bbe3637 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -12,6 +12,7 @@ from mcp.types import ( ModelPreferences, SamplingMessage, TextContent, + ToolAnnotations, ) from nextcloud_mcp_server.auth import require_scopes @@ -34,7 +35,13 @@ logger = logging.getLogger(__name__) def configure_semantic_tools(mcp: FastMCP): """Configure semantic search tools for MCP server.""" - @mcp.tool() + @mcp.tool( + title="Semantic Search", + annotations=ToolAnnotations( + readOnlyHint=True, # Search doesn't modify data + openWorldHint=False, # Searches only indexed Nextcloud data + ), + ) @require_scopes("semantic:read") @instrument_tool async def nc_semantic_search( @@ -285,7 +292,13 @@ def configure_semantic_tools(mcp: FastMCP): logger.error(f"Search error: {e}", exc_info=True) raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}")) - @mcp.tool() + @mcp.tool( + title="Search with AI-Generated Answer", + annotations=ToolAnnotations( + readOnlyHint=True, # Search doesn't modify data + openWorldHint=False, # Searches only indexed Nextcloud data + ), + ) @require_scopes("semantic:read") @instrument_tool async def nc_semantic_search_answer( @@ -623,7 +636,13 @@ def configure_semantic_tools(mcp: FastMCP): success=True, ) - @mcp.tool() + @mcp.tool( + title="Check Indexing Status", + annotations=ToolAnnotations( + readOnlyHint=True, # Only checks status + openWorldHint=True, + ), + ) @require_scopes("semantic:read") @instrument_tool async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse: diff --git a/nextcloud_mcp_server/server/sharing.py b/nextcloud_mcp_server/server/sharing.py index 75f7a04..207810d 100644 --- a/nextcloud_mcp_server/server/sharing.py +++ b/nextcloud_mcp_server/server/sharing.py @@ -3,6 +3,7 @@ import json from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -16,7 +17,10 @@ def configure_sharing_tools(mcp: FastMCP): mcp: FastMCP server instance """ - @mcp.tool() + @mcp.tool( + title="Create Share", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("sharing:write") @instrument_tool async def nc_share_create( @@ -56,7 +60,12 @@ def configure_sharing_tools(mcp: FastMCP): ) return json.dumps(share_data, indent=2) - @mcp.tool() + @mcp.tool( + title="Delete Share", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("sharing:write") @instrument_tool async def nc_share_delete(share_id: int, ctx: Context) -> str: @@ -76,7 +85,10 @@ def configure_sharing_tools(mcp: FastMCP): {"success": True, "message": f"Share {share_id} deleted"}, indent=2 ) - @mcp.tool() + @mcp.tool( + title="Get Share Details", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("sharing:write") @instrument_tool async def nc_share_get(share_id: int, ctx: Context) -> str: @@ -95,7 +107,10 @@ def configure_sharing_tools(mcp: FastMCP): share_data = await client.sharing.get_share(share_id) return json.dumps(share_data, indent=2) - @mcp.tool() + @mcp.tool( + title="List Shares", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("sharing:write") @instrument_tool async def nc_share_list( @@ -117,7 +132,10 @@ def configure_sharing_tools(mcp: FastMCP): ) return json.dumps(shares, indent=2) - @mcp.tool() + @mcp.tool( + title="Update Share", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("sharing:write") @instrument_tool async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str: diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py index 011989f..76e3c66 100644 --- a/nextcloud_mcp_server/server/tables.py +++ b/nextcloud_mcp_server/server/tables.py @@ -1,6 +1,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -11,7 +12,10 @@ logger = logging.getLogger(__name__) def configure_tables_tools(mcp: FastMCP): # Tables tools - @mcp.tool() + @mcp.tool( + title="List Tables", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("tables:read") @instrument_tool async def nc_tables_list_tables(ctx: Context): @@ -19,7 +23,10 @@ def configure_tables_tools(mcp: FastMCP): client = await get_client(ctx) return await client.tables.list_tables() - @mcp.tool() + @mcp.tool( + title="Get Table Schema", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("tables:read") @instrument_tool async def nc_tables_get_schema(table_id: int, ctx: Context): @@ -27,7 +34,10 @@ def configure_tables_tools(mcp: FastMCP): client = await get_client(ctx) return await client.tables.get_table_schema(table_id) - @mcp.tool() + @mcp.tool( + title="Read Table Rows", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) @require_scopes("tables:read") @instrument_tool async def nc_tables_read_table( @@ -40,7 +50,10 @@ def configure_tables_tools(mcp: FastMCP): client = await get_client(ctx) return await client.tables.get_table_rows(table_id, limit, offset) - @mcp.tool() + @mcp.tool( + title="Insert Table Row", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("tables:write") @instrument_tool async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context): @@ -51,7 +64,10 @@ def configure_tables_tools(mcp: FastMCP): client = await get_client(ctx) return await client.tables.create_row(table_id, data) - @mcp.tool() + @mcp.tool( + title="Update Table Row", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) @require_scopes("tables:write") @instrument_tool async def nc_tables_update_row(row_id: int, data: dict, ctx: Context): @@ -62,7 +78,12 @@ def configure_tables_tools(mcp: FastMCP): client = await get_client(ctx) return await client.tables.update_row(row_id, data) - @mcp.tool() + @mcp.tool( + title="Delete Table Row", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) @require_scopes("tables:write") @instrument_tool async def nc_tables_delete_row(row_id: int, ctx: Context): diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index 720e5b0..51f2479 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -1,6 +1,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -16,7 +17,13 @@ logger = logging.getLogger(__name__) def configure_webdav_tools(mcp: FastMCP): # WebDAV file system tools - @mcp.tool() + @mcp.tool( + title="List Files and Directories", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_list_directory( @@ -50,7 +57,13 @@ def configure_webdav_tools(mcp: FastMCP): total_size=total_size, ) - @mcp.tool() + @mcp.tool( + title="Read File", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_read_file(path: str, ctx: Context): @@ -117,7 +130,13 @@ def configure_webdav_tools(mcp: FastMCP): "encoding": "base64", } - @mcp.tool() + @mcp.tool( + title="Write File", + annotations=ToolAnnotations( + idempotentHint=False, # No etag/version control = not truly idempotent + openWorldHint=True, + ), + ) @require_scopes("files:write") @instrument_tool async def nc_webdav_write_file( @@ -146,7 +165,13 @@ def configure_webdav_tools(mcp: FastMCP): return await client.webdav.write_file(path, content_bytes, content_type) - @mcp.tool() + @mcp.tool( + title="Create Directory", + annotations=ToolAnnotations( + idempotentHint=True, # Creating existing dir returns 405 = same end state + openWorldHint=True, + ), + ) @require_scopes("files:write") @instrument_tool async def nc_webdav_create_directory(path: str, ctx: Context): @@ -161,7 +186,14 @@ def configure_webdav_tools(mcp: FastMCP): client = await get_client(ctx) return await client.webdav.create_directory(path) - @mcp.tool() + @mcp.tool( + title="Delete File or Directory", + annotations=ToolAnnotations( + destructiveHint=True, # Permanently deletes data + idempotentHint=True, # Deleting deleted resource = same end state + openWorldHint=True, + ), + ) @require_scopes("files:write") @instrument_tool async def nc_webdav_delete_resource(path: str, ctx: Context): @@ -176,7 +208,13 @@ def configure_webdav_tools(mcp: FastMCP): client = await get_client(ctx) return await client.webdav.delete_resource(path) - @mcp.tool() + @mcp.tool( + title="Move or Rename File", + annotations=ToolAnnotations( + idempotentHint=False, # Moving changes source and dest + openWorldHint=True, + ), + ) @require_scopes("files:write") @instrument_tool async def nc_webdav_move_resource( @@ -197,7 +235,13 @@ def configure_webdav_tools(mcp: FastMCP): source_path, destination_path, overwrite ) - @mcp.tool() + @mcp.tool( + title="Copy File or Directory", + annotations=ToolAnnotations( + idempotentHint=False, # Creates new resource each time + openWorldHint=True, + ), + ) @require_scopes("files:write") @instrument_tool async def nc_webdav_copy_resource( @@ -218,7 +262,13 @@ def configure_webdav_tools(mcp: FastMCP): source_path, destination_path, overwrite ) - @mcp.tool() + @mcp.tool( + title="Search Files", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_search_files( @@ -335,7 +385,13 @@ def configure_webdav_tools(mcp: FastMCP): filters_applied=filters if filters else None, ) - @mcp.tool() + @mcp.tool( + title="Find Files by Name", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_find_by_name( @@ -363,7 +419,13 @@ def configure_webdav_tools(mcp: FastMCP): filters_applied={"name_pattern": pattern}, ) - @mcp.tool() + @mcp.tool( + title="Find Files by Type", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_find_by_type( @@ -391,7 +453,13 @@ def configure_webdav_tools(mcp: FastMCP): filters_applied={"mime_type": mime_type}, ) - @mcp.tool() + @mcp.tool( + title="List Favorite Files", + annotations=ToolAnnotations( + readOnlyHint=True, + openWorldHint=True, + ), + ) @require_scopes("files:read") @instrument_tool async def nc_webdav_list_favorites(