NoteDiscovery/mcp_server/client.py

431 lines
13 KiB
Python
Raw Permalink Normal View History

2026-03-13 16:53:50 +00:00
"""
HTTP client for NoteDiscovery API.
Provides a clean interface to all NoteDiscovery API endpoints
with proper error handling, retries, and timeout management.
"""
import json
2026-03-13 17:01:48 +00:00
import time
2026-03-13 16:53:50 +00:00
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,
)
2026-03-13 17:01:48 +00:00
# Execute with retries and exponential backoff
2026-03-13 16:53:50 +00:00
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:
2026-03-13 17:01:48 +00:00
# HTTP error (4xx, 5xx) - don't retry, return immediately
2026-03-13 16:53:50 +00:00
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:
2026-03-13 17:01:48 +00:00
# Network error - retry with backoff
2026-03-13 16:53:50 +00:00
last_error = f"Connection error: {e.reason}"
2026-03-13 17:01:48 +00:00
if attempt < self.config.max_retries - 1:
time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s...
2026-03-13 16:53:50 +00:00
continue
except TimeoutError:
2026-03-13 17:01:48 +00:00
# Timeout - retry with backoff
2026-03-13 16:53:50 +00:00
last_error = f"Request timed out after {self.timeout}s"
2026-03-13 17:01:48 +00:00
if attempt < self.config.max_retries - 1:
time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s...
2026-03-13 16:53:50 +00:00
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,
limit: int | None = None,
offset: int = 0
) -> APIResponse:
2026-03-13 16:53:50 +00:00
"""
List all notes with metadata.
Args:
limit: Maximum number of notes to return (None = no limit)
offset: Number of notes to skip (default: 0)
2026-03-13 16:53:50 +00:00
Returns:
APIResponse with notes and folders data
"""
params: dict[str, str] = {}
if limit is not None:
params["limit"] = str(limit)
params["offset"] = str(offset)
return self._request("GET", "/api/notes", params=params if params else None)
2026-03-13 16:53:50 +00:00
def get_note(self, path: str) -> APIResponse:
"""
Get a specific note's content.
2026-06-16 16:09:48 +00:00
2026-03-13 16:53:50 +00:00
Args:
path: Note path (e.g., "folder/note.md")
2026-06-16 16:09:48 +00:00
2026-03-13 16:53:50 +00:00
Returns:
APIResponse with note content and metadata
"""
2026-06-16 16:09:48 +00:00
# /api/notes/{path} defaults to include_backlinks=True server-side,
# which scans every note in the vault. The MCP get_note tool only
# surfaces content + metadata, so we skip the backlinks computation.
# Backlinks are still available via the dedicated get_backlinks method.
2026-03-13 16:53:50 +00:00
encoded_path = urllib.parse.quote(path, safe="")
2026-06-16 16:09:48 +00:00
return self._request(
"GET",
f"/api/notes/{encoded_path}",
params={"include_backlinks": "false"},
)
2026-03-13 16:53:50 +00:00
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}")
def append_to_note(self, path: str, content: str, add_timestamp: bool = False) -> APIResponse:
"""
Append content to an existing note.
Args:
path: Note path
content: Content to append
add_timestamp: Whether to add a timestamp header
Returns:
APIResponse with append result
"""
encoded_path = urllib.parse.quote(path, safe="")
return self._request(
"PATCH",
f"/api/notes/{encoded_path}",
data={"content": content, "add_timestamp": add_timestamp}
)
def move_note(self, old_path: str, new_path: str) -> APIResponse:
"""
Move or rename a note.
Args:
old_path: Current note path
new_path: New note path
Returns:
APIResponse with move result
"""
return self._request(
"POST",
"/api/notes/move",
data={"oldPath": old_path, "newPath": new_path}
)
def create_note_from_template(
self,
template_name: str,
note_path: str,
) -> APIResponse:
"""
Create a note from a template.
2026-06-16 15:52:28 +00:00
Built-in placeholders ({{title}}, {{date}}, {{datetime}},
{{folder}}, {{date:FMT}}, etc.) are substituted server-side
by apply_template_placeholders. There is no per-call variable
injection; use update_note after creation for custom content.
Args:
template_name: Name of the template
note_path: Path for the new note
2026-06-16 15:52:28 +00:00
Returns:
APIResponse with creation result
"""
# The /api/templates/create-note endpoint reads `templateName` and `notePath`.
data = {
"templateName": template_name,
"notePath": note_path,
}
return self._request("POST", "/api/templates/create-note", data=data)
2026-03-13 16:53:50 +00:00
# =========================================================================
# Search API
# =========================================================================
def search(
self,
query: str,
limit: int | None = None,
offset: int = 0
) -> APIResponse:
2026-03-13 16:53:50 +00:00
"""
Search notes by query.
Args:
query: Search query string
limit: Maximum number of results to return (None = no limit)
offset: Number of results to skip (default: 0)
2026-03-13 16:53:50 +00:00
Returns:
APIResponse with search results
"""
params: dict[str, str] = {"q": query}
if limit is not None:
params["limit"] = str(limit)
params["offset"] = str(offset)
return self._request("GET", "/api/search", params=params)
2026-03-13 16:53:50 +00:00
# =========================================================================
# 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,
limit: int | None = None,
offset: int = 0
) -> APIResponse:
2026-03-13 16:53:50 +00:00
"""
Get notes with a specific tag.
Args:
tag: Tag name
limit: Maximum number of notes to return (None = no limit)
offset: Number of notes to skip (default: 0)
2026-03-13 16:53:50 +00:00
Returns:
APIResponse with matching notes
"""
encoded_tag = urllib.parse.quote(tag, safe="")
params: dict[str, str] = {}
if limit is not None:
params["limit"] = str(limit)
params["offset"] = str(offset)
return self._request(
"GET",
f"/api/tags/{encoded_tag}",
params=params if params else None
)
2026-03-13 16:53:50 +00:00
# =========================================================================
# 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.
2026-03-24 10:42:13 +00:00
2026-03-13 16:53:50 +00:00
Returns:
APIResponse with graph nodes and links
"""
return self._request("GET", "/api/graph")
2026-03-24 10:42:13 +00:00
def get_backlinks(self, path: str) -> APIResponse:
"""
Get backlinks (reverse links) for a specific note.
Args:
path: Note path (e.g., "folder/note.md")
Returns:
APIResponse with note info and backlinks
"""
encoded_path = urllib.parse.quote(path, safe="")
return self._request("GET", f"/api/notes/{encoded_path}", params={"include_backlinks": "true"})
2026-03-13 16:53:50 +00:00
# =========================================================================
# 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")