Merge pull request #185 from gamosoft/features/mcp

Features/mcp
This commit is contained in:
Guillermo Villar 2026-03-17 11:26:26 +01:00 committed by GitHub
commit 0702812c81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2087 additions and 13 deletions

2
.gitignore vendored
View File

@ -52,3 +52,5 @@ Thumbs.db
# Environment # Environment
.env .env
# Video demo files
video/

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

@ -66,6 +66,41 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
- 🕸️ **Graph View** - Interactive visualization of connected notes - 🕸️ **Graph View** - Interactive visualization of connected notes
- ⭐ **Favorites** - Star your most-used notes for instant access - ⭐ **Favorites** - Star your most-used notes for instant access
- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC - 📑 **Outline Panel** - Navigate headings with click-to-jump TOC
- 🤖 **AI Assistant Ready** - MCP integration for Claude, Cursor & more
## 🤖 AI-Powered Note Management
<p align="center">
<img src="https://img.shields.io/badge/MCP-Compatible-blueviolet?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTEyIDJhMTAgMTAgMCAxIDAgMTAgMTBIMTJWMnoiLz48cGF0aCBkPSJNMjEuMTggOC44MmMtLjI4LS40LS43Mi0uODItMS4xOC0uODJoLTNjLS40NiAwLS45LjQyLTEuMTguODItLjI4LjQtLjMyLjk4LS4xMiAxLjQybDEuNSAzYy4yLjQ0LjY2LjcgMS4xMi43aDEuMzZjLjQ2IDAgLjkyLS4yNiAxLjEyLS43bDEuNS0zYy4yLS40NC4xNi0xLjAyLS4xMi0xLjQyeiIvPjwvc3ZnPg==" alt="MCP Compatible">
<img src="https://img.shields.io/badge/Works%20with-Claude-orange?style=for-the-badge" alt="Works with Claude">
<img src="https://img.shields.io/badge/Works%20with-Cursor-blue?style=for-the-badge" alt="Works with Cursor">
</p>
NoteDiscovery includes a built-in **Model Context Protocol (MCP)** server, letting AI assistants directly interact with your notes:
| What AI Can Do | Example |
|----------------|---------|
| 🔍 **Search & Discover** | *"Find all my notes about Docker deployment"* |
| 📝 **Create & Edit** | *"Create a meeting notes template for tomorrow"* |
| 📁 **Organize** | *"Move all project notes to the archive folder"* |
| 🏷️ **Tag & Categorize** | *"List all notes tagged with #urgent"* |
| 📊 **Explore Connections** | *"Show me the knowledge graph of my notes"* |
| ✍️ **Append Ideas** | *"Add this thought to my daily journal"* |
**One-line setup** for Cursor, Claude Desktop, and other MCP-compatible tools:
```json
{
"mcpServers": {
"notediscovery": {
"command": "docker",
"args": ["run", "--rm", "-i", "-e", "NOTEDISCOVERY_URL=http://host.docker.internal:8000", "ghcr.io/gamosoft/notediscovery:latest", "python", "-m", "mcp_server"]
}
}
}
```
> 💡 **See [MCP.md](documentation/MCP.md)** for complete setup instructions and all available tools.
## 🚀 Quick Start ## 🚀 Quick Start
@ -211,6 +246,7 @@ Want to learn more?
- 📊 **[MERMAID.md](documentation/MERMAID.md)** - Diagram creation with Mermaid (flowcharts, sequence diagrams, and more) - 📊 **[MERMAID.md](documentation/MERMAID.md)** - Diagram creation with Mermaid (flowcharts, sequence diagrams, and more)
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins - 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
- 🌐 **[API.md](documentation/API.md)** - REST API documentation and examples - 🌐 **[API.md](documentation/API.md)** - REST API documentation and examples
- 🤖 **[MCP.md](documentation/MCP.md)** - AI assistant integration (Claude, Cursor, and more)
- 🔐 **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance - 🔐 **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance
- 🔧 **[ENVIRONMENT_VARIABLES.md](documentation/ENVIRONMENT_VARIABLES.md)** - Configure settings via environment variables - 🔧 **[ENVIRONMENT_VARIABLES.md](documentation/ENVIRONMENT_VARIABLES.md)** - Configure settings via environment variables

View File

@ -1078,6 +1078,62 @@ async def create_or_update_note(request: Request, note_path: str, content: dict)
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))
@api_router.patch("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("60/minute")
async def append_to_note(request: Request, note_path: str, data: dict):
"""
Append content to an existing note without overwriting.
Perfect for journals, logs, or collecting ideas incrementally.
Args:
note_path: Path to the note
data: Dictionary with 'content' to append and optional 'add_timestamp' boolean
"""
try:
content_to_append = data.get('content', '')
add_timestamp = data.get('add_timestamp', False)
if not content_to_append:
raise HTTPException(status_code=400, detail="Content to append is required")
# Get existing content
existing_content = get_note_content(config['storage']['notes_dir'], note_path)
if existing_content is None:
raise HTTPException(status_code=404, detail="Note not found")
# Build the appended content
if add_timestamp:
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
content_to_append = f"\n\n---\n\n**{timestamp}**\n\n{content_to_append}"
else:
content_to_append = f"\n\n{content_to_append}"
new_content = existing_content + content_to_append
# Run on_note_save hook
transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=new_content)
if transformed_content is None:
transformed_content = new_content
success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
if not success:
raise HTTPException(status_code=500, detail="Failed to append to note")
return {
"success": True,
"path": note_path,
"message": "Content appended successfully"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to append to note"))
@api_router.delete("/notes/{note_path:path}", tags=["Notes"]) @api_router.delete("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def remove_note(request: Request, note_path: str): async def remove_note(request: Request, note_path: str):

View File

@ -7,8 +7,8 @@
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>NoteDiscovery - Your Self-Hosted Knowledge Base</title> <title>NoteDiscovery - Your Self-Hosted Knowledge Base</title>
<meta name="title" content="NoteDiscovery - Your Self-Hosted Knowledge Base"> <meta name="title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
<meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, graph view, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source."> <meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with AI assistant integration (MCP), wikilinks, graph view, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Works with Claude, Cursor, and other AI tools. Self-hosted, free, and open source.">
<meta name="keywords" content="note taking, markdown editor, self-hosted, knowledge base, open source, privacy, notes app, docker, second brain, obsidian alternative, notion, evernote, onenote, latex, mermaid, diagrams, math equations, templates, tags, yaml frontmatter"> <meta name="keywords" content="note taking, markdown editor, self-hosted, knowledge base, open source, privacy, notes app, docker, second brain, obsidian alternative, notion, evernote, onenote, latex, mermaid, diagrams, math equations, templates, tags, yaml frontmatter, AI assistant, MCP, model context protocol, Claude, Cursor, AI notes, AI knowledge base, AI-powered notes">
<meta name="author" content="Gamosoft"> <meta name="author" content="Gamosoft">
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
<link rel="canonical" href="https://www.notediscovery.com"> <link rel="canonical" href="https://www.notediscovery.com">
@ -17,7 +17,7 @@
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://www.notediscovery.com"> <meta property="og:url" content="https://www.notediscovery.com">
<meta property="og:title" content="NoteDiscovery - Your Self-Hosted Knowledge Base"> <meta property="og:title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
<meta property="og:description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source."> <meta property="og:description" content="AI-powered note-taking with MCP integration for Claude, Cursor & more. LaTeX math, Mermaid diagrams, graph view, wikilinks. Self-hosted, free, and open source.">
<meta property="og:image" content="https://www.notediscovery.com/og-image.png"> <meta property="og:image" content="https://www.notediscovery.com/og-image.png">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
@ -28,7 +28,7 @@
<meta name="twitter:site" content="@gamosoft"> <meta name="twitter:site" content="@gamosoft">
<meta name="twitter:creator" content="@gamosoft"> <meta name="twitter:creator" content="@gamosoft">
<meta name="twitter:title" content="NoteDiscovery - Your Self-Hosted Knowledge Base"> <meta name="twitter:title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
<meta name="twitter:description" content="A lightweight, privacy-focused note-taking application with LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source."> <meta name="twitter:description" content="AI-powered note-taking with MCP integration for Claude & Cursor. LaTeX, Mermaid diagrams, graph view. Self-hosted, free, open source.">
<meta name="twitter:image" content="https://www.notediscovery.com/og-image.png"> <meta name="twitter:image" content="https://www.notediscovery.com/og-image.png">
<meta name="twitter:image:alt" content="NoteDiscovery - Self-hosted note-taking application interface"> <meta name="twitter:image:alt" content="NoteDiscovery - Self-hosted note-taking application interface">
@ -689,7 +689,7 @@
<div class="hero animate-on-scroll"> <div class="hero animate-on-scroll">
<div class="hero-content"> <div class="hero-content">
<h2>Take Control of Your Notes</h2> <h2>Take Control of Your Notes</h2>
<p>A lightweight, privacy-focused note-taking application with powerful features: LaTeX math equations, Mermaid diagrams, smart tags, custom templates, code highlighting, and more. Write, organize, and discover your notes with a beautiful, modern interface—all running on your own server.</p> <p>A lightweight, privacy-focused note-taking application with powerful features: AI assistant integration (MCP), LaTeX math equations, Mermaid diagrams, smart tags, custom templates, code highlighting, and more. Write and organize your notes with a beautiful, modern interface all running on your own server.</p>
<div class="cta-buttons"> <div class="cta-buttons">
<a href="https://gamosoft-notediscovery-demo.hf.space" class="cta-button demo" target="_blank" rel="noopener">Try Live Demo</a> <a href="https://gamosoft-notediscovery-demo.hf.space" class="cta-button demo" target="_blank" rel="noopener">Try Live Demo</a>
<a href="https://github.com/gamosoft/NoteDiscovery" class="cta-button" target="_blank" rel="noopener">Get Started on GitHub →</a> <a href="https://github.com/gamosoft/NoteDiscovery" class="cta-button" target="_blank" rel="noopener">Get Started on GitHub →</a>
@ -840,6 +840,12 @@
<p>No subscriptions, no hidden fees. Free and open source forever.</p> <p>No subscriptions, no hidden fees. Free and open source forever.</p>
</div> </div>
<div class="feature animate-on-scroll">
<div class="feature-icon">🤖</div>
<h3>AI Assistant Ready</h3>
<p>MCP integration lets Claude, Cursor & other AI tools search, create, and organize your notes.</p>
</div>
<div class="feature animate-on-scroll"> <div class="feature animate-on-scroll">
<div class="feature-icon">🧮</div> <div class="feature-icon">🧮</div>
<h3>LaTeX Math</h3> <h3>LaTeX Math</h3>
@ -912,11 +918,6 @@
<p>Progressive Web App (PWA) - install on desktop or mobile for a native experience.</p> <p>Progressive Web App (PWA) - install on desktop or mobile for a native experience.</p>
</div> </div>
<div class="feature animate-on-scroll">
<div class="feature-icon">🌍</div>
<h3>Multi-Language</h3>
<p>Built-in translations for English, Spanish, German, and French. Easy to add more.</p>
</div>
</div> </div>
<div class="benefits animate-on-scroll"> <div class="benefits animate-on-scroll">
@ -1009,6 +1010,14 @@
[[Link notes]] Obsidian-style [[Link notes]] Obsidian-style
</div> </div>
</div> </div>
<div class="benefit-item">
<span class="emoji">🌍</span>
<div class="text">
<strong>Multi-Language</strong>
English, Spanish, German, French
</div>
</div>
</div> </div>
</div> </div>
@ -1182,11 +1191,11 @@
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": "NoteDiscovery", "name": "NoteDiscovery",
"description": "A lightweight, Obsidian and Evernote alternative, privacy-focused Markdown note-taking application with LaTeX math, Mermaid diagrams, and code highlighting. Self-hosted, free, and open source.", "description": "A lightweight, AI-powered Markdown note-taking application with MCP integration for Claude, Cursor and other AI assistants. Features LaTeX math, Mermaid diagrams, graph view, and code highlighting. Self-hosted Obsidian and Evernote alternative. Free and open source.",
"url": "https://www.notediscovery.com", "url": "https://www.notediscovery.com",
"applicationCategory": "ProductivityApplication", "applicationCategory": "ProductivityApplication",
"operatingSystem": "Linux, Windows, macOS", "operatingSystem": "Linux, Windows, macOS",
"featureList": "Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", "featureList": "AI assistant integration, MCP (Model Context Protocol), Claude integration, Cursor integration, Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian alternative, Evernote alternative, Notion alternative, Second brain, AI-powered notes",
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "0", "price": "0",

View File

@ -70,6 +70,56 @@ DELETE /api/notes/{note_path}
curl -X DELETE http://localhost:8000/api/notes/test.md curl -X DELETE http://localhost:8000/api/notes/test.md
``` ```
### Append to Note
```http
PATCH /api/notes/{note_path}
Content-Type: application/json
{
"content": "Content to append...",
"add_timestamp": true
}
```
Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas incrementally.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | Content to append to the note |
| `add_timestamp` | boolean | No | If `true`, prepends a timestamp header (default: `false`) |
**Response:**
```json
{
"success": true,
"path": "daily-journal.md",
"message": "Content appended successfully"
}
```
**Example with timestamp:**
```bash
curl -X PATCH http://localhost:8000/api/notes/daily-journal.md \
-H "Content-Type: application/json" \
-d '{"content": "Had a productive meeting about the roadmap.", "add_timestamp": true}'
```
This will append:
```markdown
---
**2024-03-13 14:30**
Had a productive meeting about the roadmap.
```
**Windows PowerShell:**
```powershell
curl.exe -X PATCH http://localhost:8000/api/notes/daily-journal.md -H "Content-Type: application/json" -d "{\"content\": \"New entry here\", \"add_timestamp\": true}"
```
### Move Note ### Move Note
```http ```http
POST /api/notes/move POST /api/notes/move

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

355
documentation/MCP.md Normal file
View File

@ -0,0 +1,355 @@
# 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
- ✏️ **Append** to existing notes (journals, logs)
- 📂 **Organize** notes (move, rename, folders)
- 📋 **Use templates** to create structured 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 |
| `get_recent_notes` | Get recently modified notes (last N days) |
### 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 |
| `append_to_note` | Append content to an existing note (great for journals/logs) |
| `move_note` | Move or rename a note |
| `delete_note` | Delete a note |
| `create_folder` | Create a new folder |
### Templates
| Tool | Description |
|------|-------------|
| `list_templates` | List available templates |
| `get_template` | Get template content |
| `create_note_from_template` | Create a note from a template with variable substitution |
### System
| Tool | Description |
|------|-------------|
| `health_check` | Verify server connectivity |
## Tool Details
### `append_to_note`
Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `path` | string | Yes | Path to existing note |
| `content` | string | Yes | Content to append |
| `add_timestamp` | boolean | No | Add timestamp header before content |
**Example prompt:** "Add this meeting summary to my daily-journal.md with a timestamp"
---
### `move_note`
Move or rename a note to a different location.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `old_path` | string | Yes | Current note path |
| `new_path` | string | Yes | New path (can include folder) |
**Example prompt:** "Move draft.md to published/final-article.md"
---
### `get_recent_notes`
Get recently modified notes. Useful for context about what you've been working on.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `days` | integer | No | 7 | Notes modified in last N days |
| `limit` | integer | No | 10 | Max notes to return |
**Example prompt:** "What was I working on this week?"
---
### `create_note_from_template`
Create a new note from a template with variable substitution.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `template_name` | string | Yes | Template name (e.g., "meeting-notes") |
| `note_path` | string | Yes | Path for the new note |
| `variables` | object | No | Variables to substitute (e.g., `{"project": "Alpha"}`) |
**Built-in placeholders:** `{{date}}`, `{{time}}`, `{{datetime}}`, `{{title}}`, `{{folder}}`
**Example prompt:** "Create a new meeting note for Project Alpha using the meeting-notes template"
## Usage Examples
Once configured, you can interact with your notes naturally:
> **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..."
> **User:** "Add this to my daily journal with a timestamp"
>
> **AI:** *Uses `append_to_note` with `add_timestamp: true`*
>
> "Done! I've appended your entry to 'daily-journal.md' with today's timestamp."
> **User:** "What was I working on last week?"
>
> **AI:** *Uses `get_recent_notes` with `days: 7`*
>
> "You modified 5 notes in the last week: project-roadmap.md, meeting-notes.md..."
> **User:** "Create a meeting note for the design review using my template"
>
> **AI:** *Uses `create_note_from_template` with the meeting-notes template*
>
> "Created 'meetings/design-review-2024-03-13.md' from your meeting-notes template."
## Authentication
If you have authentication enabled in NoteDiscovery:
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()

371
mcp_server/client.py Normal file
View File

@ -0,0 +1,371 @@
"""
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 time
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 and exponential backoff
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) - don't retry, return immediately
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 with backoff
last_error = f"Connection error: {e.reason}"
if attempt < self.config.max_retries - 1:
time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s...
continue
except TimeoutError:
# Timeout - retry with backoff
last_error = f"Request timed out after {self.timeout}s"
if attempt < self.config.max_retries - 1:
time.sleep(2 ** attempt * 0.1) # 0.1s, 0.2s, 0.4s...
continue
except json.JSONDecodeError as e:
return APIResponse(
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}")
def append_to_note(self, path: str, content: str, add_timestamp: bool = False) -> APIResponse:
"""
Append content to an existing note.
Args:
path: Note path
content: Content to append
add_timestamp: Whether to add a timestamp header
Returns:
APIResponse with append result
"""
encoded_path = urllib.parse.quote(path, safe="")
return self._request(
"PATCH",
f"/api/notes/{encoded_path}",
data={"content": content, "add_timestamp": add_timestamp}
)
def move_note(self, old_path: str, new_path: str) -> APIResponse:
"""
Move or rename a note.
Args:
old_path: Current note path
new_path: New note path
Returns:
APIResponse with move result
"""
return self._request(
"POST",
"/api/notes/move",
data={"oldPath": old_path, "newPath": new_path}
)
def create_note_from_template(
self,
template_name: str,
note_path: str,
variables: dict | None = None
) -> APIResponse:
"""
Create a note from a template.
Args:
template_name: Name of the template
note_path: Path for the new note
variables: Variables to substitute in the template
Returns:
APIResponse with creation result
"""
data = {
"template": template_name,
"path": note_path,
}
if variables:
data["variables"] = variables
return self._request("POST", "/api/templates/create-note", data=data)
# =========================================================================
# Search API
# =========================================================================
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,
)

688
mcp_server/server.py Normal file
View File

@ -0,0 +1,688 @@
"""
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 _validate_path(self, path: str) -> tuple[bool, str]:
"""
Validate a path for safety.
Args:
path: The path to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not path:
return False, "path is required"
# Reject path traversal attempts
if ".." in path:
return False, "path cannot contain '..'"
# Reject absolute paths (Unix and Windows)
if path.startswith("/") or path.startswith("\\"):
return False, "path cannot be absolute"
if len(path) >= 2 and path[1] == ":": # Windows drive letter (e.g., C:)
return False, "path cannot be absolute"
# Reject null bytes (security)
if "\x00" in path:
return False, "path contains invalid characters"
return True, ""
def _format_response(self, response: APIResponse) -> str:
"""Format API response as readable text."""
if not response.success:
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", "")
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
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", "")
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
if not content:
return "Error: content is required"
response = self.client.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", "")
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
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", "")
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
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", "")
# Validate template name (same rules as paths for safety)
is_valid, error = self._validate_path(name)
if not is_valid:
return f"Error: {error.replace('path', 'name')}"
response = self.client.get_template(name)
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_append_to_note(self, args: dict) -> str:
"""Append content to an existing note."""
path = args.get("path", "")
content = args.get("content", "")
add_timestamp = args.get("add_timestamp", False)
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
if not content:
return "Error: content is required"
response = self.client.append_to_note(path, content, add_timestamp)
if not response.success:
return f"Failed to append to note: {response.error}"
return f"✅ Content appended to: {path}"
def _tool_move_note(self, args: dict) -> str:
"""Move or rename a note."""
old_path = args.get("old_path", "")
new_path = args.get("new_path", "")
is_valid, error = self._validate_path(old_path)
if not is_valid:
return f"Error: old_path - {error}"
is_valid, error = self._validate_path(new_path)
if not is_valid:
return f"Error: new_path - {error}"
response = self.client.move_note(old_path, new_path)
if not response.success:
return f"Failed to move note: {response.error}"
return f"✅ Note moved: {old_path}{new_path}"
def _tool_get_recent_notes(self, args: dict) -> str:
"""Get recently modified notes."""
days = args.get("days", 7)
limit = args.get("limit", 10)
# Get all notes
response = self.client.list_notes()
if not response.success:
return f"Failed to get notes: {response.error}"
data = response.data or {}
notes = data.get("notes", [])
if not notes:
return "No notes found."
# Filter by date
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=days)
recent_notes = []
for note in notes:
modified_str = note.get("modified", "")
if modified_str:
try:
# Parse ISO format datetime
modified = datetime.fromisoformat(modified_str.replace("Z", "+00:00"))
if modified.replace(tzinfo=None) >= cutoff:
recent_notes.append(note)
except (ValueError, TypeError):
continue
# Sort by modified date (most recent first) and limit
recent_notes.sort(key=lambda x: x.get("modified", ""), reverse=True)
recent_notes = recent_notes[:limit]
if not recent_notes:
return f"No notes modified in the last {days} day(s)."
output = [f"📅 Notes modified in the last {days} day(s) (showing {len(recent_notes)}):\n"]
for note in recent_notes:
path = note.get("path", "unknown")
modified = note.get("modified", "")[:10] # Just the date part
output.append(f" 📝 {path} (modified: {modified})")
return "\n".join(output)
def _tool_create_note_from_template(self, args: dict) -> str:
"""Create a note from a template."""
template_name = args.get("template_name", "")
note_path = args.get("note_path", "")
variables = args.get("variables", {})
if not template_name:
return "Error: template_name is required"
is_valid, error = self._validate_path(note_path)
if not is_valid:
return f"Error: note_path - {error}"
response = self.client.create_note_from_template(template_name, note_path, variables)
if not response.success:
return f"Failed to create note from template: {response.error}"
return f"✅ Note created from template '{template_name}': {note_path}"
def _tool_health_check(self, args: dict) -> str:
"""Check server health."""
response = self.client.health_check()
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":
if not self._initialized:
self._send_response(
request_id,
error=self._error(-32002, "Server not initialized")
)
return
result = self.handle_list_tools(params)
self._send_response(request_id, result)
elif method == "tools/call":
if not self._initialized:
self._send_response(
request_id,
error=self._error(-32002, "Server not initialized")
)
return
result = self.handle_call_tool(params)
self._send_response(request_id, result)
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()

275
mcp_server/tools.py Normal file
View File

@ -0,0 +1,275 @@
"""
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"]
}
},
{
"name": "append_to_note",
"description": "Append content to an existing note without overwriting. Perfect for journals, logs, meeting notes, or collecting ideas incrementally.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the existing note"
},
"content": {
"type": "string",
"description": "Content to append to the note"
},
"add_timestamp": {
"type": "boolean",
"description": "Whether to add a timestamp header before the appended content (default: false)"
}
},
"required": ["path", "content"]
}
},
{
"name": "move_note",
"description": "Move or rename a note to a different path. Use this to reorganize notes or rename them.",
"inputSchema": {
"type": "object",
"properties": {
"old_path": {
"type": "string",
"description": "Current path of the note"
},
"new_path": {
"type": "string",
"description": "New path for the note (can be in a different folder)"
}
},
"required": ["old_path", "new_path"]
}
},
{
"name": "get_recent_notes",
"description": "Get recently modified notes. Useful for finding what you were working on recently.",
"inputSchema": {
"type": "object",
"properties": {
"days": {
"type": "integer",
"description": "Get notes modified in the last N days (default: 7)"
},
"limit": {
"type": "integer",
"description": "Maximum number of notes to return (default: 10)"
}
},
"required": []
}
},
{
"name": "create_note_from_template",
"description": "Create a new note from a template with variable substitution. Variables in the template like {{variable_name}} will be replaced.",
"inputSchema": {
"type": "object",
"properties": {
"template_name": {
"type": "string",
"description": "Name of the template to use (e.g., 'meeting-notes', 'daily-journal')"
},
"note_path": {
"type": "string",
"description": "Path for the new note (e.g., 'meetings/2024-03-13.md')"
},
"variables": {
"type": "object",
"description": "Variables to substitute in the template (e.g., {\"project\": \"Alpha\", \"date\": \"2024-03-13\"})"
}
},
"required": ["template_name", "note_path"]
}
},
# =========================================================================
# Templates
# =========================================================================
{
"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",
]