bigwolfe commited on
Commit
a5a2c66
·
1 Parent(s): 9983530

logging and polish

Browse files
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
- detail = {"detail": {"errors": exc.errors()}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 absolute_path.exists():
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
- - [ ] [T121] Create README.md with project overview, tech stack, local setup instructions (backend venv + npm install)
202
- - [ ] [T122] Add README.md section: "Running Backend" with uvicorn command for HTTP API and python -m backend.src.mcp.server for MCP STDIO
203
- - [ ] [T123] Add README.md section: "Running Frontend" with npm run dev command
204
- - [ ] [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
- - [ ] [T126] Update .env.example with all variables: JWT_SECRET_KEY, VAULT_BASE_PATH, HF_OAUTH_CLIENT_ID, HF_OAUTH_CLIENT_SECRET, DATABASE_PATH
207
- - [ ] [T127] Add structured logging to backend/src/services/vault.py: log file operations with user_id, note_path, operation type
208
- - [ ] [T128] Add structured logging to backend/src/services/indexer.py: log index updates with user_id, note_path, duration_ms
209
- - [ ] [T129] Add structured logging to backend/src/mcp/server.py: log MCP tool calls with tool_name, user_id, duration_ms
210
- - [ ] [T130] Improve error messages in backend/src/api/middleware/error_handlers.py: include detail objects with field names and reasons
211
- - [ ] [T131] Add input validation to all HTTP API routes: validate path format, content size, required fields
212
- - [ ] [T132] Add input validation to all MCP tools: validate path format, content size via Pydantic models
213
- - [ ] [T133] Add rate limiting consideration to README.md: note potential need for per-user rate limits in production
214
- - [ ] [T134] Add performance optimization notes to README.md: FTS5 prefix indexes, SQLite WAL mode for concurrency
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