""" HTTP client for NoteDiscovery API. Provides a clean interface to all NoteDiscovery API endpoints with proper error handling, retries, and timeout management. """ import json import time import urllib.request import urllib.error import urllib.parse from typing import Any, Optional from dataclasses import dataclass from .config import MCPConfig @dataclass class APIResponse: """Represents an API response.""" success: bool data: Any error: Optional[str] = None status_code: int = 200 class NoteDiscoveryClient: """ HTTP client for NoteDiscovery API. Uses only stdlib (urllib) for minimal dependencies. Handles authentication, retries, and error formatting. """ def __init__(self, config: MCPConfig) -> None: """ Initialize the client. Args: config: MCP configuration object """ self.config = config self.base_url = config.base_url self.headers = config.headers self.timeout = config.timeout def _request( self, method: str, endpoint: str, params: Optional[dict[str, str]] = None, data: Optional[dict[str, Any]] = None, ) -> APIResponse: """ Make an HTTP request to the API. Args: method: HTTP method (GET, POST, DELETE, etc.) endpoint: API endpoint (e.g., "/api/notes") params: Query parameters data: JSON body data Returns: APIResponse with success status and data/error """ # Build URL with query parameters url = f"{self.base_url}{endpoint}" if params: query_string = urllib.parse.urlencode(params) url = f"{url}?{query_string}" # Prepare request body body = None if data is not None: body = json.dumps(data).encode("utf-8") # Create request request = urllib.request.Request( url, data=body, headers=self.headers, method=method, ) # Execute with retries and exponential backoff last_error = None for attempt in range(self.config.max_retries): try: with urllib.request.urlopen(request, timeout=self.timeout) as response: response_data = response.read().decode("utf-8") return APIResponse( success=True, data=json.loads(response_data) if response_data else None, status_code=response.status, ) except urllib.error.HTTPError as e: # HTTP error (4xx, 5xx) - don't retry, return immediately error_body = "" try: error_body = e.read().decode("utf-8") error_detail = json.loads(error_body).get("detail", error_body) except Exception: error_detail = error_body or str(e) return APIResponse( success=False, data=None, error=f"HTTP {e.code}: {error_detail}", status_code=e.code, ) except urllib.error.URLError as e: # Network error - retry with backoff last_error = f"Connection error: {e.reason}" if attempt < self.config.max_retries - 1: time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s... continue except TimeoutError: # Timeout - retry with backoff last_error = f"Request timed out after {self.timeout}s" if attempt < self.config.max_retries - 1: time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s... continue except json.JSONDecodeError as e: return APIResponse( success=False, data=None, error=f"Invalid JSON response: {e}", ) # All retries exhausted return APIResponse( success=False, data=None, error=last_error or "Unknown error after retries", ) # ========================================================================= # Notes API # ========================================================================= def list_notes(self) -> APIResponse: """ List all notes with metadata. Returns: APIResponse with notes and folders data """ return self._request("GET", "/api/notes") def get_note(self, path: str) -> APIResponse: """ Get a specific note's content. Args: path: Note path (e.g., "folder/note.md") Returns: APIResponse with note content and metadata """ # URL-encode the path encoded_path = urllib.parse.quote(path, safe="") return self._request("GET", f"/api/notes/{encoded_path}") def create_note(self, path: str, content: str) -> APIResponse: """ Create or update a note. Args: path: Note path content: Markdown content Returns: APIResponse with creation result """ encoded_path = urllib.parse.quote(path, safe="") return self._request("POST", f"/api/notes/{encoded_path}", data={"content": content}) def delete_note(self, path: str) -> APIResponse: """ Delete a note. Args: path: Note path Returns: APIResponse with deletion result """ encoded_path = urllib.parse.quote(path, safe="") return self._request("DELETE", f"/api/notes/{encoded_path}") # ========================================================================= # Search API # ========================================================================= def search(self, query: str) -> APIResponse: """ Search notes by query. Args: query: Search query string Returns: APIResponse with search results """ return self._request("GET", "/api/search", params={"q": query}) # ========================================================================= # Tags API # ========================================================================= def list_tags(self) -> APIResponse: """ List all tags with note counts. Returns: APIResponse with tags data """ return self._request("GET", "/api/tags") def get_notes_by_tag(self, tag: str) -> APIResponse: """ Get notes with a specific tag. Args: tag: Tag name Returns: APIResponse with matching notes """ encoded_tag = urllib.parse.quote(tag, safe="") return self._request("GET", f"/api/tags/{encoded_tag}") # ========================================================================= # Folders API # ========================================================================= def create_folder(self, path: str) -> APIResponse: """ Create a new folder. Args: path: Folder path Returns: APIResponse with creation result """ return self._request("POST", "/api/folders", data={"path": path}) # ========================================================================= # Graph API # ========================================================================= def get_graph(self) -> APIResponse: """ Get note relationship graph data. Returns: APIResponse with graph nodes and links """ return self._request("GET", "/api/graph") # ========================================================================= # Templates API # ========================================================================= def list_templates(self) -> APIResponse: """ List available note templates. Returns: APIResponse with templates list """ return self._request("GET", "/api/templates") def get_template(self, name: str) -> APIResponse: """ Get a specific template's content. Args: name: Template name Returns: APIResponse with template content """ encoded_name = urllib.parse.quote(name, safe="") return self._request("GET", f"/api/templates/{encoded_name}") # ========================================================================= # System API # ========================================================================= def health_check(self) -> APIResponse: """ Check if NoteDiscovery server is healthy. Returns: APIResponse with health status """ return self._request("GET", "/health") def get_config(self) -> APIResponse: """ Get NoteDiscovery configuration. Returns: APIResponse with config data """ return self._request("GET", "/api/config")