added mcp server

This commit is contained in:
Gamosoft 2026-03-13 17:53:50 +01:00
parent f097f5e6da
commit a7c073909a
10 changed files with 1539 additions and 1 deletions

View File

@ -56,10 +56,13 @@ COPY --from=builder /install /usr/local
# Copy minified frontend from minifier stage # Copy minified frontend from minifier stage
COPY --from=minifier /build/frontend ./frontend COPY --from=minifier /build/frontend ./frontend
# Copy other application files # Copy application files
COPY backend ./backend COPY backend ./backend
COPY mcp_server ./mcp_server
COPY config.yaml . COPY config.yaml .
COPY VERSION . COPY VERSION .
COPY run.py .
COPY pyproject.toml .
COPY plugins ./plugins COPY plugins ./plugins
COPY themes ./themes COPY themes ./themes
COPY locales ./locales COPY locales ./locales

View File

@ -356,6 +356,32 @@ NoteDiscovery can be installed as a standalone app on your device:
📄 **See [AUTHENTICATION.md](AUTHENTICATION.md)** for setup guide. 📄 **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 ## 🚀 Performance
- **Instant loading** - No lag, no loading spinners - **Instant loading** - No lag, no loading spinners

274
documentation/MCP.md Normal file
View File

@ -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
└── ...
```

24
mcp_server/__init__.py Normal file
View File

@ -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__"]

11
mcp_server/__main__.py Normal file
View File

@ -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()

303
mcp_server/client.py Normal file
View File

@ -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")

79
mcp_server/config.py Normal file
View File

@ -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,
)

534
mcp_server/server.py Normal file
View File

@ -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()

195
mcp_server/tools.py Normal file
View File

@ -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

89
pyproject.toml Normal file
View File

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