From a7c073909ae25b5c0709edaca78926b933208e1e Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Fri, 13 Mar 2026 17:53:50 +0100 Subject: [PATCH 1/5] added mcp server --- Dockerfile | 5 +- documentation/FEATURES.md | 26 ++ documentation/MCP.md | 274 +++++++++++++++++++ mcp_server/__init__.py | 24 ++ mcp_server/__main__.py | 11 + mcp_server/client.py | 303 +++++++++++++++++++++ mcp_server/config.py | 79 ++++++ mcp_server/server.py | 534 ++++++++++++++++++++++++++++++++++++++ mcp_server/tools.py | 195 ++++++++++++++ pyproject.toml | 89 +++++++ 10 files changed, 1539 insertions(+), 1 deletion(-) create mode 100644 documentation/MCP.md create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/__main__.py create mode 100644 mcp_server/client.py create mode 100644 mcp_server/config.py create mode 100644 mcp_server/server.py create mode 100644 mcp_server/tools.py create mode 100644 pyproject.toml diff --git a/Dockerfile b/Dockerfile index fa70f1a..9c4b25c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,10 +56,13 @@ COPY --from=builder /install /usr/local # Copy minified frontend from minifier stage COPY --from=minifier /build/frontend ./frontend -# Copy other application files +# Copy application files COPY backend ./backend +COPY mcp_server ./mcp_server COPY config.yaml . COPY VERSION . +COPY run.py . +COPY pyproject.toml . COPY plugins ./plugins COPY themes ./themes COPY locales ./locales diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 8cdc366..9d831f4 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -356,6 +356,32 @@ NoteDiscovery can be installed as a standalone app on your device: ๐Ÿ“„ **See [AUTHENTICATION.md](AUTHENTICATION.md)** for setup guide. +## ๐Ÿค– AI Integration (MCP) + +Built-in **Model Context Protocol (MCP)** server for AI assistant integration: + +- **Search notes** - AI can search through your knowledge base +- **Read content** - AI can read and understand your notes +- **Browse tags** - AI understands your organization +- **Create notes** - AI can save summaries and insights +- **Knowledge graph** - AI can explore note relationships +- **Zero setup** - Works with Docker or Python, just add config to Cursor/Claude + +### Quick Setup (Docker) +```json +{ + "mcpServers": { + "notediscovery": { + "command": "docker", + "args": ["run", "--rm", "-i", "ghcr.io/gamosoft/notediscovery:latest", "python", "-m", "mcp_server"], + "env": { "NOTEDISCOVERY_URL": "http://host.docker.internal:8000" } + } + } +} +``` + +๐Ÿ“„ **See [MCP.md](MCP.md)** for complete setup guide. + ## ๐Ÿš€ Performance - **Instant loading** - No lag, no loading spinners diff --git a/documentation/MCP.md b/documentation/MCP.md new file mode 100644 index 0000000..da0df89 --- /dev/null +++ b/documentation/MCP.md @@ -0,0 +1,274 @@ +# MCP Integration (AI Assistants) + +NoteDiscovery includes a built-in **Model Context Protocol (MCP)** server that enables AI assistants like **Cursor**, **Claude Desktop**, and other MCP-compatible clients to interact with your notes. + +## What is MCP? + +MCP (Model Context Protocol) is an open standard that allows AI assistants to securely access external tools and data sources. With the NoteDiscovery MCP server, your AI assistant can: + +- ๐Ÿ” **Search** through your notes +- ๐Ÿ“– **Read** note contents +- ๐Ÿท๏ธ **Browse** by tags +- ๐Ÿ“ **Create** new notes +- ๐Ÿ”— **Explore** the knowledge graph + +## Quick Setup + +### If You Use Docker + +Add this to your `~/.cursor/mcp.json` (or Claude Desktop config): + +```json +{ + "mcpServers": { + "notediscovery": { + "command": "docker", + "args": [ + "run", "--rm", "-i", + "-e", "NOTEDISCOVERY_URL", + "-e", "NOTEDISCOVERY_API_KEY", + "ghcr.io/gamosoft/notediscovery:latest", + "python", "-m", "mcp_server" + ], + "env": { + "NOTEDISCOVERY_URL": "http://host.docker.internal:8000", + "NOTEDISCOVERY_API_KEY": "" + } + } + } +} +``` + +### If You Use Python + +1. **Install NoteDiscovery** (if not already): + ```bash + pip install notediscovery + # or from source: + pip install . + ``` + +2. **Add to your MCP config:** + ```json + { + "mcpServers": { + "notediscovery": { + "command": "notediscovery-mcp", + "env": { + "NOTEDISCOVERY_URL": "http://localhost:8000", + "NOTEDISCOVERY_API_KEY": "" + } + } + } + } + ``` + +### Running from Source (No Install) + +```json +{ + "mcpServers": { + "notediscovery": { + "command": "python", + "args": ["-m", "mcp_server"], + "cwd": "/path/to/NoteDiscovery", + "env": { + "PYTHONPATH": "/path/to/NoteDiscovery", + "NOTEDISCOVERY_URL": "http://localhost:8000" + } + } + } +} +``` + +> **Note:** The `PYTHONPATH` is required so Python can find the `mcp_server` module. On Windows, use backslashes: `"PYTHONPATH": "C:\\path\\to\\NoteDiscovery"` + +## Configuration + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NOTEDISCOVERY_URL` | Yes | `http://localhost:8000` | URL where NoteDiscovery is running | +| `NOTEDISCOVERY_API_KEY` | If auth enabled | - | API key from `config.yaml` | +| `NOTEDISCOVERY_TIMEOUT` | No | `30` | Request timeout in seconds | +| `NOTEDISCOVERY_MAX_RETRIES` | No | `3` | Max retry attempts for failed requests | + +### URL Configuration by Setup + +| Your Setup | `NOTEDISCOVERY_URL` | +|------------|---------------------| +| Local Python (`run.py`) | `http://localhost:8000` | +| Docker with `-p 8000:8000` | `http://host.docker.internal:8000` | +| Docker with `-p 3000:8000` | `http://host.docker.internal:3000` | +| Remote server | `https://notes.example.com` | + +## Available Tools + +The MCP server provides these tools to AI assistants: + +### Search & Discovery + +| Tool | Description | +|------|-------------| +| `search_notes` | Full-text search across all notes | +| `list_notes` | List all notes with metadata | +| `get_note` | Read a specific note's content | + +### Organization + +| Tool | Description | +|------|-------------| +| `list_tags` | List all tags with note counts | +| `get_notes_by_tag` | Find notes with a specific tag | +| `get_graph` | Get knowledge graph data | + +### Note Management + +| Tool | Description | +|------|-------------| +| `create_note` | Create or update a note | +| `delete_note` | Delete a note | +| `create_folder` | Create a new folder | + +### Templates + +| Tool | Description | +|------|-------------| +| `list_templates` | List available templates | +| `get_template` | Get template content | + +### System + +| Tool | Description | +|------|-------------| +| `health_check` | Verify server connectivity | + +## Usage Examples + +Once configured, you can interact with your notes naturally: + +> **User:** "What did I write about Kubernetes?" +> +> **AI:** *Uses `search_notes` to find relevant notes, then `get_note` to read them* +> +> "I found 3 notes about Kubernetes. In your 'devops/k8s-setup.md' note from last week, you documented..." + +> **User:** "Create a new note summarizing our conversation" +> +> **AI:** *Uses `create_note` to save the summary* +> +> "Done! I've created 'meetings/ai-discussion-2024-03-13.md' with the summary." + +> **User:** "Show me all notes tagged with #project" +> +> **AI:** *Uses `get_notes_by_tag` to find them* +> +> "You have 7 notes with the #project tag..." + +## Authentication + +If you have authentication enabled in NoteDiscovery: + +1. Generate an API key in `config.yaml`: + ```yaml + authentication: + enabled: true + api_key: "your-secure-api-key-here" + ``` + +2. Add the key to your MCP config: + ```json + "env": { + "NOTEDISCOVERY_URL": "http://localhost:8000", + "NOTEDISCOVERY_API_KEY": "your-secure-api-key-here" + } + ``` + +## Troubleshooting + +### "Connection refused" error + +- Ensure NoteDiscovery is running +- Check the `NOTEDISCOVERY_URL` is correct +- For Docker: use `host.docker.internal` instead of `localhost` + +### "Not authenticated" error + +- Check that your API key is correct +- Ensure the API key in MCP config matches `config.yaml` + +### MCP server not starting + +- Check Cursor/Claude Desktop logs for errors +- Try running manually: `python -m mcp_server` +- Verify Python 3.10+ is installed + +### Verify connectivity manually + +```bash +# Set environment variables +export NOTEDISCOVERY_URL=http://localhost:8000 +export NOTEDISCOVERY_API_KEY=your-key + +# Run the MCP server (Ctrl+C to stop) +python -m mcp_server +``` + +Then in another terminal: +```bash +# Test the health endpoint directly +curl http://localhost:8000/health +``` + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” stdio (JSON-RPC) โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ AI Assistant โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ MCP Server โ”‚ +โ”‚ (Cursor/Claude) โ”‚ โ”‚ (notediscovery- โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ mcp) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ HTTP/REST + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ NoteDiscovery โ”‚ + โ”‚ Server โ”‚ + โ”‚ (port 8000) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Your Notes โ”‚ + โ”‚ (./data/*.md) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +The MCP server is a **separate process** that: +1. Communicates with AI assistants via stdio (stdin/stdout) +2. Translates MCP requests into HTTP API calls +3. Returns results back to the AI assistant + +Your notes stay local. The MCP server just provides a bridge for AI access. + +## Privacy & Security + +- **Notes stay local**: The MCP server only accesses notes through NoteDiscovery's API +- **No external calls**: No data is sent to external services +- **API key protected**: Use authentication to control access +- **Read what you allow**: AI can only access notes NoteDiscovery serves + +## File Structure + +``` +NoteDiscovery/ +โ”œโ”€โ”€ mcp_server/ +โ”‚ โ”œโ”€โ”€ __init__.py # Package entry point +โ”‚ โ”œโ”€โ”€ __main__.py # Module runner +โ”‚ โ”œโ”€โ”€ server.py # MCP protocol implementation +โ”‚ โ”œโ”€โ”€ client.py # HTTP client for NoteDiscovery API +โ”‚ โ”œโ”€โ”€ config.py # Configuration management +โ”‚ โ””โ”€โ”€ tools.py # Tool definitions +โ””โ”€โ”€ ... +``` diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..3f1bcbb --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1,24 @@ +""" +NoteDiscovery MCP Server + +A Model Context Protocol (MCP) server that enables AI assistants +to interact with NoteDiscovery notes. + +Usage: + # As module + python -m mcp_server + + # As installed CLI + notediscovery-mcp + +Environment Variables: + NOTEDISCOVERY_URL: NoteDiscovery server URL (default: http://localhost:8000) + NOTEDISCOVERY_API_KEY: API key for authentication (optional) +""" + +__version__ = "1.0.0" +__author__ = "NoteDiscovery" + +from .server import main + +__all__ = ["main", "__version__"] diff --git a/mcp_server/__main__.py b/mcp_server/__main__.py new file mode 100644 index 0000000..4f511e2 --- /dev/null +++ b/mcp_server/__main__.py @@ -0,0 +1,11 @@ +""" +Entry point for running the MCP server as a module. + +Usage: + python -m mcp_server +""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/mcp_server/client.py b/mcp_server/client.py new file mode 100644 index 0000000..37391b1 --- /dev/null +++ b/mcp_server/client.py @@ -0,0 +1,303 @@ +""" +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 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 + 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) + 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 + last_error = f"Connection error: {e.reason}" + continue + except TimeoutError: + last_error = f"Request timed out after {self.timeout}s" + 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") diff --git a/mcp_server/config.py b/mcp_server/config.py new file mode 100644 index 0000000..336efb8 --- /dev/null +++ b/mcp_server/config.py @@ -0,0 +1,79 @@ +""" +Configuration management for the MCP server. + +Handles environment variables and provides validated configuration. +""" + +import os +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class MCPConfig: + """Immutable configuration for the MCP server.""" + + base_url: str + api_key: Optional[str] + timeout: float + max_retries: int + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + # Validate URL format + parsed = urlparse(self.base_url) + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"Invalid NOTEDISCOVERY_URL: {self.base_url}") + + if parsed.scheme not in ("http", "https"): + raise ValueError(f"URL must use http or https: {self.base_url}") + + @property + def headers(self) -> dict[str, str]: + """Get HTTP headers for API requests.""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "NoteDiscovery-MCP/1.0", + } + if self.api_key: + headers["X-API-Key"] = self.api_key + return headers + + +def load_config() -> MCPConfig: + """ + Load configuration from environment variables. + + Environment Variables: + NOTEDISCOVERY_URL: Server URL (default: http://localhost:8000) + NOTEDISCOVERY_API_KEY: API key for authentication (optional) + NOTEDISCOVERY_TIMEOUT: Request timeout in seconds (default: 30) + NOTEDISCOVERY_MAX_RETRIES: Max retry attempts (default: 3) + + Returns: + MCPConfig: Validated configuration object + + Raises: + ValueError: If configuration is invalid + """ + base_url = os.getenv("NOTEDISCOVERY_URL", "http://localhost:8000").rstrip("/") + api_key = os.getenv("NOTEDISCOVERY_API_KEY", "").strip() or None + + try: + timeout = float(os.getenv("NOTEDISCOVERY_TIMEOUT", "30")) + except ValueError: + timeout = 30.0 + + try: + max_retries = int(os.getenv("NOTEDISCOVERY_MAX_RETRIES", "3")) + except ValueError: + max_retries = 3 + + return MCPConfig( + base_url=base_url, + api_key=api_key, + timeout=timeout, + max_retries=max_retries, + ) diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000..8dc734a --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,534 @@ +""" +MCP Server implementation for NoteDiscovery. + +Implements the Model Context Protocol (MCP) over stdio, +enabling AI assistants to interact with NoteDiscovery notes. + +This implementation uses only Python stdlib for minimal dependencies. +""" + +import json +import sys +import traceback +from typing import Any, Optional + +from .config import load_config, MCPConfig +from .client import NoteDiscoveryClient, APIResponse +from .tools import TOOLS, get_tool_names + + +# MCP Protocol version +MCP_VERSION = "2024-11-05" + +# Server info +SERVER_INFO = { + "name": "notediscovery-mcp", + "version": "1.0.0", +} + + +class MCPServer: + """ + MCP Server that bridges AI assistants with NoteDiscovery. + + Implements the MCP protocol over stdio (JSON-RPC 2.0). + """ + + def __init__(self, config: MCPConfig) -> None: + """ + Initialize the MCP server. + + Args: + config: MCP configuration + """ + self.config = config + self.client = NoteDiscoveryClient(config) + self._initialized = False + + def _log(self, message: str) -> None: + """Log message to stderr (not stdout which is for MCP protocol).""" + print(f"[notediscovery-mcp] {message}", file=sys.stderr) + + def _send_response(self, id: Any, result: Any = None, error: Optional[dict] = None) -> None: + """ + Send a JSON-RPC response. + + Args: + id: Request ID + result: Result data (for success) + error: Error object (for failure) + """ + response: dict[str, Any] = { + "jsonrpc": "2.0", + "id": id, + } + + if error is not None: + response["error"] = error + else: + response["result"] = result + + # Write to stdout with newline + print(json.dumps(response), flush=True) + + def _send_notification(self, method: str, params: Optional[dict] = None) -> None: + """ + Send a JSON-RPC notification (no response expected). + + Args: + method: Notification method + params: Optional parameters + """ + notification: dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + } + if params is not None: + notification["params"] = params + + print(json.dumps(notification), flush=True) + + def _error(self, code: int, message: str, data: Any = None) -> dict: + """Create a JSON-RPC error object.""" + error = {"code": code, "message": message} + if data is not None: + error["data"] = data + return error + + # ========================================================================= + # MCP Protocol Handlers + # ========================================================================= + + def handle_initialize(self, params: dict) -> dict: + """Handle initialize request.""" + self._initialized = True + self._log(f"Initialized with client: {params.get('clientInfo', {}).get('name', 'unknown')}") + + return { + "protocolVersion": MCP_VERSION, + "serverInfo": SERVER_INFO, + "capabilities": { + "tools": {}, # We support tools + }, + } + + def handle_initialized(self, params: dict) -> None: + """Handle initialized notification.""" + self._log(f"Connected to NoteDiscovery at {self.config.base_url}") + + def handle_list_tools(self, params: dict) -> dict: + """Handle tools/list request.""" + return {"tools": TOOLS} + + def handle_call_tool(self, params: dict) -> dict: + """ + Handle tools/call request. + + Dispatches to the appropriate tool handler based on tool name. + """ + name = params.get("name", "") + arguments = params.get("arguments", {}) + + self._log(f"Calling tool: {name}") + + # Dispatch to tool handler + handler = getattr(self, f"_tool_{name}", None) + if handler is None: + return { + "content": [{ + "type": "text", + "text": f"Unknown tool: {name}. Available tools: {', '.join(get_tool_names())}", + }], + "isError": True, + } + + try: + result = handler(arguments) + return { + "content": [{ + "type": "text", + "text": result, + }], + } + except Exception as e: + self._log(f"Tool error: {e}") + return { + "content": [{ + "type": "text", + "text": f"Error executing {name}: {str(e)}", + }], + "isError": True, + } + + # ========================================================================= + # Tool Implementations + # ========================================================================= + + def _format_response(self, response: APIResponse) -> str: + """Format API response as readable text.""" + if not response.success: + return f"Error: {response.error}" + + if response.data is None: + return "Success (no data)" + + # Pretty print JSON + return json.dumps(response.data, indent=2, ensure_ascii=False) + + def _tool_search_notes(self, args: dict) -> str: + """Search notes by query.""" + query = args.get("query", "") + if not query: + return "Error: query is required" + + response = self.client.search(query) + + if not response.success: + return f"Search failed: {response.error}" + + data = response.data or {} + results = data.get("results", []) + + if not results: + return f"No notes found matching '{query}'" + + # Format search results + output = [f"Found {len(results)} result(s) for '{query}':\n"] + for i, result in enumerate(results[:20], 1): # Limit to 20 results + path = result.get("path", "unknown") + snippet = result.get("snippet", "") + output.append(f"{i}. **{path}**") + if snippet: + output.append(f" {snippet[:200]}...") + output.append("") + + if len(results) > 20: + output.append(f"... and {len(results) - 20} more results") + + return "\n".join(output) + + def _tool_list_notes(self, args: dict) -> str: + """List all notes.""" + response = self.client.list_notes() + + if not response.success: + return f"Failed to list notes: {response.error}" + + data = response.data or {} + notes = data.get("notes", []) + folders = data.get("folders", []) + + output = [f"Found {len(notes)} note(s) in {len(folders)} folder(s):\n"] + + # Group notes by folder + notes_by_folder: dict[str, list] = {} + for note in notes: + path = note.get("path", "") + folder = "/".join(path.split("/")[:-1]) or "(root)" + if folder not in notes_by_folder: + notes_by_folder[folder] = [] + notes_by_folder[folder].append(note) + + for folder in sorted(notes_by_folder.keys()): + output.append(f"๐Ÿ“ {folder}/") + for note in notes_by_folder[folder]: + name = note.get("name", "unknown") + modified = note.get("modified", "") + output.append(f" ๐Ÿ“ {name} (modified: {modified})") + output.append("") + + return "\n".join(output) + + def _tool_get_note(self, args: dict) -> str: + """Get note content.""" + path = args.get("path", "") + if not path: + return "Error: path is required" + + response = self.client.get_note(path) + + if not response.success: + return f"Failed to get note: {response.error}" + + data = response.data or {} + content = data.get("content", "") + metadata = data.get("metadata", {}) + + output = [f"# {path}\n"] + if metadata: + output.append(f"Modified: {metadata.get('modified', 'unknown')}") + output.append(f"Size: {metadata.get('size', 0)} bytes\n") + output.append("---\n") + output.append(content) + + return "\n".join(output) + + def _tool_list_tags(self, args: dict) -> str: + """List all tags.""" + response = self.client.list_tags() + + if not response.success: + return f"Failed to list tags: {response.error}" + + data = response.data or {} + tags = data.get("tags", {}) + + if not tags: + return "No tags found in any notes." + + output = [f"Found {len(tags)} tag(s):\n"] + # tags is a dict: {"tag_name": count, ...} + for name, count in sorted(tags.items(), key=lambda x: x[1], reverse=True): + output.append(f" #{name} ({count} note{'s' if count != 1 else ''})") + + return "\n".join(output) + + def _tool_get_notes_by_tag(self, args: dict) -> str: + """Get notes with a specific tag.""" + tag = args.get("tag", "") + if not tag: + return "Error: tag is required" + + response = self.client.get_notes_by_tag(tag) + + if not response.success: + return f"Failed to get notes by tag: {response.error}" + + data = response.data or {} + notes = data.get("notes", []) + + if not notes: + return f"No notes found with tag '#{tag}'" + + output = [f"Notes with tag #{tag}:\n"] + for note in notes: + path = note.get("path", "unknown") + output.append(f" ๐Ÿ“ {path}") + + return "\n".join(output) + + def _tool_get_graph(self, args: dict) -> str: + """Get knowledge graph data.""" + response = self.client.get_graph() + + if not response.success: + return f"Failed to get graph: {response.error}" + + data = response.data or {} + nodes = data.get("nodes", []) + links = data.get("links", []) + + output = [f"Knowledge Graph: {len(nodes)} nodes, {len(links)} connections\n"] + + # Find most connected notes + connection_count: dict[str, int] = {} + for link in links: + source = link.get("source", "") + target = link.get("target", "") + connection_count[source] = connection_count.get(source, 0) + 1 + connection_count[target] = connection_count.get(target, 0) + 1 + + if connection_count: + output.append("Most connected notes:") + sorted_notes = sorted(connection_count.items(), key=lambda x: x[1], reverse=True)[:10] + for note, count in sorted_notes: + output.append(f" {note}: {count} connections") + + return "\n".join(output) + + def _tool_create_note(self, args: dict) -> str: + """Create or update a note.""" + path = args.get("path", "") + content = args.get("content", "") + + if not path: + return "Error: path is required" + if not content: + return "Error: content is required" + + response = self.client.create_note(path, content) + + if not response.success: + return f"Failed to create note: {response.error}" + + return f"โœ… Note created/updated: {path}" + + def _tool_delete_note(self, args: dict) -> str: + """Delete a note.""" + path = args.get("path", "") + if not path: + return "Error: path is required" + + response = self.client.delete_note(path) + + if not response.success: + return f"Failed to delete note: {response.error}" + + return f"๐Ÿ—‘๏ธ Note deleted: {path}" + + def _tool_create_folder(self, args: dict) -> str: + """Create a folder.""" + path = args.get("path", "") + if not path: + return "Error: path is required" + + response = self.client.create_folder(path) + + if not response.success: + return f"Failed to create folder: {response.error}" + + return f"๐Ÿ“ Folder created: {path}" + + def _tool_list_templates(self, args: dict) -> str: + """List templates.""" + response = self.client.list_templates() + + if not response.success: + return f"Failed to list templates: {response.error}" + + data = response.data or {} + templates = data.get("templates", []) + + if not templates: + return "No templates available." + + output = ["Available templates:\n"] + for template in templates: + name = template.get("name", "unknown") + output.append(f" ๐Ÿ“„ {name}") + + return "\n".join(output) + + def _tool_get_template(self, args: dict) -> str: + """Get template content.""" + name = args.get("name", "") + if not name: + return "Error: name is required" + + response = self.client.get_template(name) + + if not response.success: + return f"Failed to get template: {response.error}" + + data = response.data or {} + content = data.get("content", "") + + return f"Template: {name}\n---\n{content}" + + def _tool_health_check(self, args: dict) -> str: + """Check server health.""" + response = self.client.health_check() + + if not response.success: + return f"โŒ NoteDiscovery is not reachable: {response.error}" + + return f"โœ… NoteDiscovery is healthy at {self.config.base_url}" + + # ========================================================================= + # Main Loop + # ========================================================================= + + def handle_request(self, request: dict) -> None: + """ + Handle a single JSON-RPC request. + + Args: + request: Parsed JSON-RPC request + """ + request_id = request.get("id") + method = request.get("method", "") + params = request.get("params", {}) + + try: + # Route to handler + if method == "initialize": + result = self.handle_initialize(params) + self._send_response(request_id, result) + + elif method == "notifications/initialized": + self.handle_initialized(params) + # Notifications don't get responses + + elif method == "tools/list": + result = self.handle_list_tools(params) + self._send_response(request_id, result) + + elif method == "tools/call": + result = self.handle_call_tool(params) + self._send_response(request_id, result) + + elif method == "ping": + self._send_response(request_id, {}) + + else: + # Unknown method + if request_id is not None: + self._send_response( + request_id, + error=self._error(-32601, f"Method not found: {method}") + ) + + except Exception as e: + self._log(f"Error handling {method}: {e}") + traceback.print_exc(file=sys.stderr) + if request_id is not None: + self._send_response( + request_id, + error=self._error(-32603, f"Internal error: {str(e)}") + ) + + def run(self) -> None: + """ + Run the MCP server, reading requests from stdin. + + This is the main event loop that processes JSON-RPC messages. + """ + self._log("Starting MCP server...") + self._log(f"NoteDiscovery URL: {self.config.base_url}") + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + self.handle_request(request) + except json.JSONDecodeError as e: + self._log(f"Invalid JSON: {e}") + # Send parse error + self._send_response( + None, + error=self._error(-32700, f"Parse error: {str(e)}") + ) + + self._log("Server shutting down") + + +def main() -> None: + """ + Main entry point for the MCP server. + + Loads configuration from environment variables and starts the server. + """ + try: + config = load_config() + except ValueError as e: + print(f"Configuration error: {e}", file=sys.stderr) + sys.exit(1) + + server = MCPServer(config) + + try: + server.run() + except KeyboardInterrupt: + print("\n[notediscovery-mcp] Interrupted", file=sys.stderr) + sys.exit(0) + except Exception as e: + print(f"[notediscovery-mcp] Fatal error: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mcp_server/tools.py b/mcp_server/tools.py new file mode 100644 index 0000000..dd64118 --- /dev/null +++ b/mcp_server/tools.py @@ -0,0 +1,195 @@ +""" +MCP Tool definitions for NoteDiscovery. + +Defines all available tools, their schemas, and descriptions. +Following MCP specification for tool definitions. +""" + +from typing import Any + +# Tool definitions following MCP schema specification +TOOLS: list[dict[str, Any]] = [ + # ========================================================================= + # Search & Discovery + # ========================================================================= + { + "name": "search_notes", + "description": "Search through all notes using full-text search. Returns matching notes with snippets showing where the match was found. Use this to find notes by content, keywords, or phrases.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query. Can be keywords, phrases, or natural language." + } + }, + "required": ["query"] + } + }, + { + "name": "list_notes", + "description": "List all notes in the knowledge base with their metadata (title, path, last modified date, size). Use this to get an overview of available notes or find notes by browsing.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_note", + "description": "Read the full content of a specific note by its path. Returns the complete markdown content along with metadata. Use this after finding a note via search or list to read its contents.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the note (e.g., 'folder/note.md' or 'note.md')" + } + }, + "required": ["path"] + } + }, + + # ========================================================================= + # Tags & Organization + # ========================================================================= + { + "name": "list_tags", + "description": "List all tags used across notes with the count of notes for each tag. Use this to understand how notes are organized and find topics.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_notes_by_tag", + "description": "Get all notes that have a specific tag. Use this to find related notes on a topic.", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "Tag name (without the # symbol)" + } + }, + "required": ["tag"] + } + }, + + # ========================================================================= + # Knowledge Graph + # ========================================================================= + { + "name": "get_graph", + "description": "Get the knowledge graph showing relationships between notes. Returns nodes (notes) and edges (links between them). Use this to understand how notes connect to each other.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + + # ========================================================================= + # Note Management (Write Operations) + # ========================================================================= + { + "name": "create_note", + "description": "Create a new note or update an existing one. The note will be saved as a markdown file. Use this to save new information or update existing notes.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path for the note (e.g., 'folder/new-note.md'). Include .md extension." + }, + "content": { + "type": "string", + "description": "Markdown content for the note" + } + }, + "required": ["path", "content"] + } + }, + { + "name": "delete_note", + "description": "Delete a note permanently. Use with caution - this cannot be undone.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the note to delete" + } + }, + "required": ["path"] + } + }, + { + "name": "create_folder", + "description": "Create a new folder for organizing notes.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path for the new folder (e.g., 'projects/2024')" + } + }, + "required": ["path"] + } + }, + + # ========================================================================= + # Templates + # ========================================================================= + { + "name": "list_templates", + "description": "List available note templates. Templates provide pre-formatted structures for common note types.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_template", + "description": "Get the content of a specific template. Use this to see what a template contains before using it.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Template name" + } + }, + "required": ["name"] + } + }, + + # ========================================================================= + # System + # ========================================================================= + { + "name": "health_check", + "description": "Check if NoteDiscovery server is running and healthy. Use this to verify connectivity.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, +] + + +def get_tool_names() -> list[str]: + """Get list of all tool names.""" + return [tool["name"] for tool in TOOLS] + + +def get_tool_by_name(name: str) -> dict[str, Any] | None: + """Get tool definition by name.""" + for tool in TOOLS: + if tool["name"] == name: + return tool + return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a055f0c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "notediscovery" +dynamic = ["version"] +description = "A beautiful, fast markdown notes discovery and browsing tool" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "NoteDiscovery", email = "contact@notediscovery.com"} +] +keywords = [ + "notes", + "markdown", + "knowledge-base", + "personal-wiki", + "note-taking", + "mcp", + "ai", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business :: News/Diary", + "Topic :: Text Processing :: Markup :: Markdown", +] +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "python-multipart>=0.0.6", + "markdown>=3.5.0", + "pyyaml>=6.0", + "aiofiles>=23.2.0", + "cryptography>=41.0.0", + "bcrypt>=4.1.0", + "itsdangerous>=2.1.0", + "slowapi>=0.1.9", + "colorama>=0.4.6", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "httpx>=0.25.0", +] + +[project.urls] +Homepage = "https://notediscovery.com" +Documentation = "https://github.com/gamosoft/NoteDiscovery#readme" +Repository = "https://github.com/gamosoft/NoteDiscovery" +Issues = "https://github.com/gamosoft/NoteDiscovery/issues" + +[project.scripts] +# Main application +notediscovery = "run:main" +# MCP server for AI assistants +notediscovery-mcp = "mcp_server:main" + +[tool.setuptools] +packages = ["backend", "mcp_server", "plugins"] +include-package-data = true + +[tool.setuptools.dynamic] +version = {file = "VERSION"} + +[tool.setuptools.package-data] +"*" = [ + "*.yaml", + "*.json", + "*.css", + "*.html", + "*.js", + "*.svg", + "*.md", +] From 50b7d08bb2d51c48e1a611ec12a5c41e6c314b2e Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Fri, 13 Mar 2026 18:01:48 +0100 Subject: [PATCH 2/5] response and backoff fixes --- mcp_server/client.py | 12 ++++++-- mcp_server/server.py | 67 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/mcp_server/client.py b/mcp_server/client.py index 37391b1..e1ce7c1 100644 --- a/mcp_server/client.py +++ b/mcp_server/client.py @@ -6,6 +6,7 @@ with proper error handling, retries, and timeout management. """ import json +import time import urllib.request import urllib.error import urllib.parse @@ -82,7 +83,7 @@ class NoteDiscoveryClient: method=method, ) - # Execute with retries + # Execute with retries and exponential backoff last_error = None for attempt in range(self.config.max_retries): try: @@ -94,7 +95,7 @@ class NoteDiscoveryClient: status_code=response.status, ) except urllib.error.HTTPError as e: - # HTTP error (4xx, 5xx) + # HTTP error (4xx, 5xx) - don't retry, return immediately error_body = "" try: error_body = e.read().decode("utf-8") @@ -109,11 +110,16 @@ class NoteDiscoveryClient: status_code=e.code, ) except urllib.error.URLError as e: - # Network error - retry + # 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( diff --git a/mcp_server/server.py b/mcp_server/server.py index 8dc734a..55f1ed1 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -164,6 +164,35 @@ class MCPServer: # Tool Implementations # ========================================================================= + def _validate_path(self, path: str) -> tuple[bool, str]: + """ + Validate a path for safety. + + Args: + path: The path to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not path: + return False, "path is required" + + # Reject path traversal attempts + if ".." in path: + return False, "path cannot contain '..'" + + # Reject absolute paths (Unix and Windows) + if path.startswith("/") or path.startswith("\\"): + return False, "path cannot be absolute" + if len(path) >= 2 and path[1] == ":": # Windows drive letter (e.g., C:) + return False, "path cannot be absolute" + + # Reject null bytes (security) + if "\x00" in path: + return False, "path contains invalid characters" + + return True, "" + def _format_response(self, response: APIResponse) -> str: """Format API response as readable text.""" if not response.success: @@ -242,8 +271,9 @@ class MCPServer: def _tool_get_note(self, args: dict) -> str: """Get note content.""" path = args.get("path", "") - if not path: - return "Error: path is required" + is_valid, error = self._validate_path(path) + if not is_valid: + return f"Error: {error}" response = self.client.get_note(path) @@ -341,8 +371,9 @@ class MCPServer: path = args.get("path", "") content = args.get("content", "") - if not path: - return "Error: path is required" + is_valid, error = self._validate_path(path) + if not is_valid: + return f"Error: {error}" if not content: return "Error: content is required" @@ -356,8 +387,9 @@ class MCPServer: def _tool_delete_note(self, args: dict) -> str: """Delete a note.""" path = args.get("path", "") - if not path: - return "Error: path is required" + is_valid, error = self._validate_path(path) + if not is_valid: + return f"Error: {error}" response = self.client.delete_note(path) @@ -369,8 +401,9 @@ class MCPServer: def _tool_create_folder(self, args: dict) -> str: """Create a folder.""" path = args.get("path", "") - if not path: - return "Error: path is required" + is_valid, error = self._validate_path(path) + if not is_valid: + return f"Error: {error}" response = self.client.create_folder(path) @@ -402,8 +435,10 @@ class MCPServer: def _tool_get_template(self, args: dict) -> str: """Get template content.""" name = args.get("name", "") - if not name: - return "Error: name is required" + # Validate template name (same rules as paths for safety) + is_valid, error = self._validate_path(name) + if not is_valid: + return f"Error: {error.replace('path', 'name')}" response = self.client.get_template(name) @@ -450,10 +485,22 @@ class MCPServer: # Notifications don't get responses elif method == "tools/list": + if not self._initialized: + self._send_response( + request_id, + error=self._error(-32002, "Server not initialized") + ) + return result = self.handle_list_tools(params) self._send_response(request_id, result) elif method == "tools/call": + if not self._initialized: + self._send_response( + request_id, + error=self._error(-32002, "Server not initialized") + ) + return result = self.handle_call_tool(params) self._send_response(request_id, result) From eab16aafff2ad299bbdcfc64b59b9eda23da1c3d Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Fri, 13 Mar 2026 18:33:27 +0100 Subject: [PATCH 3/5] more tools, append endpoint, doc updates --- backend/main.py | 56 ++++++++++++++++++++++ documentation/API.md | 50 ++++++++++++++++++++ documentation/MCP.md | 81 ++++++++++++++++++++++++++++++++ mcp_server/client.py | 62 +++++++++++++++++++++++++ mcp_server/server.py | 107 +++++++++++++++++++++++++++++++++++++++++++ mcp_server/tools.py | 80 ++++++++++++++++++++++++++++++++ 6 files changed, 436 insertions(+) diff --git a/backend/main.py b/backend/main.py index 339547f..39d853c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1078,6 +1078,62 @@ async def create_or_update_note(request: Request, note_path: str, content: dict) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note")) +@api_router.patch("/notes/{note_path:path}", tags=["Notes"]) +@limiter.limit("60/minute") +async def append_to_note(request: Request, note_path: str, data: dict): + """ + Append content to an existing note without overwriting. + + Perfect for journals, logs, or collecting ideas incrementally. + + Args: + note_path: Path to the note + data: Dictionary with 'content' to append and optional 'add_timestamp' boolean + """ + try: + content_to_append = data.get('content', '') + add_timestamp = data.get('add_timestamp', False) + + if not content_to_append: + raise HTTPException(status_code=400, detail="Content to append is required") + + # Get existing content + existing_content = get_note_content(config['storage']['notes_dir'], note_path) + + if existing_content is None: + raise HTTPException(status_code=404, detail="Note not found") + + # Build the appended content + if add_timestamp: + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + content_to_append = f"\n\n---\n\n**{timestamp}**\n\n{content_to_append}" + else: + content_to_append = f"\n\n{content_to_append}" + + new_content = existing_content + content_to_append + + # Run on_note_save hook + transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=new_content) + if transformed_content is None: + transformed_content = new_content + + success = save_note(config['storage']['notes_dir'], note_path, transformed_content) + + if not success: + raise HTTPException(status_code=500, detail="Failed to append to note") + + return { + "success": True, + "path": note_path, + "message": "Content appended successfully" + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to append to note")) + + @api_router.delete("/notes/{note_path:path}", tags=["Notes"]) @limiter.limit("30/minute") async def remove_note(request: Request, note_path: str): diff --git a/documentation/API.md b/documentation/API.md index 27c32dd..259253a 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -70,6 +70,56 @@ DELETE /api/notes/{note_path} curl -X DELETE http://localhost:8000/api/notes/test.md ``` +### Append to Note +```http +PATCH /api/notes/{note_path} +Content-Type: application/json + +{ + "content": "Content to append...", + "add_timestamp": true +} +``` + +Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas incrementally. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | Yes | Content to append to the note | +| `add_timestamp` | boolean | No | If `true`, prepends a timestamp header (default: `false`) | + +**Response:** +```json +{ + "success": true, + "path": "daily-journal.md", + "message": "Content appended successfully" +} +``` + +**Example with timestamp:** +```bash +curl -X PATCH http://localhost:8000/api/notes/daily-journal.md \ + -H "Content-Type: application/json" \ + -d '{"content": "Had a productive meeting about the roadmap.", "add_timestamp": true}' +``` + +This will append: +```markdown + +--- + +**2024-03-13 14:30** + +Had a productive meeting about the roadmap. +``` + +**Windows PowerShell:** +```powershell +curl.exe -X PATCH http://localhost:8000/api/notes/daily-journal.md -H "Content-Type: application/json" -d "{\"content\": \"New entry here\", \"add_timestamp\": true}" +``` + ### Move Note ```http POST /api/notes/move diff --git a/documentation/MCP.md b/documentation/MCP.md index da0df89..5d07fb6 100644 --- a/documentation/MCP.md +++ b/documentation/MCP.md @@ -10,6 +10,9 @@ MCP (Model Context Protocol) is an open standard that allows AI assistants to se - ๐Ÿ“– **Read** note contents - ๐Ÿท๏ธ **Browse** by tags - ๐Ÿ“ **Create** new notes +- โœ๏ธ **Append** to existing notes (journals, logs) +- ๐Ÿ“‚ **Organize** notes (move, rename, folders) +- ๐Ÿ“‹ **Use templates** to create structured notes - ๐Ÿ”— **Explore** the knowledge graph ## Quick Setup @@ -114,6 +117,7 @@ The MCP server provides these tools to AI assistants: | `search_notes` | Full-text search across all notes | | `list_notes` | List all notes with metadata | | `get_note` | Read a specific note's content | +| `get_recent_notes` | Get recently modified notes (last N days) | ### Organization @@ -128,6 +132,8 @@ The MCP server provides these tools to AI assistants: | Tool | Description | |------|-------------| | `create_note` | Create or update a note | +| `append_to_note` | Append content to an existing note (great for journals/logs) | +| `move_note` | Move or rename a note | | `delete_note` | Delete a note | | `create_folder` | Create a new folder | @@ -137,6 +143,7 @@ The MCP server provides these tools to AI assistants: |------|-------------| | `list_templates` | List available templates | | `get_template` | Get template content | +| `create_note_from_template` | Create a note from a template with variable substitution | ### System @@ -144,6 +151,62 @@ The MCP server provides these tools to AI assistants: |------|-------------| | `health_check` | Verify server connectivity | +## Tool Details + +### `append_to_note` + +Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `path` | string | Yes | Path to existing note | +| `content` | string | Yes | Content to append | +| `add_timestamp` | boolean | No | Add timestamp header before content | + +**Example prompt:** "Add this meeting summary to my daily-journal.md with a timestamp" + +--- + +### `move_note` + +Move or rename a note to a different location. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `old_path` | string | Yes | Current note path | +| `new_path` | string | Yes | New path (can include folder) | + +**Example prompt:** "Move draft.md to published/final-article.md" + +--- + +### `get_recent_notes` + +Get recently modified notes. Useful for context about what you've been working on. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `days` | integer | No | 7 | Notes modified in last N days | +| `limit` | integer | No | 10 | Max notes to return | + +**Example prompt:** "What was I working on this week?" + +--- + +### `create_note_from_template` + +Create a new note from a template with variable substitution. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `template_name` | string | Yes | Template name (e.g., "meeting-notes") | +| `note_path` | string | Yes | Path for the new note | +| `variables` | object | No | Variables to substitute (e.g., `{"project": "Alpha"}`) | + +**Built-in placeholders:** `{{date}}`, `{{time}}`, `{{datetime}}`, `{{title}}`, `{{folder}}` + +**Example prompt:** "Create a new meeting note for Project Alpha using the meeting-notes template" + ## Usage Examples Once configured, you can interact with your notes naturally: @@ -166,6 +229,24 @@ Once configured, you can interact with your notes naturally: > > "You have 7 notes with the #project tag..." +> **User:** "Add this to my daily journal with a timestamp" +> +> **AI:** *Uses `append_to_note` with `add_timestamp: true`* +> +> "Done! I've appended your entry to 'daily-journal.md' with today's timestamp." + +> **User:** "What was I working on last week?" +> +> **AI:** *Uses `get_recent_notes` with `days: 7`* +> +> "You modified 5 notes in the last week: project-roadmap.md, meeting-notes.md..." + +> **User:** "Create a meeting note for the design review using my template" +> +> **AI:** *Uses `create_note_from_template` with the meeting-notes template* +> +> "Created 'meetings/design-review-2024-03-13.md' from your meeting-notes template." + ## Authentication If you have authentication enabled in NoteDiscovery: diff --git a/mcp_server/client.py b/mcp_server/client.py index e1ce7c1..01184bc 100644 --- a/mcp_server/client.py +++ b/mcp_server/client.py @@ -189,6 +189,68 @@ class NoteDiscoveryClient: 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, + variables: dict | None = None + ) -> APIResponse: + """ + Create a note from a template. + + Args: + template_name: Name of the template + note_path: Path for the new note + variables: Variables to substitute in the template + + Returns: + APIResponse with creation result + """ + data = { + "template": template_name, + "path": note_path, + } + if variables: + data["variables"] = variables + + return self._request("POST", "/api/templates/create-note", data=data) + # ========================================================================= # Search API # ========================================================================= diff --git a/mcp_server/server.py b/mcp_server/server.py index 55f1ed1..77ae5f6 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -450,6 +450,113 @@ class MCPServer: return f"Template: {name}\n---\n{content}" + def _tool_append_to_note(self, args: dict) -> str: + """Append content to an existing note.""" + path = args.get("path", "") + content = args.get("content", "") + add_timestamp = args.get("add_timestamp", False) + + is_valid, error = self._validate_path(path) + if not is_valid: + return f"Error: {error}" + if not content: + return "Error: content is required" + + response = self.client.append_to_note(path, content, add_timestamp) + + if not response.success: + return f"Failed to append to note: {response.error}" + + return f"โœ… Content appended to: {path}" + + def _tool_move_note(self, args: dict) -> str: + """Move or rename a note.""" + old_path = args.get("old_path", "") + new_path = args.get("new_path", "") + + is_valid, error = self._validate_path(old_path) + if not is_valid: + return f"Error: old_path - {error}" + + is_valid, error = self._validate_path(new_path) + if not is_valid: + return f"Error: new_path - {error}" + + response = self.client.move_note(old_path, new_path) + + if not response.success: + return f"Failed to move note: {response.error}" + + return f"โœ… Note moved: {old_path} โ†’ {new_path}" + + def _tool_get_recent_notes(self, args: dict) -> str: + """Get recently modified notes.""" + days = args.get("days", 7) + limit = args.get("limit", 10) + + # Get all notes + response = self.client.list_notes() + + if not response.success: + return f"Failed to get notes: {response.error}" + + data = response.data or {} + notes = data.get("notes", []) + + if not notes: + return "No notes found." + + # Filter by date + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(days=days) + + recent_notes = [] + for note in notes: + modified_str = note.get("modified", "") + if modified_str: + try: + # Parse ISO format datetime + modified = datetime.fromisoformat(modified_str.replace("Z", "+00:00")) + if modified.replace(tzinfo=None) >= cutoff: + recent_notes.append(note) + except (ValueError, TypeError): + continue + + # Sort by modified date (most recent first) and limit + recent_notes.sort(key=lambda x: x.get("modified", ""), reverse=True) + recent_notes = recent_notes[:limit] + + if not recent_notes: + return f"No notes modified in the last {days} day(s)." + + output = [f"๐Ÿ“… Notes modified in the last {days} day(s) (showing {len(recent_notes)}):\n"] + for note in recent_notes: + path = note.get("path", "unknown") + modified = note.get("modified", "")[:10] # Just the date part + output.append(f" ๐Ÿ“ {path} (modified: {modified})") + + return "\n".join(output) + + def _tool_create_note_from_template(self, args: dict) -> str: + """Create a note from a template.""" + template_name = args.get("template_name", "") + note_path = args.get("note_path", "") + variables = args.get("variables", {}) + + if not template_name: + return "Error: template_name is required" + + is_valid, error = self._validate_path(note_path) + if not is_valid: + return f"Error: note_path - {error}" + + response = self.client.create_note_from_template(template_name, note_path, variables) + + if not response.success: + return f"Failed to create note from template: {response.error}" + + return f"โœ… Note created from template '{template_name}': {note_path}" + def _tool_health_check(self, args: dict) -> str: """Check server health.""" response = self.client.health_check() diff --git a/mcp_server/tools.py b/mcp_server/tools.py index dd64118..60974bb 100644 --- a/mcp_server/tools.py +++ b/mcp_server/tools.py @@ -139,6 +139,86 @@ TOOLS: list[dict[str, Any]] = [ "required": ["path"] } }, + { + "name": "append_to_note", + "description": "Append content to an existing note without overwriting. Perfect for journals, logs, meeting notes, or collecting ideas incrementally.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the existing note" + }, + "content": { + "type": "string", + "description": "Content to append to the note" + }, + "add_timestamp": { + "type": "boolean", + "description": "Whether to add a timestamp header before the appended content (default: false)" + } + }, + "required": ["path", "content"] + } + }, + { + "name": "move_note", + "description": "Move or rename a note to a different path. Use this to reorganize notes or rename them.", + "inputSchema": { + "type": "object", + "properties": { + "old_path": { + "type": "string", + "description": "Current path of the note" + }, + "new_path": { + "type": "string", + "description": "New path for the note (can be in a different folder)" + } + }, + "required": ["old_path", "new_path"] + } + }, + { + "name": "get_recent_notes", + "description": "Get recently modified notes. Useful for finding what you were working on recently.", + "inputSchema": { + "type": "object", + "properties": { + "days": { + "type": "integer", + "description": "Get notes modified in the last N days (default: 7)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of notes to return (default: 10)" + } + }, + "required": [] + } + }, + { + "name": "create_note_from_template", + "description": "Create a new note from a template with variable substitution. Variables in the template like {{variable_name}} will be replaced.", + "inputSchema": { + "type": "object", + "properties": { + "template_name": { + "type": "string", + "description": "Name of the template to use (e.g., 'meeting-notes', 'daily-journal')" + }, + "note_path": { + "type": "string", + "description": "Path for the new note (e.g., 'meetings/2024-03-13.md')" + }, + "variables": { + "type": "object", + "description": "Variables to substitute in the template (e.g., {\"project\": \"Alpha\", \"date\": \"2024-03-13\"})" + } + }, + "required": ["template_name", "note_path"] + } + }, # ========================================================================= # Templates From 3fff49b84bece8a5a11e655b1def33c380db3488 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 16 Mar 2026 09:12:59 +0100 Subject: [PATCH 4/5] updated readme, landing page --- README.md | 36 ++++++++++++++++++++++++++++++++++++ docs/index.html | 33 +++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b633524..3bf17a7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,41 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - ๐Ÿ•ธ๏ธ **Graph View** - Interactive visualization of connected notes - โญ **Favorites** - Star your most-used notes for instant access - ๐Ÿ“‘ **Outline Panel** - Navigate headings with click-to-jump TOC +- ๐Ÿค– **AI Assistant Ready** - MCP integration for Claude, Cursor & more + +## ๐Ÿค– AI-Powered Note Management + +

+ MCP Compatible + Works with Claude + Works with Cursor +

+ +NoteDiscovery includes a built-in **Model Context Protocol (MCP)** server, letting AI assistants directly interact with your notes: + +| What AI Can Do | Example | +|----------------|---------| +| ๐Ÿ” **Search & Discover** | *"Find all my notes about Docker deployment"* | +| ๐Ÿ“ **Create & Edit** | *"Create a meeting notes template for tomorrow"* | +| ๐Ÿ“ **Organize** | *"Move all project notes to the archive folder"* | +| ๐Ÿท๏ธ **Tag & Categorize** | *"List all notes tagged with #urgent"* | +| ๐Ÿ“Š **Explore Connections** | *"Show me the knowledge graph of my notes"* | +| โœ๏ธ **Append Ideas** | *"Add this thought to my daily journal"* | + +**One-line setup** for Cursor, Claude Desktop, and other MCP-compatible tools: + +```json +{ + "mcpServers": { + "notediscovery": { + "command": "docker", + "args": ["run", "--rm", "-i", "-e", "NOTEDISCOVERY_URL=http://host.docker.internal:8000", "ghcr.io/gamosoft/notediscovery:latest", "python", "-m", "mcp_server"] + } + } +} +``` + +> ๐Ÿ’ก **See [MCP.md](documentation/MCP.md)** for complete setup instructions and all available tools. ## ๐Ÿš€ Quick Start @@ -211,6 +246,7 @@ Want to learn more? - ๐Ÿ“Š **[MERMAID.md](documentation/MERMAID.md)** - Diagram creation with Mermaid (flowcharts, sequence diagrams, and more) - ๐Ÿ”Œ **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins - ๐ŸŒ **[API.md](documentation/API.md)** - REST API documentation and examples +- ๐Ÿค– **[MCP.md](documentation/MCP.md)** - AI assistant integration (Claude, Cursor, and more) - ๐Ÿ” **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance - ๐Ÿ”ง **[ENVIRONMENT_VARIABLES.md](documentation/ENVIRONMENT_VARIABLES.md)** - Configure settings via environment variables diff --git a/docs/index.html b/docs/index.html index 5b0ffef..cb47d41 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,8 +7,8 @@ NoteDiscovery - Your Self-Hosted Knowledge Base - - + + @@ -17,7 +17,7 @@ - + @@ -28,7 +28,7 @@ - + @@ -689,7 +689,7 @@

Take Control of Your Notes

-

A lightweight, privacy-focused note-taking application with powerful features: LaTeX math equations, Mermaid diagrams, smart tags, custom templates, code highlighting, and more. Write, organize, and discover your notes with a beautiful, modern interfaceโ€”all running on your own server.

+

A lightweight, privacy-focused note-taking application with powerful features: AI assistant integration (MCP), LaTeX math equations, Mermaid diagrams, smart tags, custom templates, code highlighting, and more. Write and organize your notes with a beautiful, modern interface all running on your own server.

Try Live Demo Get Started on GitHub โ†’ @@ -840,6 +840,12 @@

No subscriptions, no hidden fees. Free and open source forever.

+
+
๐Ÿค–
+

AI Assistant Ready

+

MCP integration lets Claude, Cursor & other AI tools search, create, and organize your notes.

+
+
๐Ÿงฎ

LaTeX Math

@@ -912,11 +918,6 @@

Progressive Web App (PWA) - install on desktop or mobile for a native experience.

-
-
๐ŸŒ
-

Multi-Language

-

Built-in translations for English, Spanish, German, and French. Easy to add more.

-
@@ -1009,6 +1010,14 @@ [[Link notes]] Obsidian-style
+ +
+ ๐ŸŒ +
+ Multi-Language + English, Spanish, German, French +
+
@@ -1182,11 +1191,11 @@ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "NoteDiscovery", - "description": "A lightweight, Obsidian and Evernote alternative, privacy-focused Markdown note-taking application with LaTeX math, Mermaid diagrams, and code highlighting. Self-hosted, free, and open source.", + "description": "A lightweight, AI-powered Markdown note-taking application with MCP integration for Claude, Cursor and other AI assistants. Features LaTeX math, Mermaid diagrams, graph view, and code highlighting. Self-hosted Obsidian and Evernote alternative. Free and open source.", "url": "https://www.notediscovery.com", "applicationCategory": "ProductivityApplication", "operatingSystem": "Linux, Windows, macOS", - "featureList": "Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", + "featureList": "AI assistant integration, MCP (Model Context Protocol), Claude integration, Cursor integration, Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian alternative, Evernote alternative, Notion alternative, Second brain, AI-powered notes", "offers": { "@type": "Offer", "price": "0", From 7b366f11f25a3936dddaadf824c6cfc261d2f100 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 17 Mar 2026 11:22:22 +0100 Subject: [PATCH 5/5] ignore demo folder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5b26cd4..460ccdb 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ Thumbs.db # Environment .env +# Video demo files +video/