commit
0702812c81
|
|
@ -52,3 +52,5 @@ Thumbs.db
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Video demo files
|
||||||
|
video/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
@ -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,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")
|
||||||
|
|
@ -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,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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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