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", +]