Files
Chris Coutinho 4b026e9aa0 feat: implement ADR-009 - refactor semantic search to use generic semantic:read scope
This implements ADR-009, which documents the decision to use a generic
`semantic:read` OAuth scope instead of requiring all app-specific scopes
for semantic search functionality.

Changes:
- Created new `nextcloud_mcp_server/models/semantic.py` with semantic search models
  - SemanticSearchResult (with new doc_type field for multi-app support)
  - SemanticSearchResponse
  - SamplingSearchResponse
  - VectorSyncStatusResponse

- Created new `nextcloud_mcp_server/server/semantic.py` with semantic search tools
  - nc_semantic_search (renamed from nc_notes_semantic_search)
  - nc_semantic_search_answer (renamed from nc_notes_semantic_search_answer)
  - nc_get_vector_sync_status (renamed from nc_notes_get_vector_sync_status)
  - All tools now use @require_scopes("semantic:read") instead of "notes:read"

- Updated `nextcloud_mcp_server/server/notes.py`
  - Removed semantic search tools (moved to semantic.py)
  - Removed semantic search model imports
  - Removed unused MCP imports (ModelHint, ModelPreferences, etc.)

- Updated `nextcloud_mcp_server/models/notes.py`
  - Removed semantic search models (moved to semantic.py)

- Updated `nextcloud_mcp_server/app.py`
  - Import configure_semantic_tools
  - Register semantic tools when VECTOR_SYNC_ENABLED=true

- Updated `nextcloud_mcp_server/server/__init__.py`
  - Export configure_semantic_tools

- Updated tests
  - tests/integration/test_sampling.py: Use new tool names
  - tests/unit/test_response_models.py: Import from semantic.py, add doc_type field

Architecture:
- Semantic search is now a cross-app feature, not tied to Notes
- Uses dual-phase authorization: semantic:read scope + per-document verification
- Supports future multi-app indexing (notes, calendar, deck, files, contacts)

Test results:
- All 69 unit tests passing
- All 5 smoke tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 05:53:53 +01:00

86 lines
2.9 KiB
Python

"""Pydantic models for Notes app responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Note(BaseModel):
"""Model for a Nextcloud note."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
content: str = Field(description="Note content in markdown")
category: str = Field(default="", description="Note category")
modified: int = Field(description="Unix timestamp of last modification")
favorite: bool = Field(
default=False, description="Whether note is marked as favorite"
)
etag: str = Field(description="ETag for versioning")
readonly: bool = Field(default=False, description="Whether note is read-only")
@property
def modified_datetime(self) -> datetime:
"""Convert Unix timestamp to datetime."""
return datetime.fromtimestamp(self.modified)
class NoteSearchResult(BaseModel):
"""Model for note search results (limited fields)."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
category: str = Field(default="", description="Note category")
score: Optional[float] = Field(None, description="Search relevance score")
class NotesSettings(BaseModel):
"""Model for Notes app settings."""
notesPath: str = Field(description="Path to notes directory")
fileSuffix: str = Field(description="File suffix for notes")
noteMode: str = Field(description="Note mode setting")
class CreateNoteResponse(IdResponse):
"""Response model for note creation."""
title: str = Field(description="The created note title")
category: str = Field(description="The created note category")
etag: str = Field(description="Current ETag for the created note")
class UpdateNoteResponse(BaseResponse):
"""Response model for note updates."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class DeleteNoteResponse(StatusResponse):
"""Response model for note deletion."""
deleted_id: int = Field(description="ID of the deleted note")
class AppendContentResponse(BaseResponse):
"""Response model for appending content to a note."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class SearchNotesResponse(BaseResponse):
"""Response model for note search."""
results: List[NoteSearchResult] = Field(description="Search results")
query: str = Field(description="The search query used")
total_found: int = Field(description="Total number of notes found")