added mcp server
This commit is contained in:
parent
f097f5e6da
commit
a7c073909a
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
@ -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__"]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue