Spaces:
Running
Running
bigwolfe
commited on
Commit
·
a5a2c66
1
Parent(s):
9983530
logging and polish
Browse files- README.md +358 -0
- backend/src/api/middleware/error_handlers.py +27 -1
- backend/src/mcp/server.py +77 -0
- backend/src/services/indexer.py +19 -0
- backend/src/services/vault.py +58 -1
- specs/001-obsidian-docs-viewer/tasks.md +13 -13
README.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Viewer
|
| 2 |
+
|
| 3 |
+
A multi-tenant Obsidian-like documentation system with AI agent integration via Model Context Protocol (MCP).
|
| 4 |
+
|
| 5 |
+
## 🎯 Overview
|
| 6 |
+
|
| 7 |
+
Document Viewer enables both humans and AI agents to create, browse, and search documentation with powerful features like:
|
| 8 |
+
|
| 9 |
+
- 📝 **Markdown Notes** with YAML frontmatter
|
| 10 |
+
- 🔗 **Wikilinks** - `[[Note Name]]` style internal linking with auto-resolution
|
| 11 |
+
- 🔍 **Full-Text Search** - BM25 ranking with recency bonus
|
| 12 |
+
- ↩️ **Backlinks** - Automatic tracking of which notes reference each other
|
| 13 |
+
- 🏷️ **Tags** - Organize notes with frontmatter tags
|
| 14 |
+
- ✏️ **Split-Pane Editor** - Live markdown preview with optimistic concurrency
|
| 15 |
+
- 🤖 **MCP Integration** - AI agents can read/write docs via FastMCP
|
| 16 |
+
- 👥 **Multi-Tenant** - Isolated vaults per user (production ready with HF OAuth)
|
| 17 |
+
|
| 18 |
+
## 🏗️ Tech Stack
|
| 19 |
+
|
| 20 |
+
### Backend
|
| 21 |
+
- **FastAPI** - HTTP API server
|
| 22 |
+
- **FastMCP** - MCP server for AI agent integration
|
| 23 |
+
- **SQLite FTS5** - Full-text search with BM25 ranking
|
| 24 |
+
- **python-frontmatter** - YAML frontmatter parsing
|
| 25 |
+
- **PyJWT** - Token-based authentication
|
| 26 |
+
|
| 27 |
+
### Frontend
|
| 28 |
+
- **React + Vite** - Modern web framework
|
| 29 |
+
- **shadcn/ui** - Beautiful UI components
|
| 30 |
+
- **Tailwind CSS** - Utility-first styling
|
| 31 |
+
- **react-markdown** - Markdown rendering with custom wikilink support
|
| 32 |
+
- **TypeScript** - Type-safe frontend code
|
| 33 |
+
|
| 34 |
+
## 📦 Local Setup
|
| 35 |
+
|
| 36 |
+
### Prerequisites
|
| 37 |
+
- Python 3.11+
|
| 38 |
+
- Node.js 18+
|
| 39 |
+
- `uv` (Python package manager) or `pip`
|
| 40 |
+
|
| 41 |
+
### 1. Clone Repository
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
git clone <repository-url>
|
| 45 |
+
cd Document-MCP
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### 2. Backend Setup
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
cd backend
|
| 52 |
+
|
| 53 |
+
# Create virtual environment
|
| 54 |
+
uv venv
|
| 55 |
+
# or: python -m venv .venv
|
| 56 |
+
|
| 57 |
+
# Install dependencies
|
| 58 |
+
uv pip install -e .
|
| 59 |
+
# or: .venv/bin/pip install -e .
|
| 60 |
+
|
| 61 |
+
# Initialize database
|
| 62 |
+
cd ..
|
| 63 |
+
VIRTUAL_ENV=backend/.venv backend/.venv/bin/python -c "from backend.src.services.database import init_database; init_database()"
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### 3. Frontend Setup
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
cd frontend
|
| 70 |
+
|
| 71 |
+
# Install dependencies
|
| 72 |
+
npm install
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 4. Environment Configuration
|
| 76 |
+
|
| 77 |
+
The project includes development scripts that set environment variables automatically. For manual configuration, create a `.env` file in the backend directory:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
# backend/.env
|
| 81 |
+
JWT_SECRET_KEY=your-secret-key-here
|
| 82 |
+
VAULT_BASE_PATH=/path/to/Document-MCP/data/vaults
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
See `.env.example` for all available options.
|
| 86 |
+
|
| 87 |
+
## 🚀 Running the Application
|
| 88 |
+
|
| 89 |
+
### Easy Start (Recommended)
|
| 90 |
+
|
| 91 |
+
Use the provided scripts to start both servers:
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
# Start frontend and backend
|
| 95 |
+
./start-dev.sh
|
| 96 |
+
|
| 97 |
+
# Check status
|
| 98 |
+
./status-dev.sh
|
| 99 |
+
|
| 100 |
+
# Stop servers
|
| 101 |
+
./stop-dev.sh
|
| 102 |
+
|
| 103 |
+
# View logs
|
| 104 |
+
tail -f backend.log frontend.log
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Manual Start
|
| 108 |
+
|
| 109 |
+
#### Running Backend
|
| 110 |
+
|
| 111 |
+
Start the HTTP API server:
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
cd backend
|
| 115 |
+
JWT_SECRET_KEY="local-dev-secret-key-123" \
|
| 116 |
+
VAULT_BASE_PATH="$(pwd)/../data/vaults" \
|
| 117 |
+
.venv/bin/uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Backend will be available at: `http://localhost:8000`
|
| 121 |
+
|
| 122 |
+
API docs (Swagger): `http://localhost:8000/docs`
|
| 123 |
+
|
| 124 |
+
#### Running MCP Server (STDIO Mode)
|
| 125 |
+
|
| 126 |
+
For AI agent integration via MCP:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
cd backend
|
| 130 |
+
JWT_SECRET_KEY="local-dev-secret-key-123" \
|
| 131 |
+
VAULT_BASE_PATH="$(pwd)/../data/vaults" \
|
| 132 |
+
.venv/bin/python -m src.mcp.server
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
#### Running Frontend
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
cd frontend
|
| 139 |
+
npm run dev
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
Frontend will be available at: `http://localhost:5173`
|
| 143 |
+
|
| 144 |
+
## 🤖 MCP Client Configuration
|
| 145 |
+
|
| 146 |
+
To use the Document Viewer with AI agents (Claude Desktop, Cline, etc.), add this to your MCP configuration:
|
| 147 |
+
|
| 148 |
+
### Claude Desktop / Cline
|
| 149 |
+
|
| 150 |
+
Add to `~/.cursor/mcp.json` (or Claude Desktop settings):
|
| 151 |
+
|
| 152 |
+
```json
|
| 153 |
+
{
|
| 154 |
+
"mcpServers": {
|
| 155 |
+
"obsidian-docs": {
|
| 156 |
+
"command": "python",
|
| 157 |
+
"args": ["-m", "backend.src.mcp.server"],
|
| 158 |
+
"cwd": "/path/to/Document-MCP",
|
| 159 |
+
"env": {
|
| 160 |
+
"BEARER_TOKEN": "local-dev-token",
|
| 161 |
+
"FASTMCP_SHOW_CLI_BANNER": "false",
|
| 162 |
+
"PYTHONPATH": "/path/to/Document-MCP",
|
| 163 |
+
"JWT_SECRET_KEY": "local-dev-secret-key-123",
|
| 164 |
+
"VAULT_BASE_PATH": "/path/to/Document-MCP/data/vaults"
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
**Note:** In production, use actual JWT tokens instead of `local-dev-token`.
|
| 172 |
+
|
| 173 |
+
### Available MCP Tools
|
| 174 |
+
|
| 175 |
+
AI agents can use these tools:
|
| 176 |
+
|
| 177 |
+
- `list_notes` - List all notes in vault
|
| 178 |
+
- `read_note` - Read a specific note
|
| 179 |
+
- `write_note` - Create or update a note
|
| 180 |
+
- `delete_note` - Remove a note
|
| 181 |
+
- `search_notes` - Full-text search with BM25 ranking
|
| 182 |
+
- `get_backlinks` - Find notes linking to a target
|
| 183 |
+
- `get_tags` - List all tags with usage counts
|
| 184 |
+
|
| 185 |
+
## 🏛️ Architecture
|
| 186 |
+
|
| 187 |
+
### Data Model
|
| 188 |
+
|
| 189 |
+
**Note Structure:**
|
| 190 |
+
```yaml
|
| 191 |
+
---
|
| 192 |
+
title: My Note
|
| 193 |
+
tags: [guide, tutorial]
|
| 194 |
+
created: 2025-01-15T10:00:00Z
|
| 195 |
+
updated: 2025-01-15T14:30:00Z
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
# My Note
|
| 199 |
+
|
| 200 |
+
Content with [[Wikilinks]] to other notes.
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
**Vault Structure:**
|
| 204 |
+
```
|
| 205 |
+
data/vaults/
|
| 206 |
+
├── local-dev/ # Development user vault
|
| 207 |
+
│ ├── Getting Started.md
|
| 208 |
+
│ ├── API Documentation.md
|
| 209 |
+
│ └── ...
|
| 210 |
+
└── {user_id}/ # Production user vaults
|
| 211 |
+
└── *.md
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
**Index Tables (SQLite):**
|
| 215 |
+
- `note_metadata` - Note versions, titles, timestamps
|
| 216 |
+
- `note_fts` - FTS5 full-text search index
|
| 217 |
+
- `note_tags` - Tag associations
|
| 218 |
+
- `note_links` - Wikilink graph (resolved/unresolved)
|
| 219 |
+
- `index_health` - Index statistics per user
|
| 220 |
+
|
| 221 |
+
### Key Features
|
| 222 |
+
|
| 223 |
+
**Wikilink Resolution:**
|
| 224 |
+
- Normalizes titles to slugs: `[[Getting Started]]` → `getting-started`
|
| 225 |
+
- Matches against both title and filename
|
| 226 |
+
- Prefers same-folder matches
|
| 227 |
+
- Tracks broken links for UI styling
|
| 228 |
+
|
| 229 |
+
**Search Ranking:**
|
| 230 |
+
- BM25 algorithm with title-weighted scoring (3x title, 1x body)
|
| 231 |
+
- Recency bonus: +1.0 for notes updated in last 7 days, +0.5 for last 30 days
|
| 232 |
+
- Returns highlighted snippets with `<mark>` tags
|
| 233 |
+
|
| 234 |
+
**Optimistic Concurrency:**
|
| 235 |
+
- Version-based conflict detection for note edits
|
| 236 |
+
- Prevents data loss from concurrent edits
|
| 237 |
+
- Returns 409 Conflict with helpful message
|
| 238 |
+
|
| 239 |
+
## 🔒 Authentication
|
| 240 |
+
|
| 241 |
+
### Local Development
|
| 242 |
+
Uses a static token: `local-dev-token`
|
| 243 |
+
|
| 244 |
+
### Production (Hugging Face OAuth)
|
| 245 |
+
- Multi-tenant with per-user isolated vaults
|
| 246 |
+
- JWT tokens with user_id claims
|
| 247 |
+
- Automatic vault initialization on first login
|
| 248 |
+
|
| 249 |
+
See deployment documentation for HF OAuth setup.
|
| 250 |
+
|
| 251 |
+
## 📊 Performance Considerations
|
| 252 |
+
|
| 253 |
+
**SQLite Optimizations:**
|
| 254 |
+
- FTS5 with prefix indexes (`prefix='2 3'`) for fast autocomplete and substring matching
|
| 255 |
+
- Recommended: Enable WAL mode for concurrent reads/writes:
|
| 256 |
+
```sql
|
| 257 |
+
PRAGMA journal_mode=WAL;
|
| 258 |
+
PRAGMA synchronous=NORMAL;
|
| 259 |
+
```
|
| 260 |
+
- Normalized slug indexes (`normalized_title_slug`, `normalized_path_slug`) for O(1) wikilink resolution
|
| 261 |
+
- BM25 ranking weights: 3.0 for title matches, 1.0 for body matches
|
| 262 |
+
|
| 263 |
+
**Rate Limiting:**
|
| 264 |
+
- ⚠️ **Production Recommendation**: Add per-user rate limits to prevent abuse
|
| 265 |
+
- API endpoints currently have no rate limiting
|
| 266 |
+
- Consider implementing:
|
| 267 |
+
- `/api/notes` (POST): 100 requests/hour per user
|
| 268 |
+
- `/api/index/rebuild` (POST): 10 requests/day per user
|
| 269 |
+
- `/api/search`: 1000 requests/hour per user
|
| 270 |
+
- Use libraries like `slowapi` or Redis-based rate limiting
|
| 271 |
+
|
| 272 |
+
**Scaling:**
|
| 273 |
+
- **Single-server**: SQLite handles 100K+ notes efficiently
|
| 274 |
+
- **Multi-server**: Migrate to PostgreSQL with `pg_trgm` or `pgvector` for FTS
|
| 275 |
+
- **Caching**: Add Redis for:
|
| 276 |
+
- Session tokens (reduce DB lookups)
|
| 277 |
+
- Frequently accessed notes
|
| 278 |
+
- Search result caching (TTL: 5 minutes)
|
| 279 |
+
- **CDN**: Serve frontend assets via CDN for global performance
|
| 280 |
+
|
| 281 |
+
## 🧪 Development
|
| 282 |
+
|
| 283 |
+
### Project Structure
|
| 284 |
+
|
| 285 |
+
```
|
| 286 |
+
Document-MCP/
|
| 287 |
+
├── backend/
|
| 288 |
+
│ ├── src/
|
| 289 |
+
│ │ ├── api/ # FastAPI routes & middleware
|
| 290 |
+
│ │ ├── mcp/ # FastMCP server
|
| 291 |
+
│ │ ├── models/ # Pydantic models
|
| 292 |
+
│ │ └── services/ # Business logic
|
| 293 |
+
│ └── tests/ # Backend tests
|
| 294 |
+
├── frontend/
|
| 295 |
+
│ ├── src/
|
| 296 |
+
│ │ ├── components/ # React components
|
| 297 |
+
│ │ ├── pages/ # Page components
|
| 298 |
+
│ │ ├── services/ # API client
|
| 299 |
+
│ │ └── types/ # TypeScript types
|
| 300 |
+
│ └── tests/ # Frontend tests
|
| 301 |
+
├── data/
|
| 302 |
+
│ ├── vaults/ # User markdown files
|
| 303 |
+
│ └── index.db # SQLite database
|
| 304 |
+
├── specs/ # Feature specifications
|
| 305 |
+
└── start-dev.sh # Development startup script
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
### Adding a New Note (via UI)
|
| 309 |
+
|
| 310 |
+
1. Click "New Note" button
|
| 311 |
+
2. Enter note name (`.md` extension optional)
|
| 312 |
+
3. Edit in split-pane editor
|
| 313 |
+
4. Save with Cmd/Ctrl+S
|
| 314 |
+
|
| 315 |
+
### Adding a New Note (via MCP)
|
| 316 |
+
|
| 317 |
+
```python
|
| 318 |
+
# AI agent writes a note
|
| 319 |
+
write_note(
|
| 320 |
+
path="guides/my-guide.md",
|
| 321 |
+
body="# My Guide\n\nContent here with [[links]]",
|
| 322 |
+
title="My Guide",
|
| 323 |
+
metadata={"tags": ["guide", "tutorial"]}
|
| 324 |
+
)
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
## 🐛 Troubleshooting
|
| 328 |
+
|
| 329 |
+
**Backend won't start:**
|
| 330 |
+
- Ensure virtual environment is activated
|
| 331 |
+
- Check environment variables are set
|
| 332 |
+
- Verify database is initialized
|
| 333 |
+
|
| 334 |
+
**Frontend shows connection errors:**
|
| 335 |
+
- Ensure backend is running on port 8000
|
| 336 |
+
- Check Vite proxy configuration in `frontend/vite.config.ts`
|
| 337 |
+
|
| 338 |
+
**Search returns no results:**
|
| 339 |
+
- Verify notes are indexed (check Settings → Index Health)
|
| 340 |
+
- Try rebuilding the index via Settings page
|
| 341 |
+
|
| 342 |
+
**MCP tools not showing in Claude:**
|
| 343 |
+
- Verify MCP configuration path is correct
|
| 344 |
+
- Check `PYTHONPATH` includes project root
|
| 345 |
+
- Restart Claude Desktop after config changes
|
| 346 |
+
|
| 347 |
+
## 📝 License
|
| 348 |
+
|
| 349 |
+
[Add license information]
|
| 350 |
+
|
| 351 |
+
## 🤝 Contributing
|
| 352 |
+
|
| 353 |
+
[Add contributing guidelines]
|
| 354 |
+
|
| 355 |
+
## 📧 Contact
|
| 356 |
+
|
| 357 |
+
[Add contact information]
|
| 358 |
+
|
backend/src/api/middleware/error_handlers.py
CHANGED
|
@@ -57,13 +57,39 @@ def _response(status_code: int, detail: Any) -> JSONResponse:
|
|
| 57 |
async def validation_exception_handler(
|
| 58 |
request: Request, exc: RequestValidationError
|
| 59 |
) -> JSONResponse:
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return _response(status.HTTP_400_BAD_REQUEST, detail)
|
| 62 |
|
| 63 |
|
| 64 |
async def http_exception_handler(
|
| 65 |
request: Request, exc: StarletteHTTPException
|
| 66 |
) -> JSONResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
return _response(exc.status_code, exc.detail)
|
| 68 |
|
| 69 |
|
|
|
|
| 57 |
async def validation_exception_handler(
|
| 58 |
request: Request, exc: RequestValidationError
|
| 59 |
) -> JSONResponse:
|
| 60 |
+
# Transform pydantic errors into more user-friendly format
|
| 61 |
+
errors = []
|
| 62 |
+
for error in exc.errors():
|
| 63 |
+
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
| 64 |
+
errors.append({
|
| 65 |
+
"field": field or "request",
|
| 66 |
+
"reason": error["msg"],
|
| 67 |
+
"type": error["type"]
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
detail = {
|
| 71 |
+
"error": "validation_error",
|
| 72 |
+
"message": "Request validation failed",
|
| 73 |
+
"detail": {"fields": errors}
|
| 74 |
+
}
|
| 75 |
+
logger.warning(
|
| 76 |
+
"Validation error",
|
| 77 |
+
extra={"url": str(request.url), "errors": errors}
|
| 78 |
+
)
|
| 79 |
return _response(status.HTTP_400_BAD_REQUEST, detail)
|
| 80 |
|
| 81 |
|
| 82 |
async def http_exception_handler(
|
| 83 |
request: Request, exc: StarletteHTTPException
|
| 84 |
) -> JSONResponse:
|
| 85 |
+
logger.warning(
|
| 86 |
+
"HTTP exception",
|
| 87 |
+
extra={
|
| 88 |
+
"url": str(request.url),
|
| 89 |
+
"status_code": exc.status_code,
|
| 90 |
+
"detail": exc.detail
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
return _response(exc.status_code, exc.detail)
|
| 94 |
|
| 95 |
|
backend/src/mcp/server.py
CHANGED
|
@@ -2,7 +2,9 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import os
|
|
|
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
from fastmcp import FastMCP
|
|
@@ -10,6 +12,8 @@ from pydantic import Field
|
|
| 10 |
|
| 11 |
from ..services import IndexerService, VaultNote, VaultService
|
| 12 |
|
|
|
|
|
|
|
| 13 |
mcp = FastMCP(
|
| 14 |
"obsidian-docs-viewer",
|
| 15 |
instructions=(
|
|
@@ -47,8 +51,23 @@ def list_notes(
|
|
| 47 |
description="Optional relative folder (trim '/' ; no '..' or '\\').",
|
| 48 |
),
|
| 49 |
) -> List[Dict[str, Any]]:
|
|
|
|
| 50 |
user_id = _current_user_id()
|
|
|
|
| 51 |
notes = vault_service.list_notes(user_id, folder=folder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return [
|
| 53 |
{
|
| 54 |
"path": entry["path"],
|
|
@@ -63,8 +82,22 @@ def list_notes(
|
|
| 63 |
def read_note(
|
| 64 |
path: str = Field(..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."),
|
| 65 |
) -> Dict[str, Any]:
|
|
|
|
| 66 |
user_id = _current_user_id()
|
|
|
|
| 67 |
note = vault_service.read_note(user_id, path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return _note_to_response(note)
|
| 69 |
|
| 70 |
|
|
@@ -84,7 +117,9 @@ def write_note(
|
|
| 84 |
description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
|
| 85 |
),
|
| 86 |
) -> Dict[str, Any]:
|
|
|
|
| 87 |
user_id = _current_user_id()
|
|
|
|
| 88 |
note = vault_service.write_note(
|
| 89 |
user_id,
|
| 90 |
path,
|
|
@@ -93,6 +128,18 @@ def write_note(
|
|
| 93 |
body=body,
|
| 94 |
)
|
| 95 |
indexer_service.index_note(user_id, note)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
return {"status": "ok", "path": path}
|
| 97 |
|
| 98 |
|
|
@@ -100,9 +147,23 @@ def write_note(
|
|
| 100 |
def delete_note(
|
| 101 |
path: str = Field(..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."),
|
| 102 |
) -> Dict[str, str]:
|
|
|
|
| 103 |
user_id = _current_user_id()
|
|
|
|
| 104 |
vault_service.delete_note(user_id, path)
|
| 105 |
indexer_service.delete_note_index(user_id, path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return {"status": "ok"}
|
| 107 |
|
| 108 |
|
|
@@ -114,8 +175,24 @@ def search_notes(
|
|
| 114 |
query: str = Field(..., description="Non-empty search query (bm25 + recency)."),
|
| 115 |
limit: int = Field(50, ge=1, le=100, description="Result cap between 1 and 100."),
|
| 116 |
) -> List[Dict[str, Any]]:
|
|
|
|
| 117 |
user_id = _current_user_id()
|
|
|
|
| 118 |
results = indexer_service.search_notes(user_id, query, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
return [
|
| 120 |
{
|
| 121 |
"path": row["path"],
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
+
import time
|
| 8 |
from typing import Any, Dict, List, Optional
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
|
|
|
| 12 |
|
| 13 |
from ..services import IndexerService, VaultNote, VaultService
|
| 14 |
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
mcp = FastMCP(
|
| 18 |
"obsidian-docs-viewer",
|
| 19 |
instructions=(
|
|
|
|
| 51 |
description="Optional relative folder (trim '/' ; no '..' or '\\').",
|
| 52 |
),
|
| 53 |
) -> List[Dict[str, Any]]:
|
| 54 |
+
start_time = time.time()
|
| 55 |
user_id = _current_user_id()
|
| 56 |
+
|
| 57 |
notes = vault_service.list_notes(user_id, folder=folder)
|
| 58 |
+
|
| 59 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 60 |
+
logger.info(
|
| 61 |
+
"MCP tool called",
|
| 62 |
+
extra={
|
| 63 |
+
"tool_name": "list_notes",
|
| 64 |
+
"user_id": user_id,
|
| 65 |
+
"folder": folder or "(root)",
|
| 66 |
+
"result_count": len(notes),
|
| 67 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
return [
|
| 72 |
{
|
| 73 |
"path": entry["path"],
|
|
|
|
| 82 |
def read_note(
|
| 83 |
path: str = Field(..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."),
|
| 84 |
) -> Dict[str, Any]:
|
| 85 |
+
start_time = time.time()
|
| 86 |
user_id = _current_user_id()
|
| 87 |
+
|
| 88 |
note = vault_service.read_note(user_id, path)
|
| 89 |
+
|
| 90 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 91 |
+
logger.info(
|
| 92 |
+
"MCP tool called",
|
| 93 |
+
extra={
|
| 94 |
+
"tool_name": "read_note",
|
| 95 |
+
"user_id": user_id,
|
| 96 |
+
"note_path": path,
|
| 97 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
return _note_to_response(note)
|
| 102 |
|
| 103 |
|
|
|
|
| 117 |
description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
|
| 118 |
),
|
| 119 |
) -> Dict[str, Any]:
|
| 120 |
+
start_time = time.time()
|
| 121 |
user_id = _current_user_id()
|
| 122 |
+
|
| 123 |
note = vault_service.write_note(
|
| 124 |
user_id,
|
| 125 |
path,
|
|
|
|
| 128 |
body=body,
|
| 129 |
)
|
| 130 |
indexer_service.index_note(user_id, note)
|
| 131 |
+
|
| 132 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 133 |
+
logger.info(
|
| 134 |
+
"MCP tool called",
|
| 135 |
+
extra={
|
| 136 |
+
"tool_name": "write_note",
|
| 137 |
+
"user_id": user_id,
|
| 138 |
+
"note_path": path,
|
| 139 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
return {"status": "ok", "path": path}
|
| 144 |
|
| 145 |
|
|
|
|
| 147 |
def delete_note(
|
| 148 |
path: str = Field(..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."),
|
| 149 |
) -> Dict[str, str]:
|
| 150 |
+
start_time = time.time()
|
| 151 |
user_id = _current_user_id()
|
| 152 |
+
|
| 153 |
vault_service.delete_note(user_id, path)
|
| 154 |
indexer_service.delete_note_index(user_id, path)
|
| 155 |
+
|
| 156 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 157 |
+
logger.info(
|
| 158 |
+
"MCP tool called",
|
| 159 |
+
extra={
|
| 160 |
+
"tool_name": "delete_note",
|
| 161 |
+
"user_id": user_id,
|
| 162 |
+
"note_path": path,
|
| 163 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 164 |
+
}
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
return {"status": "ok"}
|
| 168 |
|
| 169 |
|
|
|
|
| 175 |
query: str = Field(..., description="Non-empty search query (bm25 + recency)."),
|
| 176 |
limit: int = Field(50, ge=1, le=100, description="Result cap between 1 and 100."),
|
| 177 |
) -> List[Dict[str, Any]]:
|
| 178 |
+
start_time = time.time()
|
| 179 |
user_id = _current_user_id()
|
| 180 |
+
|
| 181 |
results = indexer_service.search_notes(user_id, query, limit=limit)
|
| 182 |
+
|
| 183 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 184 |
+
logger.info(
|
| 185 |
+
"MCP tool called",
|
| 186 |
+
extra={
|
| 187 |
+
"tool_name": "search_notes",
|
| 188 |
+
"user_id": user_id,
|
| 189 |
+
"query": query,
|
| 190 |
+
"limit": limit,
|
| 191 |
+
"result_count": len(results),
|
| 192 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
return [
|
| 197 |
{
|
| 198 |
"path": row["path"],
|
backend/src/services/indexer.py
CHANGED
|
@@ -3,14 +3,18 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from datetime import datetime, timedelta, timezone
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
import re
|
| 8 |
import sqlite3
|
|
|
|
| 9 |
from typing import Any, Dict, List, Sequence
|
| 10 |
|
| 11 |
from .database import DatabaseService
|
| 12 |
from .vault import VaultNote
|
| 13 |
|
|
|
|
|
|
|
| 14 |
WIKILINK_PATTERN = re.compile(r"\[\[([^\]]+)\]\]")
|
| 15 |
TOKEN_PATTERN = re.compile(r"[0-9A-Za-z]+(?:\*)?")
|
| 16 |
|
|
@@ -68,6 +72,8 @@ class IndexerService:
|
|
| 68 |
|
| 69 |
def index_note(self, user_id: str, note: VaultNote) -> int:
|
| 70 |
"""Insert or update index rows for a note."""
|
|
|
|
|
|
|
| 71 |
note_path = note["path"]
|
| 72 |
metadata = dict(note.get("metadata") or {})
|
| 73 |
title = note.get("title") or metadata.get("title") or Path(note_path).stem
|
|
@@ -149,6 +155,19 @@ class IndexerService:
|
|
| 149 |
|
| 150 |
self.update_index_health(conn, user_id)
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return version
|
| 153 |
finally:
|
| 154 |
conn.close()
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from datetime import datetime, timedelta, timezone
|
| 6 |
+
import logging
|
| 7 |
from pathlib import Path
|
| 8 |
import re
|
| 9 |
import sqlite3
|
| 10 |
+
import time
|
| 11 |
from typing import Any, Dict, List, Sequence
|
| 12 |
|
| 13 |
from .database import DatabaseService
|
| 14 |
from .vault import VaultNote
|
| 15 |
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
WIKILINK_PATTERN = re.compile(r"\[\[([^\]]+)\]\]")
|
| 19 |
TOKEN_PATTERN = re.compile(r"[0-9A-Za-z]+(?:\*)?")
|
| 20 |
|
|
|
|
| 72 |
|
| 73 |
def index_note(self, user_id: str, note: VaultNote) -> int:
|
| 74 |
"""Insert or update index rows for a note."""
|
| 75 |
+
start_time = time.time()
|
| 76 |
+
|
| 77 |
note_path = note["path"]
|
| 78 |
metadata = dict(note.get("metadata") or {})
|
| 79 |
title = note.get("title") or metadata.get("title") or Path(note_path).stem
|
|
|
|
| 155 |
|
| 156 |
self.update_index_health(conn, user_id)
|
| 157 |
|
| 158 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 159 |
+
logger.info(
|
| 160 |
+
"Note indexed successfully",
|
| 161 |
+
extra={
|
| 162 |
+
"user_id": user_id,
|
| 163 |
+
"note_path": note_path,
|
| 164 |
+
"version": version,
|
| 165 |
+
"tags_count": len(tags),
|
| 166 |
+
"wikilinks_count": len(wikilinks),
|
| 167 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 168 |
+
}
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
return version
|
| 172 |
finally:
|
| 173 |
conn.close()
|
backend/src/services/vault.py
CHANGED
|
@@ -3,14 +3,18 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from datetime import datetime, timezone
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
import re
|
|
|
|
| 8 |
from typing import Any, Dict, List, Tuple
|
| 9 |
|
| 10 |
import frontmatter
|
| 11 |
|
| 12 |
from .config import AppConfig, get_config
|
| 13 |
|
|
|
|
|
|
|
| 14 |
INVALID_PATH_CHARS = {'<', '>', ':', '"', '|', '?', '*'}
|
| 15 |
MAX_NOTE_BYTES = 1_048_576
|
| 16 |
H1_PATTERN = re.compile(r"^\s*#\s+(.+)$", re.MULTILINE)
|
|
@@ -115,13 +119,33 @@ class VaultService:
|
|
| 115 |
|
| 116 |
def read_note(self, user_id: str, note_path: str) -> VaultNote:
|
| 117 |
"""Read a Markdown note, returning metadata, body, and derived title."""
|
|
|
|
|
|
|
| 118 |
base = self.initialize_vault(user_id)
|
| 119 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 120 |
if not absolute_path.exists():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
raise FileNotFoundError(f"Note not found: {note_path}")
|
|
|
|
| 122 |
post = frontmatter.load(absolute_path)
|
| 123 |
metadata = dict(post.metadata or {})
|
| 124 |
body = post.content or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return self._build_note_payload(note_path, metadata, body, absolute_path)
|
| 126 |
|
| 127 |
def write_note(
|
|
@@ -134,6 +158,8 @@ class VaultService:
|
|
| 134 |
body: str,
|
| 135 |
) -> VaultNote:
|
| 136 |
"""Create or update a note with validated metadata and content."""
|
|
|
|
|
|
|
| 137 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 138 |
body = body or ""
|
| 139 |
_validate_note_body(body)
|
|
@@ -141,8 +167,9 @@ class VaultService:
|
|
| 141 |
metadata_dict: Dict[str, Any] = dict(metadata or {})
|
| 142 |
_validate_frontmatter(metadata_dict)
|
| 143 |
|
|
|
|
| 144 |
existing_created: str | None = None
|
| 145 |
-
if
|
| 146 |
try:
|
| 147 |
current = frontmatter.load(absolute_path)
|
| 148 |
current_created = current.metadata.get("created")
|
|
@@ -163,14 +190,44 @@ class VaultService:
|
|
| 163 |
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
post = frontmatter.Post(body, **metadata_dict)
|
| 165 |
absolute_path.write_text(frontmatter.dumps(post), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
return self._build_note_payload(note_path, metadata_dict, body, absolute_path)
|
| 167 |
|
| 168 |
def delete_note(self, user_id: str, note_path: str) -> None:
|
| 169 |
"""Delete a note from the vault."""
|
|
|
|
|
|
|
| 170 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 171 |
try:
|
| 172 |
absolute_path.unlink()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
except FileNotFoundError as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
raise FileNotFoundError(f"Note not found: {note_path}") from exc
|
| 175 |
|
| 176 |
def list_notes(self, user_id: str, folder: str | None = None) -> List[Dict[str, Any]]:
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
+
import logging
|
| 7 |
from pathlib import Path
|
| 8 |
import re
|
| 9 |
+
import time
|
| 10 |
from typing import Any, Dict, List, Tuple
|
| 11 |
|
| 12 |
import frontmatter
|
| 13 |
|
| 14 |
from .config import AppConfig, get_config
|
| 15 |
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
INVALID_PATH_CHARS = {'<', '>', ':', '"', '|', '?', '*'}
|
| 19 |
MAX_NOTE_BYTES = 1_048_576
|
| 20 |
H1_PATTERN = re.compile(r"^\s*#\s+(.+)$", re.MULTILINE)
|
|
|
|
| 119 |
|
| 120 |
def read_note(self, user_id: str, note_path: str) -> VaultNote:
|
| 121 |
"""Read a Markdown note, returning metadata, body, and derived title."""
|
| 122 |
+
start_time = time.time()
|
| 123 |
+
|
| 124 |
base = self.initialize_vault(user_id)
|
| 125 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 126 |
if not absolute_path.exists():
|
| 127 |
+
logger.warning(
|
| 128 |
+
"Note not found",
|
| 129 |
+
extra={"user_id": user_id, "note_path": note_path, "operation": "read"}
|
| 130 |
+
)
|
| 131 |
raise FileNotFoundError(f"Note not found: {note_path}")
|
| 132 |
+
|
| 133 |
post = frontmatter.load(absolute_path)
|
| 134 |
metadata = dict(post.metadata or {})
|
| 135 |
body = post.content or ""
|
| 136 |
+
|
| 137 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 138 |
+
logger.info(
|
| 139 |
+
"Note read successfully",
|
| 140 |
+
extra={
|
| 141 |
+
"user_id": user_id,
|
| 142 |
+
"note_path": note_path,
|
| 143 |
+
"operation": "read",
|
| 144 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 145 |
+
"size_bytes": absolute_path.stat().st_size
|
| 146 |
+
}
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
return self._build_note_payload(note_path, metadata, body, absolute_path)
|
| 150 |
|
| 151 |
def write_note(
|
|
|
|
| 158 |
body: str,
|
| 159 |
) -> VaultNote:
|
| 160 |
"""Create or update a note with validated metadata and content."""
|
| 161 |
+
start_time = time.time()
|
| 162 |
+
|
| 163 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 164 |
body = body or ""
|
| 165 |
_validate_note_body(body)
|
|
|
|
| 167 |
metadata_dict: Dict[str, Any] = dict(metadata or {})
|
| 168 |
_validate_frontmatter(metadata_dict)
|
| 169 |
|
| 170 |
+
is_new_note = not absolute_path.exists()
|
| 171 |
existing_created: str | None = None
|
| 172 |
+
if not is_new_note:
|
| 173 |
try:
|
| 174 |
current = frontmatter.load(absolute_path)
|
| 175 |
current_created = current.metadata.get("created")
|
|
|
|
| 190 |
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
post = frontmatter.Post(body, **metadata_dict)
|
| 192 |
absolute_path.write_text(frontmatter.dumps(post), encoding="utf-8")
|
| 193 |
+
|
| 194 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 195 |
+
logger.info(
|
| 196 |
+
f"Note {'created' if is_new_note else 'updated'} successfully",
|
| 197 |
+
extra={
|
| 198 |
+
"user_id": user_id,
|
| 199 |
+
"note_path": note_path,
|
| 200 |
+
"operation": "create" if is_new_note else "update",
|
| 201 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 202 |
+
"size_bytes": len(frontmatter.dumps(post).encode("utf-8"))
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
return self._build_note_payload(note_path, metadata_dict, body, absolute_path)
|
| 207 |
|
| 208 |
def delete_note(self, user_id: str, note_path: str) -> None:
|
| 209 |
"""Delete a note from the vault."""
|
| 210 |
+
start_time = time.time()
|
| 211 |
+
|
| 212 |
absolute_path = self.resolve_note_path(user_id, note_path)
|
| 213 |
try:
|
| 214 |
absolute_path.unlink()
|
| 215 |
+
|
| 216 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 217 |
+
logger.info(
|
| 218 |
+
"Note deleted successfully",
|
| 219 |
+
extra={
|
| 220 |
+
"user_id": user_id,
|
| 221 |
+
"note_path": note_path,
|
| 222 |
+
"operation": "delete",
|
| 223 |
+
"duration_ms": f"{duration_ms:.2f}"
|
| 224 |
+
}
|
| 225 |
+
)
|
| 226 |
except FileNotFoundError as exc:
|
| 227 |
+
logger.warning(
|
| 228 |
+
"Note not found for deletion",
|
| 229 |
+
extra={"user_id": user_id, "note_path": note_path, "operation": "delete"}
|
| 230 |
+
)
|
| 231 |
raise FileNotFoundError(f"Note not found: {note_path}") from exc
|
| 232 |
|
| 233 |
def list_notes(self, user_id: str, folder: str | None = None) -> List[Dict[str, Any]]:
|
specs/001-obsidian-docs-viewer/tasks.md
CHANGED
|
@@ -198,20 +198,20 @@ The MVP delivers immediate value:
|
|
| 198 |
|
| 199 |
**Goal**: Documentation, configuration, logging, error handling improvements.
|
| 200 |
|
| 201 |
-
- [
|
| 202 |
-
- [
|
| 203 |
-
- [
|
| 204 |
-
- [
|
| 205 |
- [ ] [T125] Add README.md section: "Deploying to Hugging Face Space" with environment variables and OAuth setup
|
| 206 |
-
- [
|
| 207 |
-
- [
|
| 208 |
-
- [
|
| 209 |
-
- [
|
| 210 |
-
- [
|
| 211 |
-
- [
|
| 212 |
-
- [
|
| 213 |
-
- [
|
| 214 |
-
- [
|
| 215 |
|
| 216 |
---
|
| 217 |
|
|
|
|
| 198 |
|
| 199 |
**Goal**: Documentation, configuration, logging, error handling improvements.
|
| 200 |
|
| 201 |
+
- [x] [T121] Create README.md with project overview, tech stack, local setup instructions (backend venv + npm install)
|
| 202 |
+
- [x] [T122] Add README.md section: "Running Backend" with uvicorn command for HTTP API and python -m backend.src.mcp.server for MCP STDIO
|
| 203 |
+
- [x] [T123] Add README.md section: "Running Frontend" with npm run dev command
|
| 204 |
+
- [x] [T124] Add README.md section: "MCP Client Configuration" with Claude Code/Desktop STDIO example from mcp-tools.json
|
| 205 |
- [ ] [T125] Add README.md section: "Deploying to Hugging Face Space" with environment variables and OAuth setup
|
| 206 |
+
- [x] [T126] Update .env.example with all variables: JWT_SECRET_KEY, VAULT_BASE_PATH, HF_OAUTH_CLIENT_ID, HF_OAUTH_CLIENT_SECRET, DATABASE_PATH
|
| 207 |
+
- [x] [T127] Add structured logging to backend/src/services/vault.py: log file operations with user_id, note_path, operation type
|
| 208 |
+
- [x] [T128] Add structured logging to backend/src/services/indexer.py: log index updates with user_id, note_path, duration_ms
|
| 209 |
+
- [x] [T129] Add structured logging to backend/src/mcp/server.py: log MCP tool calls with tool_name, user_id, duration_ms
|
| 210 |
+
- [x] [T130] Improve error messages in backend/src/api/middleware/error_handlers.py: include detail objects with field names and reasons
|
| 211 |
+
- [x] [T131] Add input validation to all HTTP API routes: validate path format, content size, required fields
|
| 212 |
+
- [x] [T132] Add input validation to all MCP tools: validate path format, content size via Pydantic models
|
| 213 |
+
- [x] [T133] Add rate limiting consideration to README.md: note potential need for per-user rate limits in production
|
| 214 |
+
- [x] [T134] Add performance optimization notes to README.md: FTS5 prefix indexes, SQLite WAL mode for concurrency
|
| 215 |
|
| 216 |
---
|
| 217 |
|