bigwolfe commited on
Commit
2510c5e
Β·
1 Parent(s): 90150ee
CLAUDE.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Document-MCP Development Guidelines
2
+
3
+ Auto-generated from all feature plans. Last updated: 2025-11-15
4
+
5
+ ## Active Technologies
6
+
7
+ - Python 3.11+ + FastAPI, FastMCP, python-frontmatter, PyJWT, huggingface_hub, SQLite (stdlib) (001-obsidian-docs-viewer)
8
+
9
+ ## Project Structure
10
+
11
+ ```text
12
+ src/
13
+ tests/
14
+ ```
15
+
16
+ ## Commands
17
+
18
+ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] pytest [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] ruff check .
19
+
20
+ ## Code Style
21
+
22
+ Python 3.11+: Follow standard conventions
23
+
24
+ ## Recent Changes
25
+
26
+ - 001-obsidian-docs-viewer: Added Python 3.11+ + FastAPI, FastMCP, python-frontmatter, PyJWT, huggingface_hub, SQLite (stdlib)
27
+
28
+ <!-- MANUAL ADDITIONS START -->
29
+ <!-- MANUAL ADDITIONS END -->
specs/001-obsidian-docs-viewer/contracts/http-api.yaml ADDED
@@ -0,0 +1,925 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openapi: 3.1.0
2
+ info:
3
+ title: Obsidian-Like Docs Viewer API
4
+ version: 1.0.0
5
+ description: |
6
+ Multi-tenant Obsidian-like documentation viewer with full-text search, wikilinks, and tags.
7
+
8
+ ## Authentication
9
+ All endpoints require Bearer JWT authentication via the `Authorization` header (except in local mode).
10
+
11
+ ## Multi-tenancy
12
+ All operations are scoped to the authenticated user's vault. Users cannot access other users' notes.
13
+
14
+ ## Path Encoding
15
+ Note paths in URLs must be URL-encoded (e.g., `api/design.md` becomes `api%2Fdesign.md`).
16
+ contact:
17
+ name: API Support
18
+ license:
19
+ name: MIT
20
+
21
+ servers:
22
+ - url: http://localhost:8000
23
+ description: Local development server
24
+ - url: https://your-space.hf.space
25
+ description: Hugging Face Space deployment
26
+
27
+ security:
28
+ - BearerAuth: []
29
+
30
+ tags:
31
+ - name: Authentication
32
+ description: User authentication and token management
33
+ - name: Notes
34
+ description: CRUD operations for notes
35
+ - name: Search
36
+ description: Full-text search and navigation
37
+ - name: Index
38
+ description: Index health and rebuild operations
39
+
40
+ paths:
41
+ /api/tokens:
42
+ post:
43
+ summary: Issue JWT token
44
+ description: Issue a new JWT access token for the authenticated user (90-day expiration)
45
+ operationId: issueToken
46
+ tags:
47
+ - Authentication
48
+ security:
49
+ - BearerAuth: []
50
+ responses:
51
+ '200':
52
+ description: Token issued successfully
53
+ content:
54
+ application/json:
55
+ schema:
56
+ $ref: '#/components/schemas/TokenResponse'
57
+ example:
58
+ token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTczNjk1NjgwMCwiZXhwIjoxNzQ0NzMyODAwfQ.signature
59
+ token_type: bearer
60
+ expires_at: "2025-04-15T10:30:00Z"
61
+ '401':
62
+ $ref: '#/components/responses/Unauthorized'
63
+ '500':
64
+ $ref: '#/components/responses/InternalServerError'
65
+
66
+ /api/me:
67
+ get:
68
+ summary: Get current user info
69
+ description: Retrieve authenticated user information including HF profile
70
+ operationId: getCurrentUser
71
+ tags:
72
+ - Authentication
73
+ responses:
74
+ '200':
75
+ description: User information retrieved
76
+ content:
77
+ application/json:
78
+ schema:
79
+ $ref: '#/components/schemas/User'
80
+ example:
81
+ user_id: alice
82
+ hf_profile:
83
+ username: alice
84
+ name: Alice Smith
85
+ avatar_url: https://cdn-avatars.huggingface.co/v1/alice
86
+ vault_path: /data/vaults/alice
87
+ created: "2025-01-15T10:30:00Z"
88
+ '401':
89
+ $ref: '#/components/responses/Unauthorized'
90
+ '500':
91
+ $ref: '#/components/responses/InternalServerError'
92
+
93
+ /api/notes:
94
+ get:
95
+ summary: List notes
96
+ description: List all notes in the user's vault with optional folder filtering
97
+ operationId: listNotes
98
+ tags:
99
+ - Notes
100
+ parameters:
101
+ - name: folder
102
+ in: query
103
+ description: Filter notes by folder path (e.g., "api" or "guides/tutorials")
104
+ required: false
105
+ schema:
106
+ type: string
107
+ maxLength: 256
108
+ example: api
109
+ responses:
110
+ '200':
111
+ description: List of notes
112
+ content:
113
+ application/json:
114
+ schema:
115
+ type: array
116
+ items:
117
+ $ref: '#/components/schemas/NoteSummary'
118
+ example:
119
+ - note_path: api/design.md
120
+ title: API Design
121
+ updated: "2025-01-15T14:30:00Z"
122
+ - note_path: api/endpoints.md
123
+ title: API Endpoints
124
+ updated: "2025-01-14T09:15:00Z"
125
+ '401':
126
+ $ref: '#/components/responses/Unauthorized'
127
+ '500':
128
+ $ref: '#/components/responses/InternalServerError'
129
+
130
+ /api/notes/{path}:
131
+ get:
132
+ summary: Get note
133
+ description: Retrieve full note content including metadata, body, and version
134
+ operationId: getNote
135
+ tags:
136
+ - Notes
137
+ parameters:
138
+ - $ref: '#/components/parameters/NotePath'
139
+ responses:
140
+ '200':
141
+ description: Note retrieved successfully
142
+ content:
143
+ application/json:
144
+ schema:
145
+ $ref: '#/components/schemas/Note'
146
+ example:
147
+ user_id: alice
148
+ note_path: api/design.md
149
+ version: 5
150
+ title: API Design
151
+ metadata:
152
+ tags:
153
+ - backend
154
+ - api
155
+ project: auth-service
156
+ body: "# API Design\n\nThis document describes the API architecture...\n\n## Endpoints\n\nSee [[Endpoints]] for details."
157
+ created: "2025-01-10T09:00:00Z"
158
+ updated: "2025-01-15T14:30:00Z"
159
+ size_bytes: 4096
160
+ '401':
161
+ $ref: '#/components/responses/Unauthorized'
162
+ '403':
163
+ $ref: '#/components/responses/Forbidden'
164
+ '404':
165
+ description: Note not found
166
+ content:
167
+ application/json:
168
+ schema:
169
+ $ref: '#/components/schemas/Error'
170
+ example:
171
+ error: not_found
172
+ message: Note not found
173
+ detail:
174
+ note_path: api/design.md
175
+ '500':
176
+ $ref: '#/components/responses/InternalServerError'
177
+
178
+ put:
179
+ summary: Create or update note
180
+ description: Create a new note or update an existing note with optional optimistic concurrency control
181
+ operationId: createOrUpdateNote
182
+ tags:
183
+ - Notes
184
+ parameters:
185
+ - $ref: '#/components/parameters/NotePath'
186
+ requestBody:
187
+ required: true
188
+ content:
189
+ application/json:
190
+ schema:
191
+ $ref: '#/components/schemas/NoteUpdate'
192
+ example:
193
+ title: API Design
194
+ metadata:
195
+ tags:
196
+ - backend
197
+ - api
198
+ project: auth-service
199
+ body: "# API Design\n\nThis document describes the API architecture..."
200
+ if_version: 4
201
+ responses:
202
+ '200':
203
+ description: Note updated successfully
204
+ content:
205
+ application/json:
206
+ schema:
207
+ $ref: '#/components/schemas/NoteResponse'
208
+ example:
209
+ status: ok
210
+ path: api/design.md
211
+ version: 5
212
+ '201':
213
+ description: Note created successfully
214
+ content:
215
+ application/json:
216
+ schema:
217
+ $ref: '#/components/schemas/NoteResponse'
218
+ example:
219
+ status: ok
220
+ path: api/design.md
221
+ version: 1
222
+ '400':
223
+ $ref: '#/components/responses/BadRequest'
224
+ '401':
225
+ $ref: '#/components/responses/Unauthorized'
226
+ '403':
227
+ $ref: '#/components/responses/Forbidden'
228
+ '409':
229
+ $ref: '#/components/responses/Conflict'
230
+ '413':
231
+ $ref: '#/components/responses/PayloadTooLarge'
232
+ '500':
233
+ $ref: '#/components/responses/InternalServerError'
234
+
235
+ delete:
236
+ summary: Delete note
237
+ description: Delete a note and remove it from all indices
238
+ operationId: deleteNote
239
+ tags:
240
+ - Notes
241
+ parameters:
242
+ - $ref: '#/components/parameters/NotePath'
243
+ responses:
244
+ '200':
245
+ description: Note deleted successfully
246
+ content:
247
+ application/json:
248
+ schema:
249
+ $ref: '#/components/schemas/DeleteResponse'
250
+ example:
251
+ status: ok
252
+ path: api/design.md
253
+ '401':
254
+ $ref: '#/components/responses/Unauthorized'
255
+ '403':
256
+ $ref: '#/components/responses/Forbidden'
257
+ '404':
258
+ description: Note not found
259
+ content:
260
+ application/json:
261
+ schema:
262
+ $ref: '#/components/schemas/Error'
263
+ example:
264
+ error: not_found
265
+ message: Note not found
266
+ detail:
267
+ note_path: api/design.md
268
+ '500':
269
+ $ref: '#/components/responses/InternalServerError'
270
+
271
+ /api/search:
272
+ get:
273
+ summary: Search notes
274
+ description: Full-text search across all notes with ranking (title 3x weight, body 1x, recency bonus)
275
+ operationId: searchNotes
276
+ tags:
277
+ - Search
278
+ parameters:
279
+ - name: q
280
+ in: query
281
+ description: Search query (tokenized, case-insensitive)
282
+ required: true
283
+ schema:
284
+ type: string
285
+ minLength: 1
286
+ maxLength: 256
287
+ example: authentication
288
+ - name: limit
289
+ in: query
290
+ description: Maximum number of results to return
291
+ required: false
292
+ schema:
293
+ type: integer
294
+ minimum: 1
295
+ maximum: 100
296
+ default: 50
297
+ responses:
298
+ '200':
299
+ description: Search results
300
+ content:
301
+ application/json:
302
+ schema:
303
+ type: array
304
+ items:
305
+ $ref: '#/components/schemas/SearchResult'
306
+ example:
307
+ - note_path: api/auth.md
308
+ title: Authentication Flow
309
+ snippet: "...describes the <mark>authentication</mark> process using JWT tokens..."
310
+ score: 8.5
311
+ updated: "2025-01-15T14:30:00Z"
312
+ - note_path: guides/setup.md
313
+ title: Setup Guide
314
+ snippet: "...configure <mark>authentication</mark> settings in the config file..."
315
+ score: 3.2
316
+ updated: "2025-01-10T09:00:00Z"
317
+ '400':
318
+ $ref: '#/components/responses/BadRequest'
319
+ '401':
320
+ $ref: '#/components/responses/Unauthorized'
321
+ '500':
322
+ $ref: '#/components/responses/InternalServerError'
323
+
324
+ /api/backlinks/{path}:
325
+ get:
326
+ summary: Get backlinks
327
+ description: Get all notes that reference the specified note via wikilinks
328
+ operationId: getBacklinks
329
+ tags:
330
+ - Search
331
+ parameters:
332
+ - name: path
333
+ in: path
334
+ description: URL-encoded note path (includes .md extension)
335
+ required: true
336
+ schema:
337
+ type: string
338
+ maxLength: 256
339
+ example: api%2Fdesign.md
340
+ responses:
341
+ '200':
342
+ description: Backlinks retrieved
343
+ content:
344
+ application/json:
345
+ schema:
346
+ type: array
347
+ items:
348
+ $ref: '#/components/schemas/BacklinkResult'
349
+ example:
350
+ - note_path: guides/architecture.md
351
+ title: Architecture Overview
352
+ - note_path: api/endpoints.md
353
+ title: API Endpoints
354
+ '401':
355
+ $ref: '#/components/responses/Unauthorized'
356
+ '404':
357
+ description: Note not found
358
+ content:
359
+ application/json:
360
+ schema:
361
+ $ref: '#/components/schemas/Error'
362
+ example:
363
+ error: not_found
364
+ message: Note not found
365
+ detail:
366
+ note_path: api/design.md
367
+ '500':
368
+ $ref: '#/components/responses/InternalServerError'
369
+
370
+ /api/tags:
371
+ get:
372
+ summary: List all tags
373
+ description: Get all tags used in the user's vault with note counts
374
+ operationId: listTags
375
+ tags:
376
+ - Search
377
+ responses:
378
+ '200':
379
+ description: List of tags
380
+ content:
381
+ application/json:
382
+ schema:
383
+ type: array
384
+ items:
385
+ $ref: '#/components/schemas/Tag'
386
+ example:
387
+ - tag: backend
388
+ count: 15
389
+ - tag: api
390
+ count: 12
391
+ - tag: frontend
392
+ count: 8
393
+ '401':
394
+ $ref: '#/components/responses/Unauthorized'
395
+ '500':
396
+ $ref: '#/components/responses/InternalServerError'
397
+
398
+ /api/index/health:
399
+ get:
400
+ summary: Get index health status
401
+ description: Retrieve index health metrics including note count and last update timestamps
402
+ operationId: getIndexHealth
403
+ tags:
404
+ - Index
405
+ responses:
406
+ '200':
407
+ description: Index health metrics
408
+ content:
409
+ application/json:
410
+ schema:
411
+ $ref: '#/components/schemas/IndexHealth'
412
+ example:
413
+ user_id: alice
414
+ note_count: 142
415
+ last_full_rebuild: "2025-01-01T00:00:00Z"
416
+ last_incremental_update: "2025-01-15T14:30:00Z"
417
+ '401':
418
+ $ref: '#/components/responses/Unauthorized'
419
+ '500':
420
+ $ref: '#/components/responses/InternalServerError'
421
+
422
+ /api/index/rebuild:
423
+ post:
424
+ summary: Trigger full index rebuild
425
+ description: Manually rebuild all indices from scratch by re-scanning vault files
426
+ operationId: rebuildIndex
427
+ tags:
428
+ - Index
429
+ responses:
430
+ '200':
431
+ description: Index rebuild completed
432
+ content:
433
+ application/json:
434
+ schema:
435
+ $ref: '#/components/schemas/RebuildResponse'
436
+ example:
437
+ status: ok
438
+ notes_indexed: 142
439
+ duration_ms: 1250
440
+ '401':
441
+ $ref: '#/components/responses/Unauthorized'
442
+ '500':
443
+ $ref: '#/components/responses/InternalServerError'
444
+
445
+ components:
446
+ securitySchemes:
447
+ BearerAuth:
448
+ type: http
449
+ scheme: bearer
450
+ bearerFormat: JWT
451
+ description: JWT token obtained from POST /api/tokens
452
+
453
+ parameters:
454
+ NotePath:
455
+ name: path
456
+ in: path
457
+ description: URL-encoded note path (includes .md extension, e.g., "api%2Fdesign.md")
458
+ required: true
459
+ schema:
460
+ type: string
461
+ maxLength: 256
462
+ example: api%2Fdesign.md
463
+
464
+ schemas:
465
+ User:
466
+ type: object
467
+ required:
468
+ - user_id
469
+ - vault_path
470
+ - created
471
+ properties:
472
+ user_id:
473
+ type: string
474
+ minLength: 1
475
+ maxLength: 64
476
+ description: Internal user identifier
477
+ example: alice
478
+ hf_profile:
479
+ $ref: '#/components/schemas/HFProfile'
480
+ vault_path:
481
+ type: string
482
+ description: Absolute path to user's vault directory
483
+ example: /data/vaults/alice
484
+ created:
485
+ type: string
486
+ format: date-time
487
+ description: Account creation timestamp (ISO 8601)
488
+ example: "2025-01-15T10:30:00Z"
489
+
490
+ HFProfile:
491
+ type: object
492
+ properties:
493
+ username:
494
+ type: string
495
+ description: Hugging Face username
496
+ example: alice
497
+ name:
498
+ type: string
499
+ nullable: true
500
+ description: Display name from HF profile
501
+ example: Alice Smith
502
+ avatar_url:
503
+ type: string
504
+ format: uri
505
+ nullable: true
506
+ description: Profile picture URL
507
+ example: https://cdn-avatars.huggingface.co/v1/alice
508
+
509
+ NoteMetadata:
510
+ type: object
511
+ description: Arbitrary frontmatter key-value pairs (YAML)
512
+ properties:
513
+ title:
514
+ type: string
515
+ nullable: true
516
+ tags:
517
+ type: array
518
+ items:
519
+ type: string
520
+ nullable: true
521
+ project:
522
+ type: string
523
+ nullable: true
524
+ created:
525
+ type: string
526
+ format: date-time
527
+ nullable: true
528
+ updated:
529
+ type: string
530
+ format: date-time
531
+ nullable: true
532
+ additionalProperties: true
533
+ example:
534
+ tags:
535
+ - backend
536
+ - api
537
+ project: auth-service
538
+
539
+ Note:
540
+ type: object
541
+ required:
542
+ - user_id
543
+ - note_path
544
+ - version
545
+ - title
546
+ - body
547
+ - created
548
+ - updated
549
+ - size_bytes
550
+ properties:
551
+ user_id:
552
+ type: string
553
+ description: Owner user ID
554
+ example: alice
555
+ note_path:
556
+ type: string
557
+ minLength: 1
558
+ maxLength: 256
559
+ description: Relative path to vault root (includes .md)
560
+ example: api/design.md
561
+ version:
562
+ type: integer
563
+ minimum: 1
564
+ description: Optimistic concurrency version counter
565
+ example: 5
566
+ title:
567
+ type: string
568
+ minLength: 1
569
+ description: Display title (from frontmatter, H1, or filename)
570
+ example: API Design
571
+ metadata:
572
+ $ref: '#/components/schemas/NoteMetadata'
573
+ body:
574
+ type: string
575
+ description: Markdown content (excluding frontmatter)
576
+ example: "# API Design\n\nThis document describes..."
577
+ created:
578
+ type: string
579
+ format: date-time
580
+ description: Creation timestamp (ISO 8601)
581
+ example: "2025-01-10T09:00:00Z"
582
+ updated:
583
+ type: string
584
+ format: date-time
585
+ description: Last modification timestamp (ISO 8601)
586
+ example: "2025-01-15T14:30:00Z"
587
+ size_bytes:
588
+ type: integer
589
+ minimum: 0
590
+ maximum: 1048576
591
+ description: File size in bytes (max 1 MiB)
592
+ example: 4096
593
+
594
+ NoteSummary:
595
+ type: object
596
+ required:
597
+ - note_path
598
+ - title
599
+ - updated
600
+ properties:
601
+ note_path:
602
+ type: string
603
+ example: api/design.md
604
+ title:
605
+ type: string
606
+ example: API Design
607
+ updated:
608
+ type: string
609
+ format: date-time
610
+ example: "2025-01-15T14:30:00Z"
611
+
612
+ NoteUpdate:
613
+ type: object
614
+ required:
615
+ - body
616
+ properties:
617
+ title:
618
+ type: string
619
+ nullable: true
620
+ description: Override title (if not set, will be extracted from frontmatter or body)
621
+ metadata:
622
+ $ref: '#/components/schemas/NoteMetadata'
623
+ body:
624
+ type: string
625
+ maxLength: 1048576
626
+ description: Markdown content (max 1 MiB UTF-8)
627
+ if_version:
628
+ type: integer
629
+ minimum: 1
630
+ nullable: true
631
+ description: Expected version for optimistic concurrency (409 if mismatch)
632
+ example: 4
633
+
634
+ NoteResponse:
635
+ type: object
636
+ required:
637
+ - status
638
+ - path
639
+ - version
640
+ properties:
641
+ status:
642
+ type: string
643
+ enum:
644
+ - ok
645
+ path:
646
+ type: string
647
+ example: api/design.md
648
+ version:
649
+ type: integer
650
+ example: 5
651
+
652
+ DeleteResponse:
653
+ type: object
654
+ required:
655
+ - status
656
+ - path
657
+ properties:
658
+ status:
659
+ type: string
660
+ enum:
661
+ - ok
662
+ path:
663
+ type: string
664
+ example: api/design.md
665
+
666
+ SearchResult:
667
+ type: object
668
+ required:
669
+ - note_path
670
+ - title
671
+ - snippet
672
+ - score
673
+ - updated
674
+ properties:
675
+ note_path:
676
+ type: string
677
+ example: api/auth.md
678
+ title:
679
+ type: string
680
+ example: Authentication Flow
681
+ snippet:
682
+ type: string
683
+ description: Highlighted excerpt with <mark> tags
684
+ example: "...describes the <mark>authentication</mark> process using JWT..."
685
+ score:
686
+ type: number
687
+ format: float
688
+ description: Relevance score (title 3x, body 1x, recency bonus)
689
+ example: 8.5
690
+ updated:
691
+ type: string
692
+ format: date-time
693
+ example: "2025-01-15T14:30:00Z"
694
+
695
+ BacklinkResult:
696
+ type: object
697
+ required:
698
+ - note_path
699
+ - title
700
+ properties:
701
+ note_path:
702
+ type: string
703
+ example: guides/architecture.md
704
+ title:
705
+ type: string
706
+ example: Architecture Overview
707
+
708
+ Tag:
709
+ type: object
710
+ required:
711
+ - tag
712
+ - count
713
+ properties:
714
+ tag:
715
+ type: string
716
+ description: Tag name (lowercase, normalized)
717
+ example: backend
718
+ count:
719
+ type: integer
720
+ minimum: 0
721
+ description: Number of notes with this tag
722
+ example: 15
723
+
724
+ IndexHealth:
725
+ type: object
726
+ required:
727
+ - user_id
728
+ - note_count
729
+ properties:
730
+ user_id:
731
+ type: string
732
+ example: alice
733
+ note_count:
734
+ type: integer
735
+ minimum: 0
736
+ description: Total notes indexed
737
+ example: 142
738
+ last_full_rebuild:
739
+ type: string
740
+ format: date-time
741
+ nullable: true
742
+ description: Timestamp of last manual rebuild
743
+ example: "2025-01-01T00:00:00Z"
744
+ last_incremental_update:
745
+ type: string
746
+ format: date-time
747
+ nullable: true
748
+ description: Timestamp of last write/delete operation
749
+ example: "2025-01-15T14:30:00Z"
750
+
751
+ RebuildResponse:
752
+ type: object
753
+ required:
754
+ - status
755
+ - notes_indexed
756
+ - duration_ms
757
+ properties:
758
+ status:
759
+ type: string
760
+ enum:
761
+ - ok
762
+ notes_indexed:
763
+ type: integer
764
+ minimum: 0
765
+ example: 142
766
+ duration_ms:
767
+ type: integer
768
+ minimum: 0
769
+ description: Rebuild duration in milliseconds
770
+ example: 1250
771
+
772
+ TokenResponse:
773
+ type: object
774
+ required:
775
+ - token
776
+ - token_type
777
+ - expires_at
778
+ properties:
779
+ token:
780
+ type: string
781
+ description: JWT access token
782
+ example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
783
+ token_type:
784
+ type: string
785
+ enum:
786
+ - bearer
787
+ example: bearer
788
+ expires_at:
789
+ type: string
790
+ format: date-time
791
+ description: Token expiration timestamp (90 days from issuance)
792
+ example: "2025-04-15T10:30:00Z"
793
+
794
+ Error:
795
+ type: object
796
+ required:
797
+ - error
798
+ - message
799
+ properties:
800
+ error:
801
+ type: string
802
+ description: Error code (machine-readable)
803
+ example: validation_error
804
+ message:
805
+ type: string
806
+ description: Human-readable error message
807
+ example: Invalid note path format
808
+ detail:
809
+ type: object
810
+ nullable: true
811
+ description: Additional error context
812
+ additionalProperties: true
813
+ example:
814
+ field: note_path
815
+ reason: Path must end with .md
816
+
817
+ responses:
818
+ BadRequest:
819
+ description: Bad request (invalid input)
820
+ content:
821
+ application/json:
822
+ schema:
823
+ $ref: '#/components/schemas/Error'
824
+ examples:
825
+ invalidPath:
826
+ summary: Invalid note path
827
+ value:
828
+ error: validation_error
829
+ message: Invalid note path format
830
+ detail:
831
+ field: note_path
832
+ reason: Path must end with .md
833
+ emptyQuery:
834
+ summary: Empty search query
835
+ value:
836
+ error: validation_error
837
+ message: Search query cannot be empty
838
+ detail:
839
+ field: query
840
+ reason: Query must be at least 1 character
841
+
842
+ Unauthorized:
843
+ description: Authentication required or token invalid/expired
844
+ content:
845
+ application/json:
846
+ schema:
847
+ $ref: '#/components/schemas/Error'
848
+ examples:
849
+ missingToken:
850
+ summary: Missing authorization header
851
+ value:
852
+ error: unauthorized
853
+ message: Authorization header required
854
+ expiredToken:
855
+ summary: Expired JWT token
856
+ value:
857
+ error: token_expired
858
+ message: Token expired, please re-authenticate
859
+ invalidToken:
860
+ summary: Invalid JWT token
861
+ value:
862
+ error: invalid_token
863
+ message: Invalid token signature
864
+
865
+ Forbidden:
866
+ description: Forbidden (vault limit exceeded or permission denied)
867
+ content:
868
+ application/json:
869
+ schema:
870
+ $ref: '#/components/schemas/Error'
871
+ examples:
872
+ vaultLimitExceeded:
873
+ summary: Vault note limit exceeded
874
+ value:
875
+ error: vault_note_limit_exceeded
876
+ message: Vault has reached maximum of 5,000 notes
877
+ detail:
878
+ current_count: 5000
879
+ max_limit: 5000
880
+ pathTraversal:
881
+ summary: Path traversal attempt
882
+ value:
883
+ error: forbidden
884
+ message: Path escapes vault root
885
+ detail:
886
+ note_path: ../../../etc/passwd
887
+
888
+ Conflict:
889
+ description: Version conflict (optimistic concurrency failure)
890
+ content:
891
+ application/json:
892
+ schema:
893
+ $ref: '#/components/schemas/Error'
894
+ example:
895
+ error: version_conflict
896
+ message: Note was modified since you opened it
897
+ detail:
898
+ expected_version: 4
899
+ current_version: 6
900
+ note_path: api/design.md
901
+
902
+ PayloadTooLarge:
903
+ description: Note content exceeds 1 MiB limit
904
+ content:
905
+ application/json:
906
+ schema:
907
+ $ref: '#/components/schemas/Error'
908
+ example:
909
+ error: payload_too_large
910
+ message: Note content exceeds maximum size of 1 MiB
911
+ detail:
912
+ size_bytes: 1572864
913
+ max_size_bytes: 1048576
914
+
915
+ InternalServerError:
916
+ description: Internal server error
917
+ content:
918
+ application/json:
919
+ schema:
920
+ $ref: '#/components/schemas/Error'
921
+ example:
922
+ error: internal_error
923
+ message: An unexpected error occurred
924
+ detail:
925
+ request_id: req_abc123
specs/001-obsidian-docs-viewer/contracts/mcp-tools.json ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft-07/schema#",
3
+ "title": "MCP Tools for Obsidian-Like Docs Viewer",
4
+ "description": "FastMCP tool definitions for multi-tenant documentation viewer with JSON Schema validation (Pydantic)",
5
+ "tools": [
6
+ {
7
+ "name": "list_notes",
8
+ "title": "List Notes",
9
+ "description": "List all notes in the authenticated user's vault with optional folder filtering. Returns lightweight summaries with path, title, and last modified timestamp.",
10
+ "inputSchema": {
11
+ "type": "object",
12
+ "properties": {
13
+ "folder": {
14
+ "type": "string",
15
+ "description": "Optional folder path to filter notes (e.g., 'api' or 'guides/tutorials'). If omitted, returns all notes in the vault.",
16
+ "maxLength": 256,
17
+ "examples": ["api", "guides/tutorials", ""]
18
+ }
19
+ },
20
+ "additionalProperties": false
21
+ },
22
+ "outputSchema": {
23
+ "type": "array",
24
+ "description": "Array of note summaries, sorted by most recently updated first",
25
+ "items": {
26
+ "type": "object",
27
+ "required": ["path", "title", "last_modified"],
28
+ "properties": {
29
+ "path": {
30
+ "type": "string",
31
+ "description": "Relative path to vault root (includes .md extension)",
32
+ "examples": ["api/design.md", "README.md"]
33
+ },
34
+ "title": {
35
+ "type": "string",
36
+ "description": "Display title (from frontmatter, H1, or filename)",
37
+ "examples": ["API Design", "README"]
38
+ },
39
+ "last_modified": {
40
+ "type": "string",
41
+ "format": "date-time",
42
+ "description": "Last modification timestamp (ISO 8601)",
43
+ "examples": ["2025-01-15T14:30:00Z"]
44
+ }
45
+ }
46
+ },
47
+ "examples": [
48
+ [
49
+ {
50
+ "path": "api/design.md",
51
+ "title": "API Design",
52
+ "last_modified": "2025-01-15T14:30:00Z"
53
+ },
54
+ {
55
+ "path": "api/endpoints.md",
56
+ "title": "API Endpoints",
57
+ "last_modified": "2025-01-14T09:15:00Z"
58
+ }
59
+ ]
60
+ ]
61
+ }
62
+ },
63
+ {
64
+ "name": "read_note",
65
+ "title": "Read Note",
66
+ "description": "Retrieve full note content including metadata (frontmatter), body (markdown), and system-managed fields (version, timestamps). Use this to access existing documentation.",
67
+ "inputSchema": {
68
+ "type": "object",
69
+ "required": ["path"],
70
+ "properties": {
71
+ "path": {
72
+ "type": "string",
73
+ "description": "Relative path to vault root (includes .md extension, e.g., 'api/design.md')",
74
+ "minLength": 1,
75
+ "maxLength": 256,
76
+ "pattern": "^[^/].*\\.md$",
77
+ "examples": ["api/design.md", "README.md", "guides/setup.md"]
78
+ }
79
+ },
80
+ "additionalProperties": false
81
+ },
82
+ "outputSchema": {
83
+ "type": "object",
84
+ "required": ["path", "title", "metadata", "body"],
85
+ "properties": {
86
+ "path": {
87
+ "type": "string",
88
+ "description": "Note path (echo of input)",
89
+ "examples": ["api/design.md"]
90
+ },
91
+ "title": {
92
+ "type": "string",
93
+ "description": "Display title",
94
+ "examples": ["API Design"]
95
+ },
96
+ "metadata": {
97
+ "type": "object",
98
+ "description": "Frontmatter key-value pairs (arbitrary structure)",
99
+ "properties": {
100
+ "tags": {
101
+ "type": "array",
102
+ "items": {
103
+ "type": "string"
104
+ }
105
+ },
106
+ "project": {
107
+ "type": "string"
108
+ },
109
+ "created": {
110
+ "type": "string",
111
+ "format": "date-time"
112
+ },
113
+ "updated": {
114
+ "type": "string",
115
+ "format": "date-time"
116
+ }
117
+ },
118
+ "additionalProperties": true,
119
+ "examples": [
120
+ {
121
+ "tags": ["backend", "api"],
122
+ "project": "auth-service"
123
+ }
124
+ ]
125
+ },
126
+ "body": {
127
+ "type": "string",
128
+ "description": "Markdown content (excluding frontmatter YAML)",
129
+ "examples": ["# API Design\n\nThis document describes the API architecture...\n\n## Endpoints\n\nSee [[Endpoints]] for details."]
130
+ }
131
+ },
132
+ "examples": [
133
+ {
134
+ "path": "api/design.md",
135
+ "title": "API Design",
136
+ "metadata": {
137
+ "tags": ["backend", "api"],
138
+ "project": "auth-service"
139
+ },
140
+ "body": "# API Design\n\nThis document describes the API architecture..."
141
+ }
142
+ ]
143
+ }
144
+ },
145
+ {
146
+ "name": "write_note",
147
+ "title": "Write Note",
148
+ "description": "Create a new note or update an existing note. Automatically manages version, created/updated timestamps, and index updates. Uses last-write-wins strategy (no version conflict detection for MCP writes).",
149
+ "inputSchema": {
150
+ "type": "object",
151
+ "required": ["path", "body"],
152
+ "properties": {
153
+ "path": {
154
+ "type": "string",
155
+ "description": "Relative path to vault root (includes .md extension). Must not contain '..', use Unix separators (/), and be relative (no leading /).",
156
+ "minLength": 1,
157
+ "maxLength": 256,
158
+ "pattern": "^[^/].*\\.md$",
159
+ "examples": ["api/design.md", "README.md", "guides/new-feature.md"]
160
+ },
161
+ "title": {
162
+ "type": "string",
163
+ "description": "Optional title override (if omitted, will be extracted from frontmatter or first H1 heading in body)",
164
+ "examples": ["API Design", "New Feature Guide"]
165
+ },
166
+ "metadata": {
167
+ "type": "object",
168
+ "description": "Optional frontmatter metadata (arbitrary key-value pairs). Common fields: tags (array), project (string), etc.",
169
+ "properties": {
170
+ "tags": {
171
+ "type": "array",
172
+ "items": {
173
+ "type": "string"
174
+ },
175
+ "description": "Tag names for categorization"
176
+ },
177
+ "project": {
178
+ "type": "string",
179
+ "description": "Project identifier"
180
+ }
181
+ },
182
+ "additionalProperties": true,
183
+ "examples": [
184
+ {
185
+ "tags": ["backend", "api"],
186
+ "project": "auth-service"
187
+ }
188
+ ]
189
+ },
190
+ "body": {
191
+ "type": "string",
192
+ "description": "Markdown content (max 1 MiB UTF-8). Can contain wikilinks using [[link text]] syntax.",
193
+ "maxLength": 1048576,
194
+ "examples": ["# API Design\n\nThis document describes the API architecture...\n\n## Related\n\nSee [[Endpoints]] for endpoint details."]
195
+ }
196
+ },
197
+ "additionalProperties": false
198
+ },
199
+ "outputSchema": {
200
+ "type": "object",
201
+ "required": ["status", "path"],
202
+ "properties": {
203
+ "status": {
204
+ "type": "string",
205
+ "enum": ["ok"],
206
+ "description": "Operation status"
207
+ },
208
+ "path": {
209
+ "type": "string",
210
+ "description": "Path of the created/updated note",
211
+ "examples": ["api/design.md"]
212
+ }
213
+ },
214
+ "examples": [
215
+ {
216
+ "status": "ok",
217
+ "path": "api/design.md"
218
+ }
219
+ ]
220
+ }
221
+ },
222
+ {
223
+ "name": "delete_note",
224
+ "title": "Delete Note",
225
+ "description": "Permanently delete a note from the vault and remove it from all indices (full-text, tags, links). Any wikilinks referencing this note will become unresolved.",
226
+ "inputSchema": {
227
+ "type": "object",
228
+ "required": ["path"],
229
+ "properties": {
230
+ "path": {
231
+ "type": "string",
232
+ "description": "Relative path to vault root (includes .md extension)",
233
+ "minLength": 1,
234
+ "maxLength": 256,
235
+ "pattern": "^[^/].*\\.md$",
236
+ "examples": ["api/design.md", "obsolete/old-doc.md"]
237
+ }
238
+ },
239
+ "additionalProperties": false
240
+ },
241
+ "outputSchema": {
242
+ "type": "object",
243
+ "required": ["status"],
244
+ "properties": {
245
+ "status": {
246
+ "type": "string",
247
+ "enum": ["ok"],
248
+ "description": "Operation status"
249
+ }
250
+ },
251
+ "examples": [
252
+ {
253
+ "status": "ok"
254
+ }
255
+ ]
256
+ }
257
+ },
258
+ {
259
+ "name": "search_notes",
260
+ "title": "Search Notes",
261
+ "description": "Full-text search across all notes in the user's vault. Results are ranked using: (3 * title_matches) + (1 * body_matches) + recency_bonus. Returns snippets with highlighted matches.",
262
+ "inputSchema": {
263
+ "type": "object",
264
+ "required": ["query"],
265
+ "properties": {
266
+ "query": {
267
+ "type": "string",
268
+ "description": "Search query (tokenized, case-insensitive). Supports simple keyword search with automatic stemming (e.g., 'running' matches 'run').",
269
+ "minLength": 1,
270
+ "maxLength": 256,
271
+ "examples": ["authentication", "API design", "database schema"]
272
+ }
273
+ },
274
+ "additionalProperties": false
275
+ },
276
+ "outputSchema": {
277
+ "type": "array",
278
+ "description": "Search results sorted by relevance score (descending). Limited to top 50 results.",
279
+ "maxItems": 50,
280
+ "items": {
281
+ "type": "object",
282
+ "required": ["path", "title", "snippet"],
283
+ "properties": {
284
+ "path": {
285
+ "type": "string",
286
+ "description": "Note path",
287
+ "examples": ["api/auth.md"]
288
+ },
289
+ "title": {
290
+ "type": "string",
291
+ "description": "Note title",
292
+ "examples": ["Authentication Flow"]
293
+ },
294
+ "snippet": {
295
+ "type": "string",
296
+ "description": "Highlighted excerpt from body (max ~200 chars, with <mark> tags around matches)",
297
+ "examples": ["...describes the <mark>authentication</mark> process using JWT tokens..."]
298
+ }
299
+ }
300
+ },
301
+ "examples": [
302
+ [
303
+ {
304
+ "path": "api/auth.md",
305
+ "title": "Authentication Flow",
306
+ "snippet": "...describes the <mark>authentication</mark> process using JWT tokens..."
307
+ },
308
+ {
309
+ "path": "guides/setup.md",
310
+ "title": "Setup Guide",
311
+ "snippet": "...configure <mark>authentication</mark> settings in the config file..."
312
+ }
313
+ ]
314
+ ]
315
+ }
316
+ },
317
+ {
318
+ "name": "get_backlinks",
319
+ "title": "Get Backlinks",
320
+ "description": "Get all notes that reference the specified note via wikilinks (e.g., [[Note Name]]). Useful for discovering related documentation and navigation.",
321
+ "inputSchema": {
322
+ "type": "object",
323
+ "required": ["path"],
324
+ "properties": {
325
+ "path": {
326
+ "type": "string",
327
+ "description": "Relative path to vault root (includes .md extension)",
328
+ "minLength": 1,
329
+ "maxLength": 256,
330
+ "pattern": "^[^/].*\\.md$",
331
+ "examples": ["api/design.md", "README.md"]
332
+ }
333
+ },
334
+ "additionalProperties": false
335
+ },
336
+ "outputSchema": {
337
+ "type": "array",
338
+ "description": "Array of notes that link to the target note, sorted by most recently updated first",
339
+ "items": {
340
+ "type": "object",
341
+ "required": ["path", "title"],
342
+ "properties": {
343
+ "path": {
344
+ "type": "string",
345
+ "description": "Path of the linking note",
346
+ "examples": ["guides/architecture.md"]
347
+ },
348
+ "title": {
349
+ "type": "string",
350
+ "description": "Title of the linking note",
351
+ "examples": ["Architecture Overview"]
352
+ }
353
+ }
354
+ },
355
+ "examples": [
356
+ [
357
+ {
358
+ "path": "guides/architecture.md",
359
+ "title": "Architecture Overview"
360
+ },
361
+ {
362
+ "path": "api/endpoints.md",
363
+ "title": "API Endpoints"
364
+ }
365
+ ]
366
+ ]
367
+ }
368
+ },
369
+ {
370
+ "name": "get_tags",
371
+ "title": "Get Tags",
372
+ "description": "List all tags used across the user's vault with note counts. Tags are extracted from frontmatter 'tags' field and normalized (lowercase).",
373
+ "inputSchema": {
374
+ "type": "object",
375
+ "properties": {},
376
+ "additionalProperties": false
377
+ },
378
+ "outputSchema": {
379
+ "type": "array",
380
+ "description": "Array of tags sorted by count (descending)",
381
+ "items": {
382
+ "type": "object",
383
+ "required": ["tag", "count"],
384
+ "properties": {
385
+ "tag": {
386
+ "type": "string",
387
+ "description": "Tag name (lowercase, normalized)",
388
+ "examples": ["backend", "api", "frontend"]
389
+ },
390
+ "count": {
391
+ "type": "integer",
392
+ "minimum": 0,
393
+ "description": "Number of notes with this tag",
394
+ "examples": [15, 12, 8]
395
+ }
396
+ }
397
+ },
398
+ "examples": [
399
+ [
400
+ {
401
+ "tag": "backend",
402
+ "count": 15
403
+ },
404
+ {
405
+ "tag": "api",
406
+ "count": 12
407
+ },
408
+ {
409
+ "tag": "frontend",
410
+ "count": 8
411
+ }
412
+ ]
413
+ ]
414
+ }
415
+ }
416
+ ],
417
+ "authentication": {
418
+ "type": "bearer",
419
+ "description": "MCP HTTP transport requires Bearer JWT token via 'auth' parameter. STDIO transport (local mode) does not require authentication.",
420
+ "tokenSource": "POST /api/tokens",
421
+ "tokenExpiration": "90 days"
422
+ },
423
+ "errors": {
424
+ "description": "All MCP tools follow FastMCP error handling conventions. Common error scenarios:",
425
+ "errorCodes": [
426
+ {
427
+ "code": "AUTHENTICATION_REQUIRED",
428
+ "description": "Missing or invalid JWT token (HTTP transport only)",
429
+ "httpStatus": 401
430
+ },
431
+ {
432
+ "code": "PERMISSION_DENIED",
433
+ "description": "Vault note limit exceeded (5,000 notes) or path traversal attempt",
434
+ "httpStatus": 403
435
+ },
436
+ {
437
+ "code": "NOT_FOUND",
438
+ "description": "Note path does not exist in vault",
439
+ "httpStatus": 404
440
+ },
441
+ {
442
+ "code": "VALIDATION_ERROR",
443
+ "description": "Invalid input parameters (e.g., path format, content size)",
444
+ "httpStatus": 400,
445
+ "details": {
446
+ "invalidPath": "Path must end with .md, not contain '..' or '\\', and be relative",
447
+ "payloadTooLarge": "Note body exceeds 1 MiB UTF-8 text limit",
448
+ "emptyQuery": "Search query must be at least 1 character"
449
+ }
450
+ },
451
+ {
452
+ "code": "INTERNAL_ERROR",
453
+ "description": "Unexpected server error (filesystem, database, etc.)",
454
+ "httpStatus": 500
455
+ }
456
+ ]
457
+ },
458
+ "examples": {
459
+ "description": "Common usage patterns for AI agents via MCP",
460
+ "scenarios": [
461
+ {
462
+ "name": "Create documentation structure",
463
+ "description": "AI agent creates initial project documentation with organized folders",
464
+ "steps": [
465
+ {
466
+ "tool": "write_note",
467
+ "input": {
468
+ "path": "README.md",
469
+ "title": "Project Overview",
470
+ "metadata": {
471
+ "tags": ["overview", "documentation"]
472
+ },
473
+ "body": "# Project Overview\n\nWelcome to the project.\n\n## Structure\n\n- [[API Documentation]] - API design and endpoints\n- [[Guides]] - User guides and tutorials"
474
+ }
475
+ },
476
+ {
477
+ "tool": "write_note",
478
+ "input": {
479
+ "path": "api/README.md",
480
+ "title": "API Documentation",
481
+ "metadata": {
482
+ "tags": ["api", "backend"]
483
+ },
484
+ "body": "# API Documentation\n\nSee:\n- [[Authentication]] for auth flows\n- [[Endpoints]] for endpoint specs"
485
+ }
486
+ }
487
+ ]
488
+ },
489
+ {
490
+ "name": "Update documentation with search",
491
+ "description": "AI agent searches for existing docs before updating",
492
+ "steps": [
493
+ {
494
+ "tool": "search_notes",
495
+ "input": {
496
+ "query": "authentication"
497
+ },
498
+ "output": [
499
+ {
500
+ "path": "api/auth.md",
501
+ "title": "Authentication Flow",
502
+ "snippet": "...JWT token-based authentication..."
503
+ }
504
+ ]
505
+ },
506
+ {
507
+ "tool": "read_note",
508
+ "input": {
509
+ "path": "api/auth.md"
510
+ }
511
+ },
512
+ {
513
+ "tool": "write_note",
514
+ "input": {
515
+ "path": "api/auth.md",
516
+ "body": "# Authentication Flow\n\n[Updated content with new OAuth section...]"
517
+ }
518
+ }
519
+ ]
520
+ },
521
+ {
522
+ "name": "Discover related documentation",
523
+ "description": "AI agent uses backlinks to find related docs",
524
+ "steps": [
525
+ {
526
+ "tool": "get_backlinks",
527
+ "input": {
528
+ "path": "api/design.md"
529
+ },
530
+ "output": [
531
+ {
532
+ "path": "guides/architecture.md",
533
+ "title": "Architecture Overview"
534
+ },
535
+ {
536
+ "path": "api/endpoints.md",
537
+ "title": "API Endpoints"
538
+ }
539
+ ]
540
+ },
541
+ {
542
+ "tool": "read_note",
543
+ "input": {
544
+ "path": "guides/architecture.md"
545
+ }
546
+ }
547
+ ]
548
+ }
549
+ ]
550
+ },
551
+ "validation": {
552
+ "description": "Input validation rules enforced by FastMCP/Pydantic",
553
+ "rules": {
554
+ "notePath": {
555
+ "pattern": "^[^/].*\\.md$",
556
+ "minLength": 1,
557
+ "maxLength": 256,
558
+ "mustNotContain": ["..", "\\"],
559
+ "mustEndWith": ".md",
560
+ "invalidChars": ["<", ">", ":", "\"", "|", "?", "*"]
561
+ },
562
+ "noteBody": {
563
+ "maxLength": 1048576,
564
+ "encoding": "UTF-8"
565
+ },
566
+ "searchQuery": {
567
+ "minLength": 1,
568
+ "maxLength": 256
569
+ },
570
+ "folder": {
571
+ "maxLength": 256,
572
+ "optional": true
573
+ },
574
+ "metadata": {
575
+ "type": "object",
576
+ "reservedFields": ["version"],
577
+ "tagsFormat": "array of strings"
578
+ }
579
+ }
580
+ }
581
+ }
specs/001-obsidian-docs-viewer/data-model.md ADDED
@@ -0,0 +1,1483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Model: Multi-Tenant Obsidian-Like Docs Viewer
2
+
3
+ **Feature Branch**: `001-obsidian-docs-viewer`
4
+ **Created**: 2025-11-15
5
+ **Status**: Draft
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Overview](#overview)
10
+ 2. [Entity Relationship Diagram](#entity-relationship-diagram)
11
+ 3. [Core Entities](#core-entities)
12
+ 4. [Index Entities](#index-entities)
13
+ 5. [Authentication Entities](#authentication-entities)
14
+ 6. [SQLite Schema](#sqlite-schema)
15
+ 7. [Pydantic Models](#pydantic-models)
16
+ 8. [TypeScript Type Definitions](#typescript-type-definitions)
17
+ 9. [Validation Rules](#validation-rules)
18
+ 10. [State Transitions](#state-transitions)
19
+ 11. [Relationships and Constraints](#relationships-and-constraints)
20
+
21
+ ---
22
+
23
+ ## Overview
24
+
25
+ This document defines the complete data model for a multi-tenant Obsidian-like documentation viewer. The system stores:
26
+
27
+ - **User accounts** with HF OAuth identity mapping
28
+ - **Vaults** as per-user directory trees containing Markdown notes
29
+ - **Notes** with YAML frontmatter, version tracking, and full-text indexing
30
+ - **Wikilinks** for bidirectional linking between notes
31
+ - **Tags** for categorization and filtering
32
+ - **Index metadata** for search optimization and health monitoring
33
+
34
+ **Design principles**:
35
+ - **Per-user isolation**: All data scoped by `user_id`
36
+ - **Filesystem-backed**: Notes stored as `.md` files, metadata in SQLite
37
+ - **Version-controlled**: Integer version counter for optimistic concurrency
38
+ - **Search-optimized**: SQLite FTS5 for full-text search, separate indexes for tags and links
39
+
40
+ ---
41
+
42
+ ## Entity Relationship Diagram
43
+
44
+ ```mermaid
45
+ erDiagram
46
+ USER ||--o{ VAULT : owns
47
+ VAULT ||--o{ NOTE : contains
48
+ NOTE ||--o{ WIKILINK : has_outgoing
49
+ NOTE ||--o{ WIKILINK : has_incoming
50
+ NOTE ||--o{ TAG : tagged_with
51
+ USER ||--|| INDEX_HEALTH : tracks
52
+ USER ||--o{ TOKEN : issues
53
+
54
+ USER {
55
+ string user_id PK
56
+ string hf_username
57
+ string hf_name
58
+ string hf_avatar_url
59
+ datetime created
60
+ }
61
+
62
+ VAULT {
63
+ string user_id FK
64
+ string root_path
65
+ int note_count
66
+ }
67
+
68
+ NOTE {
69
+ string user_id FK
70
+ string note_path PK
71
+ int version
72
+ string title
73
+ json metadata
74
+ string body
75
+ datetime created
76
+ datetime updated
77
+ int size_bytes
78
+ }
79
+
80
+ WIKILINK {
81
+ string user_id FK
82
+ string source_path FK
83
+ string target_path FK
84
+ string link_text
85
+ bool is_resolved
86
+ }
87
+
88
+ TAG {
89
+ string user_id FK
90
+ string tag_name
91
+ string note_path FK
92
+ }
93
+
94
+ INDEX_HEALTH {
95
+ string user_id PK
96
+ int note_count
97
+ datetime last_full_rebuild
98
+ datetime last_incremental_update
99
+ }
100
+
101
+ TOKEN {
102
+ string token_id PK
103
+ string user_id FK
104
+ datetime issued_at
105
+ datetime expires_at
106
+ string token_type
107
+ }
108
+ ```
109
+
110
+ **Key relationships**:
111
+ - One user owns one vault (1:1)
112
+ - One vault contains many notes (1:N)
113
+ - One note has many outgoing wikilinks (1:N)
114
+ - One note may be referenced by many backlinks (1:N)
115
+ - One note can have many tags (N:M via junction table)
116
+ - One user has one index health record (1:1)
117
+ - One user can issue many tokens (1:N)
118
+
119
+ ---
120
+
121
+ ## Core Entities
122
+
123
+ ### User
124
+
125
+ Represents an authenticated user with HF OAuth identity.
126
+
127
+ **Attributes**:
128
+ - `user_id` (string, PK): Internal unique identifier, derived from HF username or UUID
129
+ - `hf_username` (string, nullable): HuggingFace username (e.g., "alice")
130
+ - `hf_name` (string, nullable): Display name from HF profile
131
+ - `hf_avatar_url` (string, nullable): Profile picture URL
132
+ - `created` (datetime): Account creation timestamp (ISO 8601)
133
+
134
+ **Notes**:
135
+ - In local mode, `user_id = "local-dev"` with null HF fields
136
+ - In HF Space mode, `user_id = hf_username` (normalized to lowercase)
137
+ - `created` timestamp set on first OAuth login (vault initialization)
138
+
139
+ **Lifecycle**:
140
+ 1. User authenticates via HF OAuth
141
+ 2. Backend maps HF identity to `user_id`
142
+ 3. If new user, create vault directory and initialize index
143
+ 4. Return user info to frontend
144
+
145
+ ---
146
+
147
+ ### Vault
148
+
149
+ A per-user directory tree containing Markdown notes.
150
+
151
+ **Attributes**:
152
+ - `user_id` (string, FK): Owner of the vault
153
+ - `root_path` (string): Absolute filesystem path to vault root (e.g., `/data/vaults/alice/`)
154
+ - `note_count` (int): Cached count of notes in vault (denormalized from index)
155
+
156
+ **Constraints**:
157
+ - Max 5,000 notes per vault (enforced by FR-008)
158
+ - Root path must exist and be writable
159
+ - Directory structure is arbitrary (user-defined nested folders)
160
+
161
+ **Filesystem layout example**:
162
+ ```
163
+ /data/vaults/alice/
164
+ β”œβ”€β”€ README.md
165
+ β”œβ”€β”€ api/
166
+ β”‚ β”œβ”€β”€ design.md
167
+ β”‚ └── endpoints.md
168
+ β”œβ”€β”€ guides/
169
+ β”‚ β”œβ”€β”€ setup.md
170
+ β”‚ └── deployment.md
171
+ └── notes/
172
+ └── meeting-2025-01-15.md
173
+ ```
174
+
175
+ ---
176
+
177
+ ### Note
178
+
179
+ A Markdown file with optional YAML frontmatter and body content.
180
+
181
+ **Attributes**:
182
+ - `user_id` (string, FK): Owner of the note
183
+ - `note_path` (string, PK): Relative path to vault root, includes `.md` (e.g., `api/design.md`)
184
+ - `version` (int): Optimistic concurrency version counter (starts at 1, increments on write)
185
+ - `title` (string): Display title (from frontmatter, first H1, or filename stem)
186
+ - `metadata` (JSON): Frontmatter key-value pairs (excludes auto-managed fields)
187
+ - `body` (string): Markdown content (excluding frontmatter)
188
+ - `created` (datetime, ISO 8601): Creation timestamp (auto-set if not in frontmatter)
189
+ - `updated` (datetime, ISO 8601): Last modification timestamp (auto-set on every write)
190
+ - `size_bytes` (int): UTF-8 byte size of full file content (frontmatter + body)
191
+
192
+ **Constraints**:
193
+ - `note_path` max 256 characters, Unix-style separators (`/`), no `..` allowed
194
+ - `size_bytes` max 1 MiB (1,048,576 bytes) per FR-007
195
+ - `version` stored in index, NOT in frontmatter
196
+ - `created` and `updated` stored in index, MAY appear in frontmatter (frontmatter is source of truth on read)
197
+
198
+ **Title resolution priority** (FR-006):
199
+ 1. `metadata.get('title')` from frontmatter
200
+ 2. First `# Heading` in body (H1 only)
201
+ 3. Filename stem (e.g., `design.md` β†’ "design")
202
+
203
+ **Metadata fields** (common, but arbitrary):
204
+ - `tags` (array of strings): Tag names for categorization
205
+ - `project` (string): Project identifier
206
+ - `created` (datetime): User-provided creation timestamp
207
+ - `updated` (datetime): User-provided update timestamp
208
+ - Custom fields allowed (JSON object)
209
+
210
+ ---
211
+
212
+ ## Index Entities
213
+
214
+ ### Wikilink
215
+
216
+ Represents a bidirectional link between two notes.
217
+
218
+ **Attributes**:
219
+ - `user_id` (string, FK): Owner of the notes
220
+ - `source_path` (string, FK): Path of note containing the wikilink
221
+ - `target_path` (string, nullable, FK): Resolved path of linked note (null if unresolved)
222
+ - `link_text` (string): Original text from `[[link text]]`
223
+ - `is_resolved` (bool): True if `target_path` is non-null, false if broken link
224
+
225
+ **Extraction**:
226
+ - Regex pattern: `\[\[([^\]]+)\]\]`
227
+ - Extract all matches from note body on every write
228
+
229
+ **Resolution algorithm** (FR-015, FR-016):
230
+ 1. Normalize `link_text` to slug: lowercase, replace spaces/underscores with dash, strip non-alphanumeric
231
+ 2. Match normalized slug against:
232
+ - Normalized filename stems (e.g., `api-design.md` β†’ "api-design")
233
+ - Normalized frontmatter titles (e.g., `title: "API Design"` β†’ "api-design")
234
+ 3. If multiple matches:
235
+ - Prefer same-folder match (e.g., `api/[[design]]` β†’ `api/design.md` over `guides/design.md`)
236
+ - Tiebreaker: lexicographically smallest path
237
+ 4. If no match: `target_path = null`, `is_resolved = false`
238
+
239
+ **Slug normalization function**:
240
+ ```python
241
+ import re
242
+
243
+ def normalize_slug(text: str) -> str:
244
+ text = text.lower()
245
+ text = re.sub(r'[\s_]+', '-', text) # Spaces/underscores β†’ dash
246
+ text = re.sub(r'[^a-z0-9-]', '', text) # Keep alphanumeric + dash
247
+ text = re.sub(r'-+', '-', text) # Collapse dashes
248
+ return text.strip('-')
249
+ ```
250
+
251
+ **Backlinks**:
252
+ - To get backlinks for `note_path`, query: `WHERE target_path = note_path`
253
+ - Backlinks are automatically updated when any note's wikilinks change
254
+
255
+ ---
256
+
257
+ ### Tag
258
+
259
+ A metadata label applied to notes for categorization.
260
+
261
+ **Attributes**:
262
+ - `user_id` (string, FK): Owner of the notes
263
+ - `note_path` (string, FK): Path of tagged note
264
+ - `tag_name` (string): Tag identifier (lowercase, alphanumeric + hyphens)
265
+
266
+ **Constraints**:
267
+ - Many-to-many relationship: one note can have multiple tags, one tag can apply to multiple notes
268
+ - Tag names normalized: lowercase, strip whitespace
269
+ - Extracted from frontmatter `tags: [tag1, tag2]` array
270
+
271
+ **Tag count**:
272
+ - Computed via `COUNT(DISTINCT note_path) GROUP BY tag_name`
273
+ - Used for tag cloud and filtering UI
274
+
275
+ ---
276
+
277
+ ### Index Health
278
+
279
+ Tracks the state and freshness of per-user indices.
280
+
281
+ **Attributes**:
282
+ - `user_id` (string, PK): Owner of the index
283
+ - `note_count` (int): Total number of notes indexed
284
+ - `last_full_rebuild` (datetime, nullable, ISO 8601): Timestamp of last full index rebuild
285
+ - `last_incremental_update` (datetime, nullable, ISO 8601): Timestamp of last incremental update (write/delete)
286
+
287
+ **Usage**:
288
+ - Displayed in UI as index health indicator
289
+ - Used to detect stale indices (e.g., `note_count` mismatch with actual file count)
290
+ - Manual rebuild sets `last_full_rebuild = now()` (FR-019)
291
+ - Every write/delete sets `last_incremental_update = now()` (FR-018)
292
+
293
+ ---
294
+
295
+ ## Authentication Entities
296
+
297
+ ### Token (JWT)
298
+
299
+ A signed JSON Web Token used for API and MCP authentication.
300
+
301
+ **JWT Claims** (payload):
302
+ - `sub` (string): Subject (user_id)
303
+ - `iat` (int): Issued at timestamp (Unix epoch)
304
+ - `exp` (int): Expiration timestamp (Unix epoch, iat + 90 days)
305
+
306
+ **Header**:
307
+ - `alg: "HS256"`: HMAC SHA-256 signature algorithm
308
+ - `typ: "JWT"`: Token type
309
+
310
+ **Signature**:
311
+ - Signed with server secret (env var `JWT_SECRET_KEY`)
312
+ - Validated on every API/MCP request via `Authorization: Bearer <token>` header
313
+
314
+ **Token lifecycle**:
315
+ 1. User authenticates via HF OAuth
316
+ 2. User calls `POST /api/tokens` to issue JWT
317
+ 3. Frontend stores token in memory (React context)
318
+ 4. MCP clients pass token to `auth` parameter (FastMCP HTTP transport)
319
+ 5. Server validates token on every request, extracts `user_id` from `sub` claim
320
+ 6. Token expires after 90 days, user must re-authenticate
321
+
322
+ **Example token**:
323
+ ```json
324
+ {
325
+ "header": {
326
+ "alg": "HS256",
327
+ "typ": "JWT"
328
+ },
329
+ "payload": {
330
+ "sub": "alice",
331
+ "iat": 1736956800,
332
+ "exp": 1744732800
333
+ },
334
+ "signature": "<HMAC-SHA256-signature>"
335
+ }
336
+ ```
337
+
338
+ Encoded: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTczNjk1NjgwMCwiZXhwIjoxNzQ0NzMyODAwfQ.<signature>`
339
+
340
+ ---
341
+
342
+ ## SQLite Schema
343
+
344
+ Complete DDL for multi-index storage with per-user isolation.
345
+
346
+ ### Core Tables
347
+
348
+ #### note_metadata
349
+
350
+ Stores note metadata for fast lookups and version tracking.
351
+
352
+ ```sql
353
+ CREATE TABLE IF NOT EXISTS note_metadata (
354
+ user_id TEXT NOT NULL,
355
+ note_path TEXT NOT NULL,
356
+ version INTEGER NOT NULL DEFAULT 1,
357
+ title TEXT NOT NULL,
358
+ created TEXT NOT NULL, -- ISO 8601 timestamp
359
+ updated TEXT NOT NULL, -- ISO 8601 timestamp
360
+ size_bytes INTEGER NOT NULL DEFAULT 0,
361
+ normalized_title_slug TEXT, -- Pre-computed for wikilink resolution
362
+ normalized_path_slug TEXT, -- Pre-computed for wikilink resolution
363
+ PRIMARY KEY (user_id, note_path)
364
+ );
365
+
366
+ CREATE INDEX idx_metadata_user ON note_metadata(user_id);
367
+ CREATE INDEX idx_metadata_updated ON note_metadata(user_id, updated DESC);
368
+ CREATE INDEX idx_metadata_title_slug ON note_metadata(user_id, normalized_title_slug);
369
+ CREATE INDEX idx_metadata_path_slug ON note_metadata(user_id, normalized_path_slug);
370
+ ```
371
+
372
+ **Notes**:
373
+ - Composite primary key: `(user_id, note_path)`
374
+ - `version` starts at 1, increments on every write
375
+ - `normalized_*_slug` columns enable O(1) wikilink resolution
376
+ - Index on `updated DESC` for recency-based sorting
377
+
378
+ ---
379
+
380
+ #### note_fts
381
+
382
+ Full-text search index using SQLite FTS5.
383
+
384
+ ```sql
385
+ CREATE VIRTUAL TABLE IF NOT EXISTS note_fts USING fts5(
386
+ user_id UNINDEXED,
387
+ note_path UNINDEXED,
388
+ title,
389
+ body,
390
+ content='', -- Contentless (external content pattern)
391
+ tokenize='porter unicode61', -- Stemming + Unicode support
392
+ prefix='2 3' -- Prefix indexes for autocomplete
393
+ );
394
+ ```
395
+
396
+ **Notes**:
397
+ - `content=''` (contentless): We manually INSERT/DELETE rows, no automatic sync
398
+ - `UNINDEXED` columns are retrievable but not searchable (used for IDs)
399
+ - `porter` tokenizer: English stemming (e.g., "running" matches "run")
400
+ - `prefix='2 3'`: Enables fast `MATCH 'prefix*'` queries (2-char and 3-char prefixes)
401
+ - Manual row management: On write, `DELETE` old row + `INSERT` new row
402
+ - Ranking: Use `bm25(note_fts, 3.0, 1.0)` for title weight=3x, body weight=1x
403
+
404
+ **Query example**:
405
+ ```sql
406
+ SELECT
407
+ note_path,
408
+ title,
409
+ bm25(note_fts, 3.0, 1.0) AS rank
410
+ FROM note_fts
411
+ WHERE user_id = ? AND note_fts MATCH ?
412
+ ORDER BY rank DESC
413
+ LIMIT 50;
414
+ ```
415
+
416
+ ---
417
+
418
+ #### note_tags
419
+
420
+ Many-to-many junction table for note-tag relationships.
421
+
422
+ ```sql
423
+ CREATE TABLE IF NOT EXISTS note_tags (
424
+ user_id TEXT NOT NULL,
425
+ note_path TEXT NOT NULL,
426
+ tag TEXT NOT NULL,
427
+ PRIMARY KEY (user_id, note_path, tag)
428
+ );
429
+
430
+ CREATE INDEX idx_tags_user_tag ON note_tags(user_id, tag);
431
+ CREATE INDEX idx_tags_user_path ON note_tags(user_id, note_path);
432
+ ```
433
+
434
+ **Notes**:
435
+ - Composite primary key prevents duplicate tag assignments
436
+ - Index on `(user_id, tag)` for "all notes with tag X" queries
437
+ - Index on `(user_id, note_path)` for "all tags for note Y" queries
438
+
439
+ **Query examples**:
440
+ ```sql
441
+ -- Get all notes with tag "backend"
442
+ SELECT DISTINCT note_path, title
443
+ FROM note_tags t
444
+ JOIN note_metadata m USING (user_id, note_path)
445
+ WHERE t.user_id = ? AND t.tag = ?
446
+ ORDER BY m.updated DESC;
447
+
448
+ -- Get tag counts for user
449
+ SELECT tag, COUNT(DISTINCT note_path) as count
450
+ FROM note_tags
451
+ WHERE user_id = ?
452
+ GROUP BY tag
453
+ ORDER BY count DESC;
454
+ ```
455
+
456
+ ---
457
+
458
+ #### note_links
459
+
460
+ Stores wikilink graph for backlink navigation.
461
+
462
+ ```sql
463
+ CREATE TABLE IF NOT EXISTS note_links (
464
+ user_id TEXT NOT NULL,
465
+ source_path TEXT NOT NULL,
466
+ target_path TEXT, -- NULL if unresolved
467
+ link_text TEXT NOT NULL,
468
+ is_resolved INTEGER NOT NULL DEFAULT 0, -- Boolean: 0=broken, 1=resolved
469
+ PRIMARY KEY (user_id, source_path, link_text)
470
+ );
471
+
472
+ CREATE INDEX idx_links_user_source ON note_links(user_id, source_path);
473
+ CREATE INDEX idx_links_user_target ON note_links(user_id, target_path);
474
+ CREATE INDEX idx_links_unresolved ON note_links(user_id, is_resolved);
475
+ ```
476
+
477
+ **Notes**:
478
+ - `target_path` is nullable (null = broken link)
479
+ - `is_resolved` is integer (0 or 1) for SQLite boolean representation
480
+ - Composite primary key prevents duplicate links from same source with same text
481
+ - Index on `target_path` enables fast backlink queries
482
+
483
+ **Query examples**:
484
+ ```sql
485
+ -- Get backlinks for a note
486
+ SELECT DISTINCT l.source_path, m.title
487
+ FROM note_links l
488
+ JOIN note_metadata m ON l.user_id = m.user_id AND l.source_path = m.note_path
489
+ WHERE l.user_id = ? AND l.target_path = ?
490
+ ORDER BY m.updated DESC;
491
+
492
+ -- Get all unresolved links for user
493
+ SELECT source_path, link_text
494
+ FROM note_links
495
+ WHERE user_id = ? AND is_resolved = 0;
496
+ ```
497
+
498
+ ---
499
+
500
+ #### index_health
501
+
502
+ Tracks index state and freshness per user.
503
+
504
+ ```sql
505
+ CREATE TABLE IF NOT EXISTS index_health (
506
+ user_id TEXT PRIMARY KEY,
507
+ note_count INTEGER NOT NULL DEFAULT 0,
508
+ last_full_rebuild TEXT, -- ISO 8601 timestamp
509
+ last_incremental_update TEXT -- ISO 8601 timestamp
510
+ );
511
+ ```
512
+
513
+ **Notes**:
514
+ - One row per user
515
+ - `last_full_rebuild` set on manual rebuild (FR-042)
516
+ - `last_incremental_update` set on every write/delete (FR-018)
517
+ - `note_count` is denormalized cache for quick health checks
518
+
519
+ ---
520
+
521
+ ### Initialization Script
522
+
523
+ Complete schema initialization:
524
+
525
+ ```sql
526
+ -- Enable FTS5 extension (usually built-in)
527
+ -- PRAGMA compile_options; -- Check if FTS5 is available
528
+
529
+ BEGIN TRANSACTION;
530
+
531
+ -- Core metadata table
532
+ CREATE TABLE IF NOT EXISTS note_metadata (
533
+ user_id TEXT NOT NULL,
534
+ note_path TEXT NOT NULL,
535
+ version INTEGER NOT NULL DEFAULT 1,
536
+ title TEXT NOT NULL,
537
+ created TEXT NOT NULL,
538
+ updated TEXT NOT NULL,
539
+ size_bytes INTEGER NOT NULL DEFAULT 0,
540
+ normalized_title_slug TEXT,
541
+ normalized_path_slug TEXT,
542
+ PRIMARY KEY (user_id, note_path)
543
+ );
544
+
545
+ CREATE INDEX IF NOT EXISTS idx_metadata_user ON note_metadata(user_id);
546
+ CREATE INDEX IF NOT EXISTS idx_metadata_updated ON note_metadata(user_id, updated DESC);
547
+ CREATE INDEX IF NOT EXISTS idx_metadata_title_slug ON note_metadata(user_id, normalized_title_slug);
548
+ CREATE INDEX IF NOT EXISTS idx_metadata_path_slug ON note_metadata(user_id, normalized_path_slug);
549
+
550
+ -- Full-text search index
551
+ CREATE VIRTUAL TABLE IF NOT EXISTS note_fts USING fts5(
552
+ user_id UNINDEXED,
553
+ note_path UNINDEXED,
554
+ title,
555
+ body,
556
+ content='',
557
+ tokenize='porter unicode61',
558
+ prefix='2 3'
559
+ );
560
+
561
+ -- Tag index
562
+ CREATE TABLE IF NOT EXISTS note_tags (
563
+ user_id TEXT NOT NULL,
564
+ note_path TEXT NOT NULL,
565
+ tag TEXT NOT NULL,
566
+ PRIMARY KEY (user_id, note_path, tag)
567
+ );
568
+
569
+ CREATE INDEX IF NOT EXISTS idx_tags_user_tag ON note_tags(user_id, tag);
570
+ CREATE INDEX IF NOT EXISTS idx_tags_user_path ON note_tags(user_id, note_path);
571
+
572
+ -- Link graph
573
+ CREATE TABLE IF NOT EXISTS note_links (
574
+ user_id TEXT NOT NULL,
575
+ source_path TEXT NOT NULL,
576
+ target_path TEXT,
577
+ link_text TEXT NOT NULL,
578
+ is_resolved INTEGER NOT NULL DEFAULT 0,
579
+ PRIMARY KEY (user_id, source_path, link_text)
580
+ );
581
+
582
+ CREATE INDEX IF NOT EXISTS idx_links_user_source ON note_links(user_id, source_path);
583
+ CREATE INDEX IF NOT EXISTS idx_links_user_target ON note_links(user_id, target_path);
584
+ CREATE INDEX IF NOT EXISTS idx_links_unresolved ON note_links(user_id, is_resolved);
585
+
586
+ -- Index health tracking
587
+ CREATE TABLE IF NOT EXISTS index_health (
588
+ user_id TEXT PRIMARY KEY,
589
+ note_count INTEGER NOT NULL DEFAULT 0,
590
+ last_full_rebuild TEXT,
591
+ last_incremental_update TEXT
592
+ );
593
+
594
+ COMMIT;
595
+ ```
596
+
597
+ ---
598
+
599
+ ## Pydantic Models
600
+
601
+ Python data models using Pydantic for validation and serialization.
602
+
603
+ ### User Models
604
+
605
+ ```python
606
+ from pydantic import BaseModel, Field
607
+ from datetime import datetime
608
+ from typing import Optional
609
+
610
+
611
+ class HFProfile(BaseModel):
612
+ """HuggingFace OAuth profile information."""
613
+ username: str = Field(..., description="HF username")
614
+ name: Optional[str] = Field(None, description="Display name")
615
+ avatar_url: Optional[str] = Field(None, description="Profile picture URL")
616
+
617
+
618
+ class User(BaseModel):
619
+ """User account with authentication info."""
620
+ user_id: str = Field(..., min_length=1, max_length=64, description="Internal user ID")
621
+ hf_profile: Optional[HFProfile] = Field(None, description="HF OAuth profile")
622
+ vault_path: str = Field(..., description="Absolute path to user's vault")
623
+ created: datetime = Field(..., description="Account creation timestamp")
624
+
625
+ class Config:
626
+ json_schema_extra = {
627
+ "example": {
628
+ "user_id": "alice",
629
+ "hf_profile": {
630
+ "username": "alice",
631
+ "name": "Alice Smith",
632
+ "avatar_url": "https://cdn-avatars.huggingface.co/v1/alice"
633
+ },
634
+ "vault_path": "/data/vaults/alice",
635
+ "created": "2025-01-15T10:30:00Z"
636
+ }
637
+ }
638
+ ```
639
+
640
+ ---
641
+
642
+ ### Note Models
643
+
644
+ ```python
645
+ from pathlib import Path
646
+ import re
647
+
648
+
649
+ class NoteMetadata(BaseModel):
650
+ """Frontmatter metadata (arbitrary key-value pairs)."""
651
+ title: Optional[str] = None
652
+ tags: Optional[list[str]] = None
653
+ project: Optional[str] = None
654
+ created: Optional[datetime] = None
655
+ updated: Optional[datetime] = None
656
+
657
+ class Config:
658
+ extra = "allow" # Allow arbitrary fields
659
+
660
+
661
+ class Note(BaseModel):
662
+ """Complete note with content and metadata."""
663
+ user_id: str = Field(..., description="Owner user ID")
664
+ note_path: str = Field(
665
+ ...,
666
+ min_length=1,
667
+ max_length=256,
668
+ description="Relative path to vault root (includes .md)"
669
+ )
670
+ version: int = Field(..., ge=1, description="Optimistic concurrency version")
671
+ title: str = Field(..., min_length=1, description="Display title")
672
+ metadata: NoteMetadata = Field(default_factory=NoteMetadata, description="Frontmatter")
673
+ body: str = Field(..., description="Markdown content")
674
+ created: datetime = Field(..., description="Creation timestamp")
675
+ updated: datetime = Field(..., description="Last update timestamp")
676
+ size_bytes: int = Field(..., ge=0, le=1_048_576, description="File size in bytes")
677
+
678
+ @validator("note_path")
679
+ def validate_path(cls, v):
680
+ """Validate note path format."""
681
+ # Must end with .md
682
+ if not v.endswith('.md'):
683
+ raise ValueError("Note path must end with .md")
684
+
685
+ # Must not contain ..
686
+ if '..' in v:
687
+ raise ValueError("Note path must not contain '..'")
688
+
689
+ # Must use Unix-style separators
690
+ if '\\' in v:
691
+ raise ValueError("Note path must use Unix-style separators (/)")
692
+
693
+ # Must not start with /
694
+ if v.startswith('/'):
695
+ raise ValueError("Note path must be relative (no leading /)")
696
+
697
+ return v
698
+
699
+ class Config:
700
+ json_schema_extra = {
701
+ "example": {
702
+ "user_id": "alice",
703
+ "note_path": "api/design.md",
704
+ "version": 5,
705
+ "title": "API Design",
706
+ "metadata": {
707
+ "tags": ["backend", "api"],
708
+ "project": "auth-service"
709
+ },
710
+ "body": "# API Design\n\nThis document describes...",
711
+ "created": "2025-01-10T09:00:00Z",
712
+ "updated": "2025-01-15T14:30:00Z",
713
+ "size_bytes": 4096
714
+ }
715
+ }
716
+
717
+
718
+ class NoteCreate(BaseModel):
719
+ """Request to create a new note."""
720
+ note_path: str = Field(..., min_length=1, max_length=256)
721
+ title: Optional[str] = None
722
+ metadata: Optional[NoteMetadata] = None
723
+ body: str = Field(..., max_length=1_048_576)
724
+
725
+
726
+ class NoteUpdate(BaseModel):
727
+ """Request to update an existing note."""
728
+ title: Optional[str] = None
729
+ metadata: Optional[NoteMetadata] = None
730
+ body: str = Field(..., max_length=1_048_576)
731
+ if_version: Optional[int] = Field(None, ge=1, description="Expected version for concurrency check")
732
+
733
+
734
+ class NoteSummary(BaseModel):
735
+ """Lightweight note summary for listings."""
736
+ note_path: str
737
+ title: str
738
+ updated: datetime
739
+ ```
740
+
741
+ ---
742
+
743
+ ### Index Models
744
+
745
+ ```python
746
+ class Wikilink(BaseModel):
747
+ """Bidirectional link between notes."""
748
+ user_id: str
749
+ source_path: str
750
+ target_path: Optional[str] = Field(None, description="Null if unresolved")
751
+ link_text: str
752
+ is_resolved: bool
753
+
754
+ class Config:
755
+ json_schema_extra = {
756
+ "example": {
757
+ "user_id": "alice",
758
+ "source_path": "api/design.md",
759
+ "target_path": "api/endpoints.md",
760
+ "link_text": "Endpoints",
761
+ "is_resolved": True
762
+ }
763
+ }
764
+
765
+
766
+ class Tag(BaseModel):
767
+ """Tag with note count."""
768
+ tag_name: str
769
+ count: int = Field(..., ge=0)
770
+
771
+
772
+ class IndexHealth(BaseModel):
773
+ """Index state and freshness metrics."""
774
+ user_id: str
775
+ note_count: int = Field(..., ge=0)
776
+ last_full_rebuild: Optional[datetime] = None
777
+ last_incremental_update: Optional[datetime] = None
778
+
779
+ class Config:
780
+ json_schema_extra = {
781
+ "example": {
782
+ "user_id": "alice",
783
+ "note_count": 142,
784
+ "last_full_rebuild": "2025-01-01T00:00:00Z",
785
+ "last_incremental_update": "2025-01-15T14:30:00Z"
786
+ }
787
+ }
788
+ ```
789
+
790
+ ---
791
+
792
+ ### Search Models
793
+
794
+ ```python
795
+ class SearchResult(BaseModel):
796
+ """Full-text search result with snippet."""
797
+ note_path: str
798
+ title: str
799
+ snippet: str = Field(..., description="Highlighted excerpt from body")
800
+ score: float = Field(..., description="Relevance score (title 3x, body 1x, recency bonus)")
801
+ updated: datetime
802
+
803
+
804
+ class SearchRequest(BaseModel):
805
+ """Full-text search query."""
806
+ query: str = Field(..., min_length=1, max_length=256)
807
+ limit: int = Field(50, ge=1, le=100)
808
+ ```
809
+
810
+ ---
811
+
812
+ ### Authentication Models
813
+
814
+ ```python
815
+ class TokenResponse(BaseModel):
816
+ """JWT token issuance response."""
817
+ token: str = Field(..., description="JWT access token")
818
+ token_type: str = Field("bearer", description="Token type")
819
+ expires_at: datetime = Field(..., description="Expiration timestamp")
820
+
821
+
822
+ class JWTPayload(BaseModel):
823
+ """JWT claims payload."""
824
+ sub: str = Field(..., description="Subject (user_id)")
825
+ iat: int = Field(..., description="Issued at (Unix timestamp)")
826
+ exp: int = Field(..., description="Expiration (Unix timestamp)")
827
+ ```
828
+
829
+ ---
830
+
831
+ ## TypeScript Type Definitions
832
+
833
+ Frontend type definitions for API contracts.
834
+
835
+ ### Core Types
836
+
837
+ ```typescript
838
+ /**
839
+ * User account with HF profile
840
+ */
841
+ export interface User {
842
+ user_id: string;
843
+ hf_profile?: {
844
+ username: string;
845
+ name?: string;
846
+ avatar_url?: string;
847
+ };
848
+ vault_path: string;
849
+ created: string; // ISO 8601
850
+ }
851
+
852
+ /**
853
+ * Note metadata (frontmatter)
854
+ */
855
+ export interface NoteMetadata {
856
+ title?: string;
857
+ tags?: string[];
858
+ project?: string;
859
+ created?: string; // ISO 8601
860
+ updated?: string; // ISO 8601
861
+ [key: string]: unknown; // Arbitrary fields
862
+ }
863
+
864
+ /**
865
+ * Complete note with content
866
+ */
867
+ export interface Note {
868
+ user_id: string;
869
+ note_path: string;
870
+ version: number;
871
+ title: string;
872
+ metadata: NoteMetadata;
873
+ body: string;
874
+ created: string; // ISO 8601
875
+ updated: string; // ISO 8601
876
+ size_bytes: number;
877
+ }
878
+
879
+ /**
880
+ * Lightweight note summary for listings
881
+ */
882
+ export interface NoteSummary {
883
+ note_path: string;
884
+ title: string;
885
+ updated: string; // ISO 8601
886
+ }
887
+
888
+ /**
889
+ * Request to create a note
890
+ */
891
+ export interface NoteCreateRequest {
892
+ note_path: string;
893
+ title?: string;
894
+ metadata?: NoteMetadata;
895
+ body: string;
896
+ }
897
+
898
+ /**
899
+ * Request to update a note
900
+ */
901
+ export interface NoteUpdateRequest {
902
+ title?: string;
903
+ metadata?: NoteMetadata;
904
+ body: string;
905
+ if_version?: number; // Optimistic concurrency
906
+ }
907
+
908
+ /**
909
+ * Wikilink with resolution status
910
+ */
911
+ export interface Wikilink {
912
+ user_id: string;
913
+ source_path: string;
914
+ target_path: string | null; // Null if unresolved
915
+ link_text: string;
916
+ is_resolved: boolean;
917
+ }
918
+
919
+ /**
920
+ * Tag with note count
921
+ */
922
+ export interface Tag {
923
+ tag_name: string;
924
+ count: number;
925
+ }
926
+
927
+ /**
928
+ * Index health metrics
929
+ */
930
+ export interface IndexHealth {
931
+ user_id: string;
932
+ note_count: number;
933
+ last_full_rebuild: string | null; // ISO 8601
934
+ last_incremental_update: string | null; // ISO 8601
935
+ }
936
+
937
+ /**
938
+ * Search result with snippet
939
+ */
940
+ export interface SearchResult {
941
+ note_path: string;
942
+ title: string;
943
+ snippet: string;
944
+ score: number;
945
+ updated: string; // ISO 8601
946
+ }
947
+
948
+ /**
949
+ * JWT token response
950
+ */
951
+ export interface TokenResponse {
952
+ token: string;
953
+ token_type: "bearer";
954
+ expires_at: string; // ISO 8601
955
+ }
956
+
957
+ /**
958
+ * API error response
959
+ */
960
+ export interface APIError {
961
+ error: string;
962
+ message: string;
963
+ detail?: Record<string, unknown>;
964
+ }
965
+ ```
966
+
967
+ ---
968
+
969
+ ### Validation Helpers
970
+
971
+ ```typescript
972
+ /**
973
+ * Validate note path format
974
+ */
975
+ export function isValidNotePath(path: string): boolean {
976
+ return (
977
+ path.length > 0 &&
978
+ path.length <= 256 &&
979
+ path.endsWith('.md') &&
980
+ !path.includes('..') &&
981
+ !path.includes('\\') &&
982
+ !path.startsWith('/')
983
+ );
984
+ }
985
+
986
+ /**
987
+ * Normalize tag name (lowercase, trim)
988
+ */
989
+ export function normalizeTag(tag: string): string {
990
+ return tag.toLowerCase().trim();
991
+ }
992
+
993
+ /**
994
+ * Normalize slug for wikilink resolution
995
+ */
996
+ export function normalizeSlug(text: string): string {
997
+ return text
998
+ .toLowerCase()
999
+ .replace(/[\s_]+/g, '-') // Spaces/underscores β†’ dash
1000
+ .replace(/[^a-z0-9-]/g, '') // Keep alphanumeric + dash
1001
+ .replace(/-+/g, '-') // Collapse dashes
1002
+ .replace(/^-+|-+$/g, ''); // Trim dashes
1003
+ }
1004
+
1005
+ /**
1006
+ * Extract wikilinks from markdown body
1007
+ */
1008
+ export function extractWikilinks(markdown: string): string[] {
1009
+ const pattern = /\[\[([^\]]+)\]\]/g;
1010
+ const matches: string[] = [];
1011
+ let match;
1012
+
1013
+ while ((match = pattern.exec(markdown)) !== null) {
1014
+ matches.push(match[1]);
1015
+ }
1016
+
1017
+ return matches;
1018
+ }
1019
+ ```
1020
+
1021
+ ---
1022
+
1023
+ ## Validation Rules
1024
+
1025
+ Comprehensive validation constraints for all entities.
1026
+
1027
+ ### Note Path Validation
1028
+
1029
+ ```python
1030
+ import re
1031
+ from pathlib import Path
1032
+
1033
+ def validate_note_path(path: str) -> tuple[bool, str]:
1034
+ """
1035
+ Validate note path format.
1036
+
1037
+ Returns (is_valid, error_message).
1038
+ """
1039
+ # Length check
1040
+ if not path or len(path) > 256:
1041
+ return False, "Path must be 1-256 characters"
1042
+
1043
+ # Must end with .md
1044
+ if not path.endswith('.md'):
1045
+ return False, "Path must end with .md"
1046
+
1047
+ # Must not contain ..
1048
+ if '..' in path:
1049
+ return False, "Path must not contain '..'"
1050
+
1051
+ # Must use Unix-style separators
1052
+ if '\\' in path:
1053
+ return False, "Path must use Unix separators (/)"
1054
+
1055
+ # Must be relative
1056
+ if path.startswith('/'):
1057
+ return False, "Path must be relative (no leading /)"
1058
+
1059
+ # Must not have invalid characters
1060
+ invalid_chars = ['<', '>', ':', '"', '|', '?', '*']
1061
+ if any(c in path for c in invalid_chars):
1062
+ return False, f"Path contains invalid characters: {invalid_chars}"
1063
+
1064
+ return True, ""
1065
+
1066
+
1067
+ def sanitize_path(user_id: str, vault_root: str, note_path: str) -> Path:
1068
+ """
1069
+ Sanitize and resolve note path within vault.
1070
+
1071
+ Raises ValueError if path escapes vault root.
1072
+ """
1073
+ vault = Path(vault_root) / user_id
1074
+ full_path = (vault / note_path).resolve()
1075
+
1076
+ # Ensure path is within vault
1077
+ if not str(full_path).startswith(str(vault.resolve())):
1078
+ raise ValueError(f"Path escapes vault root: {note_path}")
1079
+
1080
+ return full_path
1081
+ ```
1082
+
1083
+ ---
1084
+
1085
+ ### Note Content Validation
1086
+
1087
+ ```python
1088
+ def validate_note_content(body: str) -> tuple[bool, str]:
1089
+ """
1090
+ Validate note content.
1091
+
1092
+ Returns (is_valid, error_message).
1093
+ """
1094
+ # Size check (1 MiB max)
1095
+ size_bytes = len(body.encode('utf-8'))
1096
+ if size_bytes > 1_048_576:
1097
+ return False, f"Note exceeds 1 MiB limit ({size_bytes} bytes)"
1098
+
1099
+ # UTF-8 validity
1100
+ try:
1101
+ body.encode('utf-8')
1102
+ except UnicodeEncodeError as e:
1103
+ return False, f"Invalid UTF-8 encoding: {e}"
1104
+
1105
+ return True, ""
1106
+
1107
+
1108
+ def validate_frontmatter(metadata: dict) -> tuple[bool, str]:
1109
+ """
1110
+ Validate frontmatter metadata.
1111
+
1112
+ Returns (is_valid, error_message).
1113
+ """
1114
+ # Check for reserved fields
1115
+ reserved = ['version'] # Version is managed by index, not frontmatter
1116
+ for key in metadata.keys():
1117
+ if key in reserved:
1118
+ return False, f"Field '{key}' is reserved and cannot be set in frontmatter"
1119
+
1120
+ # Validate tags format
1121
+ if 'tags' in metadata:
1122
+ tags = metadata['tags']
1123
+ if not isinstance(tags, list):
1124
+ return False, "Field 'tags' must be an array"
1125
+
1126
+ if not all(isinstance(t, str) for t in tags):
1127
+ return False, "All tags must be strings"
1128
+
1129
+ return True, ""
1130
+ ```
1131
+
1132
+ ---
1133
+
1134
+ ### Vault Limits
1135
+
1136
+ ```python
1137
+ def check_vault_limit(user_id: str, db) -> tuple[bool, str]:
1138
+ """
1139
+ Check if vault is within note limit.
1140
+
1141
+ Returns (is_allowed, error_message).
1142
+ """
1143
+ cursor = db.execute(
1144
+ "SELECT note_count FROM index_health WHERE user_id = ?",
1145
+ (user_id,)
1146
+ )
1147
+ row = cursor.fetchone()
1148
+
1149
+ if row is None:
1150
+ return True, "" # New vault, no limit yet
1151
+
1152
+ note_count = row[0]
1153
+
1154
+ if note_count >= 5000:
1155
+ return False, "Vault note limit exceeded (max 5,000 notes)"
1156
+
1157
+ return True, ""
1158
+ ```
1159
+
1160
+ ---
1161
+
1162
+ ### Token Validation
1163
+
1164
+ ```python
1165
+ import jwt
1166
+ from datetime import datetime, timedelta
1167
+
1168
+ SECRET_KEY = "your-secret-key" # From env var
1169
+
1170
+ def create_jwt(user_id: str) -> str:
1171
+ """Create JWT with 90-day expiration."""
1172
+ now = datetime.utcnow()
1173
+ payload = {
1174
+ "sub": user_id,
1175
+ "iat": int(now.timestamp()),
1176
+ "exp": int((now + timedelta(days=90)).timestamp())
1177
+ }
1178
+ return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
1179
+
1180
+
1181
+ def validate_jwt(token: str) -> tuple[bool, str, str]:
1182
+ """
1183
+ Validate JWT and extract user_id.
1184
+
1185
+ Returns (is_valid, user_id, error_message).
1186
+ """
1187
+ try:
1188
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
1189
+ user_id = payload["sub"]
1190
+ return True, user_id, ""
1191
+
1192
+ except jwt.ExpiredSignatureError:
1193
+ return False, "", "Token expired"
1194
+
1195
+ except jwt.InvalidTokenError as e:
1196
+ return False, "", f"Invalid token: {e}"
1197
+ ```
1198
+
1199
+ ---
1200
+
1201
+ ## State Transitions
1202
+
1203
+ State machines for entity lifecycle and version management.
1204
+
1205
+ ### Note Lifecycle
1206
+
1207
+ ```mermaid
1208
+ stateDiagram-v2
1209
+ [*] --> Creating: write_note (new path)
1210
+ Creating --> Active: save to filesystem + insert metadata
1211
+ Active --> Updating: write_note (existing path)
1212
+ Updating --> Active: increment version + update index
1213
+ Active --> Deleting: delete_note
1214
+ Deleting --> [*]: remove file + delete index rows
1215
+
1216
+ Updating --> ConflictDetected: if_version mismatch (UI writes only)
1217
+ ConflictDetected --> Active: reload or discard changes
1218
+ ```
1219
+
1220
+ **State descriptions**:
1221
+
1222
+ 1. **Creating**: Note does not exist, first write in progress
1223
+ - Validate path and content
1224
+ - Set `version = 1`, `created = now()`, `updated = now()`
1225
+ - Write file to filesystem
1226
+ - Insert rows into `note_metadata`, `note_fts`, `note_tags`, `note_links`
1227
+
1228
+ 2. **Active**: Note exists and is readable/editable
1229
+ - Can be read via API/MCP
1230
+ - Can be updated via write_note
1231
+ - Can be deleted via delete_note
1232
+
1233
+ 3. **Updating**: Modification in progress
1234
+ - Load current metadata (version, timestamps)
1235
+ - If UI write: check `if_version` matches current version
1236
+ - If version mismatch: transition to ConflictDetected
1237
+ - If MCP write: skip version check (last-write-wins)
1238
+ - Increment `version`, set `updated = now()`
1239
+ - Update file content
1240
+ - Update all index rows (delete old, insert new)
1241
+
1242
+ 4. **ConflictDetected**: Optimistic concurrency conflict (UI only)
1243
+ - Return `409 Conflict` with current and expected versions
1244
+ - UI displays error: "Note changed since you opened it"
1245
+ - User options: reload, save as copy, or discard changes
1246
+
1247
+ 5. **Deleting**: Removal in progress
1248
+ - Delete file from filesystem
1249
+ - Delete rows from `note_metadata`, `note_fts`, `note_tags`, `note_links`
1250
+ - Update backlinks (any note linking to deleted note now has unresolved link)
1251
+ - Decrement `index_health.note_count`
1252
+
1253
+ ---
1254
+
1255
+ ### Version Increment Logic
1256
+
1257
+ ```python
1258
+ def increment_version(user_id: str, note_path: str, if_version: int | None, db) -> int:
1259
+ """
1260
+ Increment note version with optional concurrency check.
1261
+
1262
+ Returns new version number.
1263
+ Raises ConflictError if if_version doesn't match.
1264
+ """
1265
+ # Get current version
1266
+ cursor = db.execute(
1267
+ "SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
1268
+ (user_id, note_path)
1269
+ )
1270
+ row = cursor.fetchone()
1271
+
1272
+ if row is None:
1273
+ # New note
1274
+ return 1
1275
+
1276
+ current_version = row[0]
1277
+
1278
+ # Optimistic concurrency check (UI writes only)
1279
+ if if_version is not None and current_version != if_version:
1280
+ raise ConflictError(
1281
+ f"Version conflict: expected {if_version}, current is {current_version}"
1282
+ )
1283
+
1284
+ # Increment version
1285
+ new_version = current_version + 1
1286
+
1287
+ return new_version
1288
+ ```
1289
+
1290
+ ---
1291
+
1292
+ ### Index Update Workflow
1293
+
1294
+ ```mermaid
1295
+ stateDiagram-v2
1296
+ [*] --> IncrementalUpdate: write_note or delete_note
1297
+ IncrementalUpdate --> DeleteOldRows: start transaction
1298
+ DeleteOldRows --> ExtractMetadata: parse frontmatter + body
1299
+ ExtractMetadata --> InsertNewRows: insert into all index tables
1300
+ InsertNewRows --> UpdateHealth: set last_incremental_update
1301
+ UpdateHealth --> [*]: commit transaction
1302
+
1303
+ [*] --> FullRebuild: POST /api/index/rebuild
1304
+ FullRebuild --> DropUserRows: delete all rows for user_id
1305
+ DropUserRows --> ScanVault: walk filesystem tree
1306
+ ScanVault --> ProcessNote: for each .md file
1307
+ ProcessNote --> ExtractMetadata
1308
+ ProcessNote --> ScanVault: next file
1309
+ ScanVault --> UpdateHealth: set last_full_rebuild
1310
+ UpdateHealth --> [*]: commit transaction
1311
+ ```
1312
+
1313
+ **Incremental update** (on every write/delete):
1314
+ 1. Start SQLite transaction
1315
+ 2. Delete all existing rows for `(user_id, note_path)` from:
1316
+ - `note_metadata`
1317
+ - `note_fts`
1318
+ - `note_tags`
1319
+ - `note_links`
1320
+ 3. Parse note content (frontmatter + body)
1321
+ 4. Extract: title, tags, wikilinks
1322
+ 5. Insert new rows into all index tables
1323
+ 6. Resolve wikilinks and update `is_resolved` flags
1324
+ 7. Update `index_health.last_incremental_update = now()`
1325
+ 8. Commit transaction
1326
+
1327
+ **Full rebuild** (manual trigger):
1328
+ 1. Start SQLite transaction
1329
+ 2. Delete all rows for `user_id` from all index tables
1330
+ 3. Walk vault directory tree, find all `.md` files
1331
+ 4. For each file:
1332
+ - Parse frontmatter + body
1333
+ - Extract metadata, tags, wikilinks
1334
+ - Insert rows into all index tables
1335
+ 5. Resolve all wikilinks (second pass after all notes indexed)
1336
+ 6. Update `index_health.note_count` and `last_full_rebuild = now()`
1337
+ 7. Commit transaction
1338
+
1339
+ ---
1340
+
1341
+ ## Relationships and Constraints
1342
+
1343
+ ### Foreign Key Relationships
1344
+
1345
+ While SQLite supports foreign keys, we don't enforce them for performance reasons (multi-tenant with user-scoped queries). Instead, we rely on application-level referential integrity.
1346
+
1347
+ **Logical relationships**:
1348
+ - `note_metadata.user_id` β†’ `User.user_id`
1349
+ - `note_tags.note_path` β†’ `note_metadata.note_path`
1350
+ - `note_links.source_path` β†’ `note_metadata.note_path`
1351
+ - `note_links.target_path` β†’ `note_metadata.note_path` (nullable)
1352
+
1353
+ **Cascade semantics** (application-enforced):
1354
+ - On delete note: cascade delete from `note_tags`, `note_links` (source), `note_fts`
1355
+ - On delete note: update `note_links` (target) to set `is_resolved = false`
1356
+
1357
+ ---
1358
+
1359
+ ### Uniqueness Constraints
1360
+
1361
+ | Table | Unique Constraint | Enforced By |
1362
+ |-------|------------------|-------------|
1363
+ | `note_metadata` | `(user_id, note_path)` | PRIMARY KEY |
1364
+ | `note_tags` | `(user_id, note_path, tag)` | PRIMARY KEY |
1365
+ | `note_links` | `(user_id, source_path, link_text)` | PRIMARY KEY |
1366
+ | `index_health` | `user_id` | PRIMARY KEY |
1367
+
1368
+ ---
1369
+
1370
+ ### Cardinality
1371
+
1372
+ | Relationship | Type | Notes |
1373
+ |-------------|------|-------|
1374
+ | User β†’ Vault | 1:1 | One user owns one vault |
1375
+ | Vault β†’ Notes | 1:N | One vault contains many notes (max 5,000) |
1376
+ | Note β†’ Tags | N:M | Many-to-many via `note_tags` junction table |
1377
+ | Note β†’ Outgoing Links | 1:N | One note has many outgoing wikilinks |
1378
+ | Note β†’ Backlinks | 1:N | One note may be referenced by many backlinks |
1379
+ | User β†’ Tokens | 1:N | One user can issue multiple JWT tokens |
1380
+
1381
+ ---
1382
+
1383
+ ### Invariants
1384
+
1385
+ Critical invariants maintained by the system:
1386
+
1387
+ 1. **Version monotonicity**: `note.version` only increases (never decreases or resets)
1388
+ 2. **Timestamp ordering**: `note.created <= note.updated` always
1389
+ 3. **Path uniqueness**: No two notes with same `(user_id, note_path)` can exist
1390
+ 4. **Size limit**: `note.size_bytes <= 1_048_576` always enforced
1391
+ 5. **Vault limit**: `COUNT(*) WHERE user_id = X <= 5000` enforced before writes
1392
+ 6. **Link consistency**: If `note_links.target_path` is not null, target note must exist
1393
+ 7. **Tag normalization**: All `note_tags.tag` values are lowercase
1394
+ 8. **Index freshness**: `index_health.last_incremental_update` is always >= most recent `note_metadata.updated` for that user
1395
+
1396
+ ---
1397
+
1398
+ ## Appendix
1399
+
1400
+ ### Common Queries Reference
1401
+
1402
+ ```sql
1403
+ -- Get all notes for user, sorted by recent update
1404
+ SELECT note_path, title, updated
1405
+ FROM note_metadata
1406
+ WHERE user_id = ?
1407
+ ORDER BY updated DESC
1408
+ LIMIT 100;
1409
+
1410
+ -- Full-text search with title boost
1411
+ SELECT
1412
+ note_path,
1413
+ title,
1414
+ snippet(note_fts, 3, '<mark>', '</mark>', '...', 32) AS snippet,
1415
+ bm25(note_fts, 3.0, 1.0) AS score
1416
+ FROM note_fts
1417
+ WHERE user_id = ? AND note_fts MATCH ?
1418
+ ORDER BY score DESC
1419
+ LIMIT 50;
1420
+
1421
+ -- Get all tags with counts
1422
+ SELECT tag, COUNT(DISTINCT note_path) as count
1423
+ FROM note_tags
1424
+ WHERE user_id = ?
1425
+ GROUP BY tag
1426
+ ORDER BY count DESC;
1427
+
1428
+ -- Get backlinks for a note
1429
+ SELECT DISTINCT l.source_path, m.title
1430
+ FROM note_links l
1431
+ JOIN note_metadata m ON l.user_id = m.user_id AND l.source_path = m.note_path
1432
+ WHERE l.user_id = ? AND l.target_path = ?
1433
+ ORDER BY m.updated DESC;
1434
+
1435
+ -- Get all unresolved wikilinks for a user
1436
+ SELECT source_path, link_text, COUNT(*) as occurrences
1437
+ FROM note_links
1438
+ WHERE user_id = ? AND is_resolved = 0
1439
+ GROUP BY source_path, link_text
1440
+ ORDER BY occurrences DESC;
1441
+
1442
+ -- Check index health
1443
+ SELECT note_count, last_full_rebuild, last_incremental_update
1444
+ FROM index_health
1445
+ WHERE user_id = ?;
1446
+ ```
1447
+
1448
+ ---
1449
+
1450
+ ### Migration Strategy
1451
+
1452
+ For future schema changes:
1453
+
1454
+ ```python
1455
+ # Example migration: Add column to note_metadata
1456
+ def migrate_v1_to_v2(db):
1457
+ """Add normalized_title_slug column."""
1458
+ db.execute("""
1459
+ ALTER TABLE note_metadata
1460
+ ADD COLUMN normalized_title_slug TEXT;
1461
+ """)
1462
+
1463
+ # Backfill existing notes
1464
+ db.execute("""
1465
+ UPDATE note_metadata
1466
+ SET normalized_title_slug = LOWER(
1467
+ REPLACE(REPLACE(title, ' ', '-'), '_', '-')
1468
+ );
1469
+ """)
1470
+
1471
+ db.execute("""
1472
+ CREATE INDEX idx_metadata_title_slug
1473
+ ON note_metadata(user_id, normalized_title_slug);
1474
+ """)
1475
+
1476
+ db.commit()
1477
+ ```
1478
+
1479
+ ---
1480
+
1481
+ **Document Status**: Draft
1482
+ **Last Updated**: 2025-11-15
1483
+ **Next Review**: After Phase 1 implementation
specs/001-obsidian-docs-viewer/plan.md CHANGED
@@ -186,4 +186,60 @@ README.md # Setup instructions, MCP client config examples
186
 
187
  ---
188
 
189
- **Plan Status**: Phase 0 and Phase 1 execution in progress below...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  ---
188
 
189
+ ## Execution Summary
190
+
191
+ ### Phase 0: Research & Technical Decisions βœ… COMPLETE
192
+
193
+ **Generated**: `research.md` (1,407 lines)
194
+
195
+ **Researched and Resolved**:
196
+ 1. βœ… FastMCP HTTP transport authentication patterns β†’ Bearer token with `BearerAuth`, JWT validation
197
+ 2. βœ… Hugging Face Space OAuth integration β†’ `attach_huggingface_oauth` + `parse_huggingface_oauth` helpers
198
+ 3. βœ… SQLite schema design β†’ FTS5 contentless + separate tags/links tables with per-user isolation
199
+ 4. βœ… Wikilink normalization β†’ Case-insensitive normalized slug matching, same-folder preference
200
+ 5. βœ… React + shadcn/ui directory tree β†’ shadcn-extension Tree View with TanStack Virtual virtualization
201
+ 6. βœ… Optimistic concurrency β†’ Version counter in SQLite + `if_version` parameter, 409 on mismatch
202
+ 7. βœ… Markdown frontmatter parsing β†’ `python-frontmatter` with try-except fallback for malformed YAML
203
+ 8. βœ… JWT token management β†’ Memory storage (MVP) or memory + HttpOnly cookie (production)
204
+
205
+ **Key Decisions**:
206
+ - FTS5 with porter tokenizer and `prefix='2 3'` for autocomplete
207
+ - shadcn-extension tree handles 5,000+ notes with <200ms render
208
+ - Version counter is simpler and faster than content hashing
209
+ - Hybrid JWT approach (memory + HttpOnly) is 2025 security best practice
210
+
211
+ ### Phase 1: Data Model & Contracts βœ… COMPLETE
212
+
213
+ **Generated**:
214
+ - `data-model.md` - Complete entity definitions, Pydantic models, TypeScript types, SQLite schema, validation rules, state transitions
215
+ - `contracts/http-api.yaml` - OpenAPI 3.1 spec with 11 endpoints, auth, all error responses, examples
216
+ - `contracts/mcp-tools.json` - 7 MCP tool schemas with JSON Schema validation, usage examples
217
+ - `quickstart.md` - Complete setup, run, test, deploy guide for local PoC and HF Space
218
+
219
+ **Data Model Highlights**:
220
+ - 7 core entities (User, Vault, Note, Wikilink, Tag, Index, Token)
221
+ - Complete Pydantic models with validators
222
+ - TypeScript types for frontend
223
+ - SQLite DDL with 5 tables (metadata, FTS, tags, links, health)
224
+ - State machines for note lifecycle and index updates
225
+
226
+ **API Contract Highlights**:
227
+ - HTTP API: 11 endpoints (auth, CRUD, search, navigation, index)
228
+ - MCP Tools: 7 tools (list, read, write, delete, search, backlinks, tags)
229
+ - All error codes documented (400, 401, 403, 409, 413, 500)
230
+ - Request/response examples for all operations
231
+
232
+ **Agent Context Updated**:
233
+ - βœ… Updated `/home/wolfe/Projects/Document-MCP/CLAUDE.md` with Python 3.11+, FastAPI, FastMCP, SQLite tech stack
234
+
235
+ ### Constitution Re-Check βœ… N/A
236
+
237
+ **Status**: No project constitution exists. Design decisions documented in research.md can form future constitution baseline.
238
+
239
+ ---
240
+
241
+ ## Next Steps
242
+
243
+ Run `/speckit.tasks` to generate dependency-ordered implementation tasks based on this plan, data model, and contracts.
244
+
245
+ **Planning Phase Complete**: 2025-11-15
specs/001-obsidian-docs-viewer/quickstart.md ADDED
@@ -0,0 +1,1455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quickstart Guide: Multi-Tenant Obsidian-Like Docs Viewer
2
+
3
+ **Feature Branch**: `001-obsidian-docs-viewer`
4
+ **Created**: 2025-11-15
5
+ **Status**: Draft
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Prerequisites](#prerequisites)
12
+ 2. [Local Development Setup](#local-development-setup)
13
+ 3. [Running the Backend](#running-the-backend)
14
+ 4. [Running the Frontend](#running-the-frontend)
15
+ 5. [MCP Client Configuration](#mcp-client-configuration)
16
+ 6. [Testing Workflows](#testing-workflows)
17
+ 7. [Development Workflows](#development-workflows)
18
+ 8. [Hugging Face Space Deployment](#hugging-face-space-deployment)
19
+ 9. [Troubleshooting](#troubleshooting)
20
+
21
+ ---
22
+
23
+ ## Prerequisites
24
+
25
+ Before starting, ensure you have the following installed:
26
+
27
+ ### Required Software
28
+
29
+ - **Python**: 3.11 or higher
30
+ - Check version: `python --version` or `python3 --version`
31
+ - Download: https://www.python.org/downloads/
32
+
33
+ - **Node.js**: 18 or higher
34
+ - Check version: `node --version`
35
+ - Download: https://nodejs.org/
36
+
37
+ - **Git**: Any recent version
38
+ - Check version: `git --version`
39
+ - Download: https://git-scm.com/downloads/
40
+
41
+ ### Optional Tools
42
+
43
+ - **Poetry** (recommended for Python dependency management): `pip install poetry`
44
+ - **Claude Desktop** or **Claude Code** (for MCP STDIO integration)
45
+ - **Docker** (for containerized deployment testing)
46
+
47
+ ---
48
+
49
+ ## Local Development Setup
50
+
51
+ ### 1. Clone the Repository
52
+
53
+ ```bash
54
+ git clone <repository-url>
55
+ cd Document-MCP
56
+ ```
57
+
58
+ ### 2. Create Environment Configuration
59
+
60
+ Copy the example environment file and customize it:
61
+
62
+ ```bash
63
+ cp .env.example .env
64
+ ```
65
+
66
+ **`.env.example` contents**:
67
+
68
+ ```bash
69
+ # Application Mode
70
+ # Options: "local" (single-user development) or "space" (multi-tenant HF Space)
71
+ MODE=local
72
+
73
+ # JWT Secret Key
74
+ # IMPORTANT: Generate a secure random string for production
75
+ # Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
76
+ JWT_SECRET_KEY=your-secret-key-change-in-production
77
+
78
+ # Vault Storage
79
+ # Base directory for per-user vault storage
80
+ VAULT_BASE_DIR=./data/vaults
81
+
82
+ # SQLite Database
83
+ # Path to SQLite database file
84
+ DB_PATH=./data/index.db
85
+
86
+ # Local Mode Configuration
87
+ # Default user ID for local development (no authentication)
88
+ LOCAL_USER_ID=local-dev
89
+
90
+ # Optional: Static Bearer Token for Local Mode
91
+ # Leave empty to disable token requirement in local mode
92
+ # Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
93
+ LOCAL_STATIC_TOKEN=
94
+
95
+ # Hugging Face Space Configuration (only needed for HF deployment)
96
+ # HF_OAUTH_CLIENT_ID=your-hf-client-id
97
+ # HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
98
+ # HF_SPACE_HOST=https://your-space.hf.space
99
+
100
+ # Backend Server
101
+ BACKEND_HOST=0.0.0.0
102
+ BACKEND_PORT=8000
103
+
104
+ # Frontend Development
105
+ VITE_API_BASE_URL=http://localhost:8000
106
+ ```
107
+
108
+ **Generate secure secrets**:
109
+
110
+ ```bash
111
+ # Generate JWT secret
112
+ python -c "import secrets; print(secrets.token_urlsafe(32))"
113
+
114
+ # Generate optional local static token
115
+ python -c "import secrets; print(secrets.token_urlsafe(32))"
116
+ ```
117
+
118
+ Update `.env` with generated values.
119
+
120
+ ### 3. Backend Setup
121
+
122
+ Navigate to the backend directory:
123
+
124
+ ```bash
125
+ cd backend
126
+ ```
127
+
128
+ #### Option A: Using Poetry (Recommended)
129
+
130
+ ```bash
131
+ # Install Poetry if not already installed
132
+ pip install poetry
133
+
134
+ # Install dependencies
135
+ poetry install
136
+
137
+ # Activate virtual environment
138
+ poetry shell
139
+ ```
140
+
141
+ #### Option B: Using pip with venv
142
+
143
+ ```bash
144
+ # Create virtual environment
145
+ python -m venv venv
146
+
147
+ # Activate virtual environment
148
+ # On Windows:
149
+ venv\Scripts\activate
150
+ # On macOS/Linux:
151
+ source venv/bin/activate
152
+
153
+ # Install dependencies
154
+ pip install -r requirements.txt
155
+ ```
156
+
157
+ **`requirements.txt` example**:
158
+
159
+ ```txt
160
+ fastapi==0.104.1
161
+ uvicorn[standard]==0.24.0
162
+ fastmcp==0.3.0
163
+ python-frontmatter==1.0.1
164
+ PyJWT==2.8.0
165
+ huggingface-hub==0.19.4
166
+ pydantic==2.5.0
167
+ python-multipart==0.0.6
168
+ aiosqlite==0.19.0
169
+ ```
170
+
171
+ ### 4. Initialize Database Schema
172
+
173
+ Run the database initialization script:
174
+
175
+ ```bash
176
+ # From backend/ directory
177
+ python -m src.services.init_db
178
+ ```
179
+
180
+ **`src/services/init_db.py` (create this file)**:
181
+
182
+ ```python
183
+ """
184
+ Database initialization script.
185
+ Creates SQLite schema and indexes.
186
+ """
187
+ import sqlite3
188
+ import os
189
+ from pathlib import Path
190
+
191
+ def init_database(db_path: str):
192
+ """Initialize SQLite database with schema."""
193
+ # Ensure directory exists
194
+ db_file = Path(db_path)
195
+ db_file.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ conn = sqlite3.connect(db_path)
198
+ cursor = conn.cursor()
199
+
200
+ # Begin transaction
201
+ cursor.execute("BEGIN TRANSACTION")
202
+
203
+ # Core metadata table
204
+ cursor.execute("""
205
+ CREATE TABLE IF NOT EXISTS note_metadata (
206
+ user_id TEXT NOT NULL,
207
+ note_path TEXT NOT NULL,
208
+ version INTEGER NOT NULL DEFAULT 1,
209
+ title TEXT NOT NULL,
210
+ created TEXT NOT NULL,
211
+ updated TEXT NOT NULL,
212
+ size_bytes INTEGER NOT NULL DEFAULT 0,
213
+ normalized_title_slug TEXT,
214
+ normalized_path_slug TEXT,
215
+ PRIMARY KEY (user_id, note_path)
216
+ )
217
+ """)
218
+
219
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_metadata_user ON note_metadata(user_id)")
220
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_metadata_updated ON note_metadata(user_id, updated DESC)")
221
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_metadata_title_slug ON note_metadata(user_id, normalized_title_slug)")
222
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_metadata_path_slug ON note_metadata(user_id, normalized_path_slug)")
223
+
224
+ # Full-text search index
225
+ cursor.execute("""
226
+ CREATE VIRTUAL TABLE IF NOT EXISTS note_fts USING fts5(
227
+ user_id UNINDEXED,
228
+ note_path UNINDEXED,
229
+ title,
230
+ body,
231
+ content='',
232
+ tokenize='porter unicode61',
233
+ prefix='2 3'
234
+ )
235
+ """)
236
+
237
+ # Tag index
238
+ cursor.execute("""
239
+ CREATE TABLE IF NOT EXISTS note_tags (
240
+ user_id TEXT NOT NULL,
241
+ note_path TEXT NOT NULL,
242
+ tag TEXT NOT NULL,
243
+ PRIMARY KEY (user_id, note_path, tag)
244
+ )
245
+ """)
246
+
247
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tags_user_tag ON note_tags(user_id, tag)")
248
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tags_user_path ON note_tags(user_id, note_path)")
249
+
250
+ # Link graph
251
+ cursor.execute("""
252
+ CREATE TABLE IF NOT EXISTS note_links (
253
+ user_id TEXT NOT NULL,
254
+ source_path TEXT NOT NULL,
255
+ target_path TEXT,
256
+ link_text TEXT NOT NULL,
257
+ is_resolved INTEGER NOT NULL DEFAULT 0,
258
+ PRIMARY KEY (user_id, source_path, link_text)
259
+ )
260
+ """)
261
+
262
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_links_user_source ON note_links(user_id, source_path)")
263
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_links_user_target ON note_links(user_id, target_path)")
264
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_links_unresolved ON note_links(user_id, is_resolved)")
265
+
266
+ # Index health tracking
267
+ cursor.execute("""
268
+ CREATE TABLE IF NOT EXISTS index_health (
269
+ user_id TEXT PRIMARY KEY,
270
+ note_count INTEGER NOT NULL DEFAULT 0,
271
+ last_full_rebuild TEXT,
272
+ last_incremental_update TEXT
273
+ )
274
+ """)
275
+
276
+ # Commit transaction
277
+ cursor.execute("COMMIT")
278
+
279
+ conn.close()
280
+ print(f"Database initialized at: {db_path}")
281
+
282
+ if __name__ == "__main__":
283
+ # Load DB path from environment or use default
284
+ db_path = os.getenv("DB_PATH", "./data/index.db")
285
+ init_database(db_path)
286
+ ```
287
+
288
+ Run initialization:
289
+
290
+ ```bash
291
+ python -m src.services.init_db
292
+ ```
293
+
294
+ Expected output:
295
+ ```
296
+ Database initialized at: ./data/index.db
297
+ ```
298
+
299
+ ### 5. Create Vault Directory Structure
300
+
301
+ ```bash
302
+ # From project root
303
+ mkdir -p data/vaults/local-dev
304
+
305
+ # Create a test note
306
+ cat > data/vaults/local-dev/README.md << 'EOF'
307
+ ---
308
+ title: Welcome to Your Vault
309
+ tags: [getting-started]
310
+ created: 2025-01-15T10:00:00Z
311
+ updated: 2025-01-15T10:00:00Z
312
+ ---
313
+
314
+ # Welcome to Your Vault
315
+
316
+ This is your personal documentation vault.
317
+
318
+ ## Features
319
+
320
+ - **AI-powered writing**: Use MCP tools to create and update notes
321
+ - **Full-text search**: Search across all notes instantly
322
+ - **Wikilinks**: Link notes together with `[[Note Name]]` syntax
323
+ - **Tags**: Organize notes with frontmatter tags
324
+
325
+ ## Getting Started
326
+
327
+ Try creating your first note:
328
+ - Via MCP: Use the `write_note` tool
329
+ - Via UI: Click "New Note" in the sidebar
330
+
331
+ Check out [[Quick Start Guide]] for more details.
332
+ EOF
333
+ ```
334
+
335
+ ### 6. Frontend Setup
336
+
337
+ Open a new terminal and navigate to the frontend directory:
338
+
339
+ ```bash
340
+ cd frontend
341
+ ```
342
+
343
+ Install dependencies:
344
+
345
+ ```bash
346
+ npm install
347
+ ```
348
+
349
+ **`package.json` example**:
350
+
351
+ ```json
352
+ {
353
+ "name": "obsidian-docs-viewer-frontend",
354
+ "version": "0.1.0",
355
+ "type": "module",
356
+ "scripts": {
357
+ "dev": "vite",
358
+ "build": "tsc && vite build",
359
+ "preview": "vite preview",
360
+ "test": "vitest",
361
+ "test:e2e": "playwright test",
362
+ "lint": "eslint src --ext ts,tsx"
363
+ },
364
+ "dependencies": {
365
+ "react": "^18.2.0",
366
+ "react-dom": "^18.2.0",
367
+ "react-router-dom": "^6.20.0",
368
+ "react-markdown": "^9.0.0",
369
+ "remark-gfm": "^4.0.0",
370
+ "remark-frontmatter": "^5.0.0",
371
+ "@radix-ui/react-dialog": "^1.0.5",
372
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
373
+ "@radix-ui/react-scroll-area": "^1.0.5",
374
+ "@radix-ui/react-separator": "^1.0.3",
375
+ "class-variance-authority": "^0.7.0",
376
+ "clsx": "^2.0.0",
377
+ "tailwind-merge": "^2.1.0",
378
+ "lucide-react": "^0.295.0"
379
+ },
380
+ "devDependencies": {
381
+ "@types/react": "^18.2.43",
382
+ "@types/react-dom": "^18.2.17",
383
+ "@typescript-eslint/eslint-plugin": "^6.14.0",
384
+ "@typescript-eslint/parser": "^6.14.0",
385
+ "@vitejs/plugin-react": "^4.2.1",
386
+ "autoprefixer": "^10.4.16",
387
+ "eslint": "^8.55.0",
388
+ "postcss": "^8.4.32",
389
+ "tailwindcss": "^3.3.6",
390
+ "typescript": "^5.2.2",
391
+ "vite": "^5.0.8",
392
+ "vitest": "^1.0.4",
393
+ "@playwright/test": "^1.40.0"
394
+ }
395
+ }
396
+ ```
397
+
398
+ Configure API base URL (if not already in `.env`):
399
+
400
+ ```bash
401
+ # frontend/.env
402
+ VITE_API_BASE_URL=http://localhost:8000
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Running the Backend
408
+
409
+ The backend can be run in multiple modes depending on your use case.
410
+
411
+ ### Mode 1: FastAPI HTTP Server (for UI access)
412
+
413
+ Start the FastAPI development server:
414
+
415
+ ```bash
416
+ # From backend/ directory (with venv activated)
417
+ uvicorn src.api.main:app --reload --host 0.0.0.0 --port 8000
418
+ ```
419
+
420
+ Expected output:
421
+ ```
422
+ INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
423
+ INFO: Started reloader process [12345] using StatReload
424
+ INFO: Started server process [12346]
425
+ INFO: Waiting for application startup.
426
+ INFO: Application startup complete.
427
+ ```
428
+
429
+ **Test the API**:
430
+
431
+ ```bash
432
+ # Health check
433
+ curl http://localhost:8000/api/health
434
+
435
+ # Expected response:
436
+ # {"status": "ok", "mode": "local"}
437
+
438
+ # Get user info (local mode)
439
+ curl http://localhost:8000/api/me
440
+
441
+ # Expected response:
442
+ # {"user_id": "local-dev", "vault_path": "./data/vaults/local-dev"}
443
+ ```
444
+
445
+ ### Mode 2: MCP STDIO Server (for Claude Code/Desktop)
446
+
447
+ Start the MCP server in STDIO mode:
448
+
449
+ ```bash
450
+ # From backend/ directory
451
+ python -m src.mcp.server
452
+ ```
453
+
454
+ Expected output:
455
+ ```
456
+ MCP server starting in STDIO mode...
457
+ Listening for MCP requests on stdin/stdout...
458
+ ```
459
+
460
+ This server communicates via standard input/output and is designed to be invoked by MCP clients like Claude Desktop.
461
+
462
+ ### Mode 3: MCP HTTP Server (for remote MCP access)
463
+
464
+ Start the MCP server with HTTP transport:
465
+
466
+ ```bash
467
+ # From backend/ directory
468
+ python -m src.mcp.server --http --port 8001
469
+ ```
470
+
471
+ Expected output:
472
+ ```
473
+ MCP server starting in HTTP mode...
474
+ Server running at: http://0.0.0.0:8001
475
+ Authentication: Bearer token required
476
+ ```
477
+
478
+ **Test MCP HTTP endpoint**:
479
+
480
+ ```bash
481
+ # Issue a token first (if using authentication)
482
+ curl -X POST http://localhost:8000/api/tokens
483
+
484
+ # Use token to call MCP tool
485
+ curl -X POST http://localhost:8001/mcp/call \
486
+ -H "Authorization: Bearer <your-token>" \
487
+ -H "Content-Type: application/json" \
488
+ -d '{
489
+ "tool": "list_notes",
490
+ "arguments": {}
491
+ }'
492
+ ```
493
+
494
+ ### Run All Backend Services (Production Mode)
495
+
496
+ For production, run both servers together:
497
+
498
+ ```bash
499
+ # Terminal 1: FastAPI server
500
+ uvicorn src.api.main:app --host 0.0.0.0 --port 8000
501
+
502
+ # Terminal 2: MCP HTTP server
503
+ python -m src.mcp.server --http --port 8001
504
+ ```
505
+
506
+ Or use a process manager like `supervisord` or `pm2`.
507
+
508
+ ---
509
+
510
+ ## Running the Frontend
511
+
512
+ Start the Vite development server:
513
+
514
+ ```bash
515
+ # From frontend/ directory
516
+ npm run dev
517
+ ```
518
+
519
+ Expected output:
520
+ ```
521
+ VITE v5.0.8 ready in 523 ms
522
+
523
+ ➜ Local: http://localhost:5173/
524
+ ➜ Network: use --host to expose
525
+ ➜ press h to show help
526
+ ```
527
+
528
+ Open your browser to **http://localhost:5173**
529
+
530
+ ### Local Mode UI Flow
531
+
532
+ 1. **Landing page**: In local mode, you'll be automatically authenticated as `local-dev`
533
+ 2. **Directory pane** (left sidebar):
534
+ - Shows vault folder structure
535
+ - Click on folders to expand/collapse
536
+ - Click on notes to view content
537
+ 3. **Search bar** (top of left sidebar):
538
+ - Type to search notes
539
+ - Results appear in dropdown with snippets
540
+ 4. **Main pane** (right side):
541
+ - View rendered Markdown
542
+ - Click "Edit" to enter edit mode
543
+ - Click wikilinks to navigate between notes
544
+ 5. **Footer** (below main pane):
545
+ - View tags (clickable chips)
546
+ - See created/updated timestamps
547
+ - View backlinks (notes that reference current note)
548
+
549
+ ### Configure Static Token (Optional)
550
+
551
+ If you set `LOCAL_STATIC_TOKEN` in `.env`, configure the frontend:
552
+
553
+ **In browser console** (http://localhost:5173):
554
+
555
+ ```javascript
556
+ localStorage.setItem('auth_token', 'your-static-token-from-env');
557
+ location.reload();
558
+ ```
559
+
560
+ Or update `frontend/src/services/auth.ts` to automatically use the token in local mode.
561
+
562
+ ---
563
+
564
+ ## MCP Client Configuration
565
+
566
+ ### Claude Desktop (STDIO)
567
+
568
+ Configure Claude Desktop to use the MCP STDIO server.
569
+
570
+ **Config file location**:
571
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
572
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
573
+ - **Linux**: `~/.config/Claude/claude_desktop_config.json`
574
+
575
+ **`claude_desktop_config.json`**:
576
+
577
+ ```json
578
+ {
579
+ "mcpServers": {
580
+ "obsidian-docs": {
581
+ "command": "python",
582
+ "args": [
583
+ "-m",
584
+ "src.mcp.server"
585
+ ],
586
+ "cwd": "/absolute/path/to/Document-MCP/backend",
587
+ "env": {
588
+ "MODE": "local",
589
+ "VAULT_BASE_DIR": "/absolute/path/to/Document-MCP/data/vaults",
590
+ "DB_PATH": "/absolute/path/to/Document-MCP/data/index.db",
591
+ "LOCAL_USER_ID": "local-dev"
592
+ }
593
+ }
594
+ }
595
+ }
596
+ ```
597
+
598
+ **Important**: Replace `/absolute/path/to/Document-MCP` with your actual project path.
599
+
600
+ **Test in Claude Desktop**:
601
+
602
+ 1. Restart Claude Desktop
603
+ 2. Open a conversation
604
+ 3. Try: "List all notes in my vault"
605
+ 4. Claude should call the `list_notes` MCP tool
606
+
607
+ ### Claude Code (STDIO)
608
+
609
+ Claude Code uses a similar configuration. Create or edit `~/.claude/mcp_config.json`:
610
+
611
+ ```json
612
+ {
613
+ "mcpServers": {
614
+ "obsidian-docs": {
615
+ "command": "python",
616
+ "args": ["-m", "src.mcp.server"],
617
+ "cwd": "/absolute/path/to/Document-MCP/backend",
618
+ "env": {
619
+ "MODE": "local",
620
+ "VAULT_BASE_DIR": "/absolute/path/to/Document-MCP/data/vaults",
621
+ "DB_PATH": "/absolute/path/to/Document-MCP/data/index.db"
622
+ }
623
+ }
624
+ }
625
+ }
626
+ ```
627
+
628
+ ### MCP HTTP Transport (Remote Access)
629
+
630
+ For remote access (e.g., from HF Space or external tools), use HTTP transport.
631
+
632
+ **Setup**:
633
+
634
+ 1. Start MCP HTTP server:
635
+ ```bash
636
+ python -m src.mcp.server --http --port 8001
637
+ ```
638
+
639
+ 2. Issue a JWT token:
640
+ ```bash
641
+ curl -X POST http://localhost:8000/api/tokens
642
+ ```
643
+
644
+ Response:
645
+ ```json
646
+ {
647
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
648
+ "token_type": "bearer",
649
+ "expires_at": "2025-04-15T10:30:00Z"
650
+ }
651
+ ```
652
+
653
+ 3. Configure MCP client with HTTP endpoint:
654
+
655
+ **Example HTTP MCP client configuration** (pseudo-code):
656
+
657
+ ```json
658
+ {
659
+ "mcp_endpoint": "http://localhost:8001/mcp",
660
+ "auth": {
661
+ "type": "bearer",
662
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
663
+ }
664
+ }
665
+ ```
666
+
667
+ **Manual HTTP MCP call**:
668
+
669
+ ```bash
670
+ curl -X POST http://localhost:8001/mcp/call \
671
+ -H "Authorization: Bearer <your-token>" \
672
+ -H "Content-Type: application/json" \
673
+ -d '{
674
+ "tool": "write_note",
675
+ "arguments": {
676
+ "path": "test/hello.md",
677
+ "title": "Hello World",
678
+ "body": "# Hello World\n\nThis is a test note created via MCP HTTP."
679
+ }
680
+ }'
681
+ ```
682
+
683
+ Expected response:
684
+ ```json
685
+ {
686
+ "status": "ok",
687
+ "path": "test/hello.md"
688
+ }
689
+ ```
690
+
691
+ ---
692
+
693
+ ## Testing Workflows
694
+
695
+ ### Backend Unit Tests
696
+
697
+ Run pytest for unit tests:
698
+
699
+ ```bash
700
+ # From backend/ directory
701
+ pytest tests/unit -v
702
+ ```
703
+
704
+ **Example test structure**:
705
+
706
+ ```
707
+ backend/tests/
708
+ β”œβ”€β”€ unit/
709
+ β”‚ β”œβ”€β”€ test_vault.py # Vault service tests
710
+ β”‚ β”œβ”€β”€ test_indexer.py # Indexer service tests
711
+ β”‚ β”œβ”€β”€ test_auth.py # Auth service tests
712
+ β”‚ └── test_wikilink.py # Wikilink resolution tests
713
+ β”œβ”€β”€ integration/
714
+ β”‚ β”œβ”€β”€ test_api_notes.py # API integration tests
715
+ β”‚ β”œβ”€β”€ test_api_search.py # Search API tests
716
+ β”‚ └── test_mcp_tools.py # MCP tool integration tests
717
+ └── contract/
718
+ β”œβ”€β”€ test_http_api_contract.py # OpenAPI contract validation
719
+ └── test_mcp_contract.py # MCP tool schema validation
720
+ ```
721
+
722
+ **Run specific test file**:
723
+
724
+ ```bash
725
+ pytest tests/unit/test_vault.py -v
726
+ ```
727
+
728
+ **Expected output**:
729
+
730
+ ```
731
+ tests/unit/test_vault.py::test_create_note PASSED
732
+ tests/unit/test_vault.py::test_read_note PASSED
733
+ tests/unit/test_vault.py::test_update_note PASSED
734
+ tests/unit/test_vault.py::test_delete_note PASSED
735
+ tests/unit/test_vault.py::test_path_validation PASSED
736
+ tests/unit/test_vault.py::test_path_traversal_blocked PASSED
737
+
738
+ ====== 6 passed in 0.23s ======
739
+ ```
740
+
741
+ ### Backend Integration Tests
742
+
743
+ Test full API workflows:
744
+
745
+ ```bash
746
+ # From backend/ directory
747
+ pytest tests/integration -v
748
+ ```
749
+
750
+ Expected output:
751
+ ```
752
+ tests/integration/test_api_notes.py::test_create_and_read_note PASSED
753
+ tests/integration/test_api_notes.py::test_update_note_with_version PASSED
754
+ tests/integration/test_api_notes.py::test_version_conflict_409 PASSED
755
+ tests/integration/test_api_search.py::test_full_text_search PASSED
756
+ tests/integration/test_api_search.py::test_search_ranking PASSED
757
+ tests/integration/test_mcp_tools.py::test_write_note_tool PASSED
758
+ tests/integration/test_mcp_tools.py::test_search_notes_tool PASSED
759
+
760
+ ====== 7 passed in 1.45s ======
761
+ ```
762
+
763
+ ### Backend Contract Tests
764
+
765
+ Validate API contracts against OpenAPI spec:
766
+
767
+ ```bash
768
+ pytest tests/contract -v
769
+ ```
770
+
771
+ Expected output:
772
+ ```
773
+ tests/contract/test_http_api_contract.py::test_openapi_spec_valid PASSED
774
+ tests/contract/test_http_api_contract.py::test_all_endpoints_match_spec PASSED
775
+ tests/contract/test_mcp_contract.py::test_mcp_tool_schemas_valid PASSED
776
+
777
+ ====== 3 passed in 0.34s ======
778
+ ```
779
+
780
+ ### Frontend Unit Tests
781
+
782
+ Run Vitest for component tests:
783
+
784
+ ```bash
785
+ # From frontend/ directory
786
+ npm test
787
+ ```
788
+
789
+ **Example test structure**:
790
+
791
+ ```
792
+ frontend/tests/
793
+ β”œβ”€β”€ unit/
794
+ β”‚ β”œβ”€β”€ DirectoryTree.test.tsx
795
+ β”‚ β”œβ”€β”€ NoteViewer.test.tsx
796
+ β”‚ β”œβ”€β”€ NoteEditor.test.tsx
797
+ β”‚ β”œβ”€β”€ SearchBar.test.tsx
798
+ β”‚ └── wikilink.test.ts
799
+ └── e2e/
800
+ β”œβ”€β”€ note-crud.spec.ts
801
+ β”œβ”€β”€ search.spec.ts
802
+ └── navigation.spec.ts
803
+ ```
804
+
805
+ **Run specific test**:
806
+
807
+ ```bash
808
+ npm test -- DirectoryTree.test.tsx
809
+ ```
810
+
811
+ Expected output:
812
+ ```
813
+ βœ“ tests/unit/DirectoryTree.test.tsx (5)
814
+ βœ“ renders folder tree structure
815
+ βœ“ expands/collapses folders on click
816
+ βœ“ selects note on click
817
+ βœ“ highlights selected note
818
+ βœ“ updates tree when notes change
819
+
820
+ Test Files 1 passed (1)
821
+ Tests 5 passed (5)
822
+ ```
823
+
824
+ ### Frontend E2E Tests
825
+
826
+ Run Playwright for end-to-end tests:
827
+
828
+ ```bash
829
+ # From frontend/ directory
830
+ npm run test:e2e
831
+ ```
832
+
833
+ **Setup Playwright** (first time):
834
+
835
+ ```bash
836
+ npx playwright install
837
+ ```
838
+
839
+ Expected output:
840
+ ```
841
+ Running 8 tests using 4 workers
842
+
843
+ βœ“ [chromium] β€Ί note-crud.spec.ts:3:1 β€Ί create new note (1.2s)
844
+ βœ“ [chromium] β€Ί note-crud.spec.ts:15:1 β€Ί edit existing note (0.9s)
845
+ βœ“ [chromium] β€Ί note-crud.spec.ts:28:1 β€Ί delete note (0.7s)
846
+ βœ“ [chromium] β€Ί search.spec.ts:3:1 β€Ί search notes by title (0.8s)
847
+ βœ“ [chromium] β€Ί search.spec.ts:12:1 β€Ί search notes by content (0.9s)
848
+ βœ“ [chromium] β€Ί navigation.spec.ts:3:1 β€Ί navigate via directory tree (0.6s)
849
+ βœ“ [chromium] β€Ί navigation.spec.ts:14:1 β€Ί navigate via wikilink click (1.1s)
850
+ βœ“ [chromium] β€Ί navigation.spec.ts:25:1 β€Ί view backlinks (0.8s)
851
+
852
+ 8 passed (6.0s)
853
+ ```
854
+
855
+ ---
856
+
857
+ ## Development Workflows
858
+
859
+ ### Workflow 1: AI Agent Writes a Note via MCP
860
+
861
+ **Scenario**: Use Claude Desktop to create a new design document.
862
+
863
+ 1. **Open Claude Desktop** (with MCP configured)
864
+
865
+ 2. **Prompt Claude**:
866
+ ```
867
+ Create a new note at "design/api-authentication.md" with the following:
868
+
869
+ Title: API Authentication Design
870
+ Tags: backend, security, api
871
+
872
+ Content:
873
+ # API Authentication Design
874
+
875
+ ## Overview
876
+ This document describes the authentication strategy for our API.
877
+
878
+ ## JWT Token Flow
879
+ 1. User authenticates via HF OAuth
880
+ 2. Server issues JWT with 90-day expiration
881
+ 3. Client includes token in Authorization header
882
+
883
+ ## Token Validation
884
+ - Validate signature using HS256
885
+ - Check expiration timestamp
886
+ - Extract user_id from 'sub' claim
887
+
888
+ See [[Security Best Practices]] for more details.
889
+ ```
890
+
891
+ 3. **Claude executes**:
892
+ - Calls `write_note` MCP tool
893
+ - Creates file at `data/vaults/local-dev/design/api-authentication.md`
894
+ - Writes frontmatter with title, tags, timestamps
895
+ - Writes markdown body
896
+ - Updates index (full-text search, tag index, wikilink graph)
897
+
898
+ 4. **Verify in UI**:
899
+ - Refresh browser at http://localhost:5173
900
+ - Expand "design" folder in directory tree
901
+ - Click "api-authentication.md"
902
+ - See rendered note with wikilink to "Security Best Practices"
903
+
904
+ ### Workflow 2: Human Edits a Note in UI
905
+
906
+ **Scenario**: Fix a typo in the note created above.
907
+
908
+ 1. **Navigate to note**:
909
+ - Open http://localhost:5173
910
+ - Click on "design/api-authentication.md" in directory tree
911
+
912
+ 2. **Enter edit mode**:
913
+ - Click "Edit" button in top-right
914
+ - UI switches to split view: markdown editor (left) and live preview (right)
915
+
916
+ 3. **Make changes**:
917
+ - Fix typo: "authenication" β†’ "authentication"
918
+ - Add a new section:
919
+ ```markdown
920
+ ## Token Expiration
921
+ Tokens expire after 90 days. Users must re-authenticate.
922
+ ```
923
+
924
+ 4. **Save changes**:
925
+ - Click "Save" button
926
+ - UI sends `PUT /api/notes/design/api-authentication.md` with `if_version: 1`
927
+ - Backend increments version to 2, updates timestamp
928
+ - UI switches back to read mode with updated content
929
+
930
+ 5. **Verify version tracking**:
931
+ - Check footer: "Updated: <timestamp>"
932
+ - Try editing again (version is now 2)
933
+
934
+ ### Workflow 3: Search for Notes
935
+
936
+ **Scenario**: Find all notes about "authentication".
937
+
938
+ 1. **Use search bar**:
939
+ - Type "authentication" in search bar (top of left sidebar)
940
+ - Search debounces and calls `GET /api/search?q=authentication`
941
+
942
+ 2. **View results**:
943
+ - Results dropdown shows:
944
+ - "API Authentication Design" (title match, high score)
945
+ - "Security Best Practices" (body match, lower score)
946
+ - Snippets highlight matching text: "...API <mark>authentication</mark> strategy..."
947
+
948
+ 3. **Navigate to result**:
949
+ - Click on "API Authentication Design"
950
+ - Main pane renders the note
951
+
952
+ ### Workflow 4: Follow Wikilinks and View Backlinks
953
+
954
+ **Scenario**: Navigate from "API Authentication Design" to "Security Best Practices" via wikilink.
955
+
956
+ 1. **View note with wikilink**:
957
+ - Open "design/api-authentication.md"
958
+ - See wikilink: `[[Security Best Practices]]` rendered as clickable link
959
+
960
+ 2. **Click wikilink**:
961
+ - UI resolves slug: "security-best-practices"
962
+ - Navigates to "security-best-practices.md" (or prompts to create if not exists)
963
+
964
+ 3. **View backlinks**:
965
+ - Footer shows "Referenced by:" section
966
+ - Lists "design/api-authentication.md" as a backlink
967
+ - Click backlink to navigate back
968
+
969
+ ### Workflow 5: Rebuild Index
970
+
971
+ **Scenario**: Manually added notes outside the app, need to rebuild index.
972
+
973
+ 1. **Add notes manually**:
974
+ ```bash
975
+ # From terminal
976
+ cat > data/vaults/local-dev/notes/meeting-2025-01-16.md << 'EOF'
977
+ ---
978
+ title: Team Meeting Notes
979
+ tags: [meetings]
980
+ ---
981
+
982
+ # Team Meeting - Jan 16, 2025
983
+
984
+ ## Attendees
985
+ - Alice, Bob, Charlie
986
+
987
+ ## Topics
988
+ - Reviewed [[API Authentication Design]]
989
+ - Discussed deployment strategy
990
+ EOF
991
+ ```
992
+
993
+ 2. **Rebuild index**:
994
+ ```bash
995
+ curl -X POST http://localhost:8000/api/index/rebuild
996
+ ```
997
+
998
+ Response:
999
+ ```json
1000
+ {
1001
+ "status": "ok",
1002
+ "note_count": 3,
1003
+ "rebuilt_at": "2025-01-16T14:30:00Z"
1004
+ }
1005
+ ```
1006
+
1007
+ 3. **Verify in UI**:
1008
+ - Refresh browser
1009
+ - See new note in directory tree
1010
+ - Note is searchable
1011
+ - Backlink from "API Authentication Design" now shows "Team Meeting Notes"
1012
+
1013
+ ---
1014
+
1015
+ ## Hugging Face Space Deployment
1016
+
1017
+ Deploy the application to Hugging Face Spaces for multi-tenant access.
1018
+
1019
+ ### Prerequisites
1020
+
1021
+ 1. **HuggingFace account**: https://huggingface.co/join
1022
+ 2. **Create a Space**:
1023
+ - Go to https://huggingface.co/new-space
1024
+ - Choose "Docker" as SDK
1025
+ - Enable "OAuth" in Space settings
1026
+
1027
+ ### 1. Configure OAuth
1028
+
1029
+ In your Space settings, enable OAuth and note:
1030
+ - **Client ID**: `hf_oauth_client_id_xxxxx`
1031
+ - **Client Secret**: `hf_oauth_client_secret_xxxxx`
1032
+
1033
+ ### 2. Set Environment Variables
1034
+
1035
+ In Space settings β†’ Variables, add:
1036
+
1037
+ | Variable | Value | Visibility |
1038
+ |----------|-------|------------|
1039
+ | `MODE` | `space` | Public |
1040
+ | `JWT_SECRET_KEY` | (generate secure secret) | Secret |
1041
+ | `VAULT_BASE_DIR` | `/data/vaults` | Public |
1042
+ | `DB_PATH` | `/data/index.db` | Public |
1043
+ | `HF_OAUTH_CLIENT_ID` | (from OAuth settings) | Secret |
1044
+ | `HF_OAUTH_CLIENT_SECRET` | (from OAuth settings) | Secret |
1045
+ | `HF_SPACE_HOST` | `https://your-space.hf.space` | Public |
1046
+
1047
+ ### 3. Create Dockerfile
1048
+
1049
+ **`Dockerfile`**:
1050
+
1051
+ ```dockerfile
1052
+ FROM python:3.11-slim
1053
+
1054
+ WORKDIR /app
1055
+
1056
+ # Install Node.js for frontend build
1057
+ RUN apt-get update && apt-get install -y \
1058
+ nodejs \
1059
+ npm \
1060
+ && rm -rf /var/lib/apt/lists/*
1061
+
1062
+ # Copy backend
1063
+ COPY backend/ /app/backend/
1064
+ WORKDIR /app/backend
1065
+ RUN pip install --no-cache-dir -r requirements.txt
1066
+
1067
+ # Initialize database
1068
+ RUN python -m src.services.init_db
1069
+
1070
+ # Copy frontend
1071
+ COPY frontend/ /app/frontend/
1072
+ WORKDIR /app/frontend
1073
+
1074
+ # Build frontend
1075
+ RUN npm install && npm run build
1076
+
1077
+ # Copy built frontend to backend static directory
1078
+ RUN mkdir -p /app/backend/static && \
1079
+ cp -r /app/frontend/dist/* /app/backend/static/
1080
+
1081
+ # Set working directory back to backend
1082
+ WORKDIR /app/backend
1083
+
1084
+ # Create data directories
1085
+ RUN mkdir -p /data/vaults
1086
+
1087
+ # Expose ports
1088
+ EXPOSE 7860 8001
1089
+
1090
+ # Start backend (FastAPI + MCP HTTP)
1091
+ CMD ["sh", "-c", "uvicorn src.api.main:app --host 0.0.0.0 --port 7860 & python -m src.mcp.server --http --port 8001 & wait"]
1092
+ ```
1093
+
1094
+ ### 4. Configure Backend to Serve Frontend
1095
+
1096
+ **`backend/src/api/main.py`** (add static file serving):
1097
+
1098
+ ```python
1099
+ from fastapi import FastAPI
1100
+ from fastapi.staticfiles import StaticFiles
1101
+ from fastapi.responses import FileResponse
1102
+ import os
1103
+
1104
+ app = FastAPI(title="Obsidian Docs Viewer API")
1105
+
1106
+ # ... (existing API routes) ...
1107
+
1108
+ # Serve frontend static files
1109
+ static_dir = os.path.join(os.path.dirname(__file__), "../../static")
1110
+ if os.path.exists(static_dir):
1111
+ app.mount("/assets", StaticFiles(directory=f"{static_dir}/assets"), name="assets")
1112
+
1113
+ @app.get("/{full_path:path}")
1114
+ async def serve_frontend(full_path: str):
1115
+ """Serve frontend SPA (fallback to index.html for client-side routing)."""
1116
+ if full_path.startswith("api/"):
1117
+ # API routes handled by existing endpoints
1118
+ return {"error": "Not found"}
1119
+
1120
+ file_path = os.path.join(static_dir, full_path)
1121
+ if os.path.exists(file_path) and os.path.isfile(file_path):
1122
+ return FileResponse(file_path)
1123
+
1124
+ # Fallback to index.html for SPA routing
1125
+ return FileResponse(f"{static_dir}/index.html")
1126
+ ```
1127
+
1128
+ ### 5. Build Frontend with HF Space URL
1129
+
1130
+ Update **`frontend/.env.production`**:
1131
+
1132
+ ```bash
1133
+ VITE_API_BASE_URL=https://your-space.hf.space
1134
+ ```
1135
+
1136
+ Build:
1137
+
1138
+ ```bash
1139
+ cd frontend
1140
+ npm run build
1141
+ ```
1142
+
1143
+ ### 6. Push to HF Space
1144
+
1145
+ ```bash
1146
+ # From project root
1147
+ git init
1148
+ git add .
1149
+ git commit -m "Initial commit"
1150
+
1151
+ # Add HF Space remote
1152
+ git remote add space https://huggingface.co/spaces/your-username/your-space-name
1153
+ git push space main
1154
+ ```
1155
+
1156
+ ### 7. Verify Deployment
1157
+
1158
+ 1. Visit `https://your-space.hf.space`
1159
+ 2. Click "Sign in with Hugging Face"
1160
+ 3. Authorize the app
1161
+ 4. You should see the main UI with your isolated vault
1162
+
1163
+ ### 8. Configure MCP HTTP Client for HF Space
1164
+
1165
+ 1. Sign in to the Space and navigate to Settings
1166
+ 2. Click "Issue API Token"
1167
+ 3. Copy the JWT token
1168
+ 4. Configure your local MCP client:
1169
+
1170
+ **MCP HTTP client config**:
1171
+
1172
+ ```json
1173
+ {
1174
+ "mcp_endpoint": "https://your-space.hf.space:8001/mcp",
1175
+ "auth": {
1176
+ "type": "bearer",
1177
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
1178
+ }
1179
+ }
1180
+ ```
1181
+
1182
+ 5. Test from command line:
1183
+
1184
+ ```bash
1185
+ curl -X POST https://your-space.hf.space:8001/mcp/call \
1186
+ -H "Authorization: Bearer <your-token>" \
1187
+ -H "Content-Type: application/json" \
1188
+ -d '{
1189
+ "tool": "list_notes",
1190
+ "arguments": {}
1191
+ }'
1192
+ ```
1193
+
1194
+ ---
1195
+
1196
+ ## Troubleshooting
1197
+
1198
+ ### Common Errors
1199
+
1200
+ #### Error: `401 Unauthorized - Invalid token`
1201
+
1202
+ **Cause**: JWT token is invalid, expired, or missing.
1203
+
1204
+ **Solution**:
1205
+ 1. Check token expiration:
1206
+ ```bash
1207
+ # Decode JWT (requires `pyjwt` library)
1208
+ python -c "import jwt; print(jwt.decode('your-token', options={'verify_signature': False}))"
1209
+ ```
1210
+ 2. Issue a new token:
1211
+ ```bash
1212
+ curl -X POST http://localhost:8000/api/tokens
1213
+ ```
1214
+ 3. Update token in client configuration
1215
+
1216
+ #### Error: `400 Bad Request - Path traversal blocked`
1217
+
1218
+ **Cause**: Note path contains `..` or absolute path components.
1219
+
1220
+ **Example**: `write_note(path="../etc/passwd")`
1221
+
1222
+ **Solution**: Use relative paths only, no `..` allowed
1223
+ ```python
1224
+ # βœ… Correct
1225
+ write_note(path="design/api.md")
1226
+
1227
+ # ❌ Incorrect
1228
+ write_note(path="../design/api.md")
1229
+ write_note(path="/design/api.md")
1230
+ ```
1231
+
1232
+ #### Error: `409 Conflict - Version mismatch`
1233
+
1234
+ **Cause**: Note was updated by another user/agent since you last read it.
1235
+
1236
+ **Example**:
1237
+ 1. You load note with version 5
1238
+ 2. Claude updates it via MCP (version β†’ 6)
1239
+ 3. You click "Save" with `if_version: 5`
1240
+ 4. Server returns `409 Conflict`
1241
+
1242
+ **Solution**:
1243
+ 1. Reload the note to get latest version
1244
+ 2. Re-apply your changes
1245
+ 3. Save with new version number
1246
+
1247
+ **UI handling**:
1248
+ ```typescript
1249
+ // In NoteEditor component
1250
+ try {
1251
+ await updateNote(path, { body, if_version: currentVersion });
1252
+ } catch (error) {
1253
+ if (error.status === 409) {
1254
+ alert("This note changed since you opened it. Please reload before saving.");
1255
+ // Optionally: reload note automatically
1256
+ }
1257
+ }
1258
+ ```
1259
+
1260
+ #### Error: `413 Payload Too Large`
1261
+
1262
+ **Cause**: Note content exceeds 1 MiB (1,048,576 bytes).
1263
+
1264
+ **Solution**: Split large notes into smaller files
1265
+ ```bash
1266
+ # Check note size
1267
+ wc -c data/vaults/local-dev/large-note.md
1268
+
1269
+ # If > 1 MiB, split into multiple notes
1270
+ ```
1271
+
1272
+ #### Error: `403 Forbidden - Vault note limit exceeded`
1273
+
1274
+ **Cause**: Vault has reached 5,000 note limit.
1275
+
1276
+ **Solution**:
1277
+ 1. Delete unused notes
1278
+ 2. Archive old notes to external storage
1279
+ 3. Request limit increase (requires code change in `FR-008`)
1280
+
1281
+ #### Error: `SQLite database is locked`
1282
+
1283
+ **Cause**: Concurrent writes to SQLite database.
1284
+
1285
+ **Solution**:
1286
+ 1. Ensure only one backend process is running
1287
+ 2. Use connection pooling (in production)
1288
+ 3. Consider upgrading to PostgreSQL for high concurrency
1289
+
1290
+ **Quick fix**:
1291
+ ```bash
1292
+ # Kill all backend processes
1293
+ pkill -f uvicorn
1294
+ pkill -f "python -m src.mcp.server"
1295
+
1296
+ # Restart backend
1297
+ uvicorn src.api.main:app --reload
1298
+ ```
1299
+
1300
+ #### Error: `FTS5 extension not available`
1301
+
1302
+ **Cause**: SQLite was compiled without FTS5 support.
1303
+
1304
+ **Solution**:
1305
+ 1. Check if FTS5 is available:
1306
+ ```python
1307
+ import sqlite3
1308
+ conn = sqlite3.connect(":memory:")
1309
+ cursor = conn.cursor()
1310
+ cursor.execute("PRAGMA compile_options")
1311
+ print([row[0] for row in cursor.fetchall()])
1312
+ # Look for 'ENABLE_FTS5' in output
1313
+ ```
1314
+
1315
+ 2. If not available, reinstall SQLite with FTS5:
1316
+ ```bash
1317
+ # macOS (Homebrew)
1318
+ brew reinstall sqlite3
1319
+
1320
+ # Linux (Ubuntu/Debian)
1321
+ sudo apt-get install --reinstall libsqlite3-0
1322
+
1323
+ # Python (rebuild pysqlite3 with FTS5)
1324
+ pip install pysqlite3-binary
1325
+ ```
1326
+
1327
+ ### Debug Mode
1328
+
1329
+ Enable debug logging for troubleshooting:
1330
+
1331
+ **Backend** (`.env`):
1332
+ ```bash
1333
+ LOG_LEVEL=DEBUG
1334
+ ```
1335
+
1336
+ **Frontend** (`frontend/.env`):
1337
+ ```bash
1338
+ VITE_DEBUG=true
1339
+ ```
1340
+
1341
+ **View logs**:
1342
+
1343
+ ```bash
1344
+ # Backend logs (if using uvicorn)
1345
+ tail -f backend.log
1346
+
1347
+ # Or run with verbose output
1348
+ uvicorn src.api.main:app --reload --log-level debug
1349
+ ```
1350
+
1351
+ **Frontend logs**: Open browser DevTools β†’ Console
1352
+
1353
+ ### Health Checks
1354
+
1355
+ #### Check API Health
1356
+
1357
+ ```bash
1358
+ curl http://localhost:8000/api/health
1359
+ ```
1360
+
1361
+ Expected response:
1362
+ ```json
1363
+ {
1364
+ "status": "ok",
1365
+ "mode": "local",
1366
+ "vault_base_dir": "./data/vaults",
1367
+ "db_path": "./data/index.db"
1368
+ }
1369
+ ```
1370
+
1371
+ #### Check Index Health
1372
+
1373
+ ```bash
1374
+ curl http://localhost:8000/api/index/health
1375
+ ```
1376
+
1377
+ Expected response:
1378
+ ```json
1379
+ {
1380
+ "user_id": "local-dev",
1381
+ "note_count": 15,
1382
+ "last_full_rebuild": "2025-01-15T10:00:00Z",
1383
+ "last_incremental_update": "2025-01-16T14:30:00Z"
1384
+ }
1385
+ ```
1386
+
1387
+ #### Verify MCP Server
1388
+
1389
+ ```bash
1390
+ # Test STDIO server (requires MCP client)
1391
+ echo '{"tool": "list_notes", "arguments": {}}' | python -m src.mcp.server
1392
+
1393
+ # Test HTTP server
1394
+ curl -X POST http://localhost:8001/mcp/call \
1395
+ -H "Authorization: Bearer <token>" \
1396
+ -H "Content-Type: application/json" \
1397
+ -d '{"tool": "list_notes", "arguments": {}}'
1398
+ ```
1399
+
1400
+ ### Performance Profiling
1401
+
1402
+ #### Backend API Response Time
1403
+
1404
+ ```bash
1405
+ # Use curl with timing
1406
+ curl -w "@curl-format.txt" -o /dev/null -s http://localhost:8000/api/notes
1407
+
1408
+ # curl-format.txt:
1409
+ # time_namelookup: %{time_namelookup}s\n
1410
+ # time_connect: %{time_connect}s\n
1411
+ # time_appconnect: %{time_appconnect}s\n
1412
+ # time_total: %{time_total}s\n
1413
+ ```
1414
+
1415
+ Expected: `time_total < 2s` for directory listing
1416
+
1417
+ #### Search Query Performance
1418
+
1419
+ ```bash
1420
+ time curl -s "http://localhost:8000/api/search?q=authentication" > /dev/null
1421
+ ```
1422
+
1423
+ Expected: `< 1s` for vaults with up to 5,000 notes
1424
+
1425
+ #### Index Rebuild Time
1426
+
1427
+ ```bash
1428
+ time curl -X POST http://localhost:8000/api/index/rebuild
1429
+ ```
1430
+
1431
+ Expected: `< 30s` for 1,000 notes
1432
+
1433
+ ---
1434
+
1435
+ ## Next Steps
1436
+
1437
+ 1. **Read the full specification**: See `specs/001-obsidian-docs-viewer/spec.md`
1438
+ 2. **Review data model**: See `specs/001-obsidian-docs-viewer/data-model.md`
1439
+ 3. **Check API contracts**: See `specs/001-obsidian-docs-viewer/contracts/`
1440
+ 4. **Start implementation**: Run `/speckit.tasks` to generate implementation tasks
1441
+
1442
+ ---
1443
+
1444
+ ## Additional Resources
1445
+
1446
+ - **FastAPI Documentation**: https://fastapi.tiangolo.com/
1447
+ - **FastMCP Documentation**: https://github.com/jlowin/fastmcp
1448
+ - **React + shadcn/ui**: https://ui.shadcn.com/
1449
+ - **HuggingFace Spaces**: https://huggingface.co/docs/hub/spaces
1450
+ - **MCP Protocol**: https://modelcontextprotocol.io/
1451
+
1452
+ ---
1453
+
1454
+ **Last Updated**: 2025-11-15
1455
+ **Status**: Ready for implementation
specs/001-obsidian-docs-viewer/research.md ADDED
@@ -0,0 +1,1407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research: Multi-Tenant Obsidian-Like Docs Viewer
2
+
3
+ **Branch**: `001-obsidian-docs-viewer` | **Date**: 2025-11-15 | **Plan**: [plan.md](./plan.md)
4
+
5
+ ## Overview
6
+
7
+ This document captures technical research and decisions for the implementation of a multi-tenant Obsidian-like documentation viewer. Each section addresses a specific research topic from Phase 0 of the implementation plan.
8
+
9
+ ---
10
+
11
+ ## 1. FastMCP HTTP Transport Authentication (Bearer Token)
12
+
13
+ ### Decision
14
+
15
+ Use FastMCP's built-in `BearerAuth` mechanism with JWT token validation for HTTP transport authentication.
16
+
17
+ **Implementation approach**:
18
+ - Server: Configure FastMCP HTTP transport to accept `Authorization: Bearer <token>` header
19
+ - Client: Pass JWT token as string to `auth` parameter (FastMCP adds "Bearer" prefix automatically)
20
+ - Token format: JWT with claims `sub=user_id`, `exp=now+90days`, signed with `HS256` and server secret
21
+
22
+ ### Rationale
23
+
24
+ 1. **Native FastMCP support**: FastMCP provides first-class Bearer token authentication via `BearerAuth` class and string token shortcuts
25
+ 2. **Minimal configuration**: Client code is as simple as `Client("https://...", auth="<token>")`
26
+ 3. **Standard compliance**: Uses industry-standard `Authorization: Bearer` header pattern
27
+ 4. **Transport flexibility**: Works seamlessly with both HTTP and SSE (Server-Sent Events) transports
28
+ 5. **Non-interactive workflow**: Perfect for AI agents and service accounts that need programmatic access
29
+
30
+ ### Alternatives Considered
31
+
32
+ **Alternative 1: Custom header authentication**
33
+ - **Rejected**: FastMCP supports custom headers but requires manual implementation of auth logic
34
+ - **Why rejected**: More complex, loses benefit of FastMCP's built-in token handling and validation
35
+
36
+ **Alternative 2: OAuth flow for MCP clients**
37
+ - **Rejected**: FastMCP supports full OAuth 2.1 flows with browser-based authentication
38
+ - **Why rejected**: Overly complex for AI agent use case; requires interactive browser flow which doesn't suit MCP STDIO or programmatic access patterns
39
+
40
+ **Alternative 3: API key-based authentication**
41
+ - **Rejected**: Could use simple API keys instead of JWTs
42
+ - **Why rejected**: JWTs provide expiration, claims, and stateless validation; better security posture for multi-tenant system
43
+
44
+ ### Implementation Notes
45
+
46
+ **Server-side setup**:
47
+ ```python
48
+ from fastmcp import FastMCP
49
+ from fastmcp.server.auth import BearerAuthProvider
50
+ import jwt
51
+
52
+ # For token validation (if using external issuer)
53
+ auth_provider = BearerAuthProvider(
54
+ public_key="<RSA_PUBLIC_KEY>",
55
+ issuer="https://your-issuer.com",
56
+ audience="your-api"
57
+ )
58
+
59
+ # For internal JWT validation (our use case)
60
+ # Validate manually in middleware/dependency injection
61
+ def validate_jwt(token: str) -> str:
62
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
63
+ return payload["sub"] # user_id
64
+ ```
65
+
66
+ **Client-side setup**:
67
+ ```python
68
+ from fastmcp import Client
69
+
70
+ # Simplest approach - pass token as string
71
+ async with Client(
72
+ "https://fastmcp.cloud/mcp",
73
+ auth="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
74
+ ) as client:
75
+ await client.call_tool("list_notes", {})
76
+
77
+ # Explicit approach - use BearerAuth class
78
+ from fastmcp.client.auth import BearerAuth
79
+
80
+ async with Client(
81
+ "https://fastmcp.cloud/mcp",
82
+ auth=BearerAuth(token="eyJhbGci...")
83
+ ) as client:
84
+ await client.call_tool("list_notes", {})
85
+ ```
86
+
87
+ **Key points**:
88
+ - Do NOT include "Bearer" prefix when passing token - FastMCP adds it automatically
89
+ - Token validation happens on every MCP tool call via HTTP transport
90
+ - STDIO transport bypasses authentication (local development only)
91
+ - For HF Space deployment, combine with HF OAuth to issue user-specific JWTs
92
+
93
+ **References**:
94
+ - FastMCP Bearer Auth docs: https://gofastmcp.com/clients/auth/bearer
95
+ - FastMCP authentication patterns: https://gyliu513.github.io/jekyll/update/2025/08/12/fastmcp-auth-patterns.html
96
+
97
+ ---
98
+
99
+ ## 2. Hugging Face Space OAuth Integration
100
+
101
+ ### Decision
102
+
103
+ Use `huggingface_hub` library's built-in OAuth helpers (`attach_huggingface_oauth`, `parse_huggingface_oauth`) for zero-configuration OAuth integration in HF Spaces.
104
+
105
+ **Implementation approach**:
106
+ - Add `hf_oauth: true` to Space metadata in README.md
107
+ - Call `attach_huggingface_oauth(app)` to auto-register OAuth endpoints (`/oauth/huggingface/login`, `/oauth/huggingface/logout`, `/oauth/huggingface/callback`)
108
+ - Call `parse_huggingface_oauth(request)` in route handlers to extract authenticated user info
109
+ - Map HF username/ID to internal `user_id` for vault scoping
110
+
111
+ ### Rationale
112
+
113
+ 1. **Zero-configuration**: HF Spaces automatically injects OAuth environment variables (`OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_SCOPES`) when `hf_oauth: true` is set
114
+ 2. **Local dev friendly**: `parse_huggingface_oauth` returns mock user in local mode, enabling seamless development without OAuth setup
115
+ 3. **Minimal code**: Two function calls provide complete OAuth flow (login redirect, callback handling, session management)
116
+ 4. **First-class support**: Official HF library with guaranteed compatibility with Spaces platform
117
+ 5. **Standard OAuth 2.0**: Under the hood, implements industry-standard OAuth with PKCE
118
+
119
+ ### Alternatives Considered
120
+
121
+ **Alternative 1: Manual OAuth implementation**
122
+ - **Rejected**: Implement OAuth flow manually using `authlib` or `requests-oauthlib`
123
+ - **Why rejected**: Significantly more code, requires manual handling of PKCE, state validation, and token exchange; error-prone and loses HF Spaces auto-configuration
124
+
125
+ **Alternative 2: Third-party auth provider (Auth0, WorkOS)**
126
+ - **Rejected**: Use external auth service and connect HF as identity provider
127
+ - **Why rejected**: Adds unnecessary complexity and external dependencies for a system designed specifically for HF Spaces deployment
128
+
129
+ **Alternative 3: Session-based auth without OAuth**
130
+ - **Rejected**: Use simple username/password with cookie sessions
131
+ - **Why rejected**: Poor UX (users already have HF accounts), requires password management, doesn't leverage HF ecosystem integration
132
+
133
+ ### Implementation Notes
134
+
135
+ **Space configuration** (README.md frontmatter):
136
+ ```yaml
137
+ ---
138
+ title: Obsidian Docs Viewer
139
+ emoji: πŸ“š
140
+ colorFrom: blue
141
+ colorTo: green
142
+ sdk: docker
143
+ app_port: 8000
144
+ hf_oauth: true # <-- Enable OAuth
145
+ ---
146
+ ```
147
+
148
+ **Backend integration** (FastAPI):
149
+ ```python
150
+ from fastapi import FastAPI, Request
151
+ from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
152
+
153
+ app = FastAPI()
154
+
155
+ # Auto-register OAuth endpoints
156
+ attach_huggingface_oauth(app)
157
+
158
+ @app.get("/")
159
+ def index(request: Request):
160
+ oauth_info = parse_huggingface_oauth(request)
161
+
162
+ if oauth_info is None:
163
+ return {"message": "Not logged in", "login_url": "/oauth/huggingface/login"}
164
+
165
+ # Extract user info
166
+ user_id = oauth_info.user_info.preferred_username # or use 'sub' for UUID
167
+ display_name = oauth_info.user_info.name
168
+ avatar = oauth_info.user_info.picture
169
+
170
+ return {
171
+ "user_id": user_id,
172
+ "display_name": display_name,
173
+ "avatar": avatar
174
+ }
175
+
176
+ @app.get("/api/me")
177
+ def get_current_user(request: Request):
178
+ oauth_info = parse_huggingface_oauth(request)
179
+ if oauth_info is None:
180
+ raise HTTPException(status_code=401, detail="Not authenticated")
181
+
182
+ # Map to internal user model
183
+ user_id = oauth_info.user_info.preferred_username
184
+
185
+ # Initialize vault on first login if needed
186
+ vault_service.ensure_vault_exists(user_id)
187
+
188
+ return {
189
+ "user_id": user_id,
190
+ "hf_profile": {
191
+ "username": oauth_info.user_info.preferred_username,
192
+ "name": oauth_info.user_info.name,
193
+ "avatar": oauth_info.user_info.picture
194
+ }
195
+ }
196
+ ```
197
+
198
+ **Frontend integration** (React):
199
+ ```typescript
200
+ // Check auth status on app load
201
+ useEffect(() => {
202
+ fetch('/api/me')
203
+ .then(res => {
204
+ if (res.ok) return res.json();
205
+ throw new Error('Not authenticated');
206
+ })
207
+ .then(user => setCurrentUser(user))
208
+ .catch(() => window.location.href = '/oauth/huggingface/login');
209
+ }, []);
210
+ ```
211
+
212
+ **Key points**:
213
+ - `attach_huggingface_oauth` must be called BEFORE defining routes that need auth
214
+ - `parse_huggingface_oauth` returns `None` if not authenticated (check before accessing user_info)
215
+ - In local development, returns mocked user with deterministic username (e.g., "local-user")
216
+ - OAuth tokens/sessions are managed by `huggingface_hub` (stored in cookies)
217
+ - For API/MCP access, issue separate JWT after OAuth login via `POST /api/tokens`
218
+
219
+ **Environment variables** (auto-injected in HF Space):
220
+ - `OAUTH_CLIENT_ID`: Public client identifier
221
+ - `OAUTH_CLIENT_SECRET`: Secret for token exchange
222
+ - `OAUTH_SCOPES`: Space-specific scopes (typically `openid profile`)
223
+
224
+ **References**:
225
+ - HF OAuth docs: https://huggingface.co/docs/hub/spaces-oauth
226
+ - huggingface_hub API: https://huggingface.co/docs/huggingface_hub/en/package_reference/oauth
227
+
228
+ ---
229
+
230
+ ## 3. SQLite Schema Design for Multi-Index Storage
231
+
232
+ ### Decision
233
+
234
+ Use SQLite with FTS5 (Full-Text Search 5) for full-text indexing, plus separate regular tables for tags and link graph. Implement per-user isolation via `user_id` column in all tables.
235
+
236
+ **Schema approach**:
237
+ - **Full-text index**: FTS5 virtual table with `title` and `body` columns, using `porter` tokenizer for stemming
238
+ - **Tag index**: Regular table with `user_id`, `tag`, `note_path` (many-to-many relationship)
239
+ - **Link graph**: Regular table with `user_id`, `source_path`, `target_path`, `link_text`, `is_resolved`
240
+ - **Metadata index**: Regular table with `user_id`, `note_path`, `version`, `created`, `updated`, `title` for fast lookups
241
+ - **Index health**: Regular table with `user_id`, `note_count`, `last_full_rebuild`, `last_incremental_update`
242
+
243
+ ### Rationale
244
+
245
+ 1. **FTS5 performance**: Native full-text search with inverted index, sub-100ms query times for thousands of documents
246
+ 2. **Separate concerns**: Full-text (FTS5), tags (simple lookup), and links (graph traversal) have different query patterns; separate tables optimize each
247
+ 3. **Per-user isolation**: `user_id` column in all tables enables simple WHERE filtering without complex row-level security
248
+ 4. **External content pattern**: FTS5 with `content=''` (contentless) avoids storing document text twice (already in filesystem)
249
+ 5. **Version tracking**: Metadata table stores version counter for optimistic concurrency without polluting frontmatter
250
+ 6. **Prefix indexes**: FTS5 `prefix='2 3'` option enables fast autocomplete/prefix search
251
+
252
+ ### Alternatives Considered
253
+
254
+ **Alternative 1: Single FTS5 table for everything**
255
+ - **Rejected**: Store tags and links as UNINDEXED columns in FTS5 table
256
+ - **Why rejected**: FTS5 is optimized for full-text, not structured data; complex queries (e.g., "all notes with tag X") would require scanning all rows; tags/links don't benefit from tokenization
257
+
258
+ **Alternative 2: Separate SQLite database per user**
259
+ - **Rejected**: One `.db` file per user instead of `user_id` column
260
+ - **Why rejected**: Increases file I/O overhead, complicates connection pooling, harder to implement global admin queries (e.g., total user count)
261
+
262
+ **Alternative 3: PostgreSQL with pg_trgm or RUM indexes**
263
+ - **Rejected**: Use full Postgres instead of SQLite
264
+ - **Why rejected**: Overkill for single-server deployment, adds deployment complexity, SQLite is sufficient for target scale (5,000 notes/user, 10 concurrent users)
265
+
266
+ **Alternative 4: In-memory index only**
267
+ - **Rejected**: Build inverted index in Python dict, no persistence
268
+ - **Why rejected**: Slow startup (rebuild on every restart), no durability, doesn't scale beyond single process
269
+
270
+ ### Implementation Notes
271
+
272
+ **Schema definition**:
273
+ ```sql
274
+ -- Metadata index (fast lookups, version tracking)
275
+ CREATE TABLE IF NOT EXISTS note_metadata (
276
+ user_id TEXT NOT NULL,
277
+ note_path TEXT NOT NULL,
278
+ version INTEGER NOT NULL DEFAULT 1,
279
+ title TEXT NOT NULL,
280
+ created TEXT NOT NULL, -- ISO 8601 timestamp
281
+ updated TEXT NOT NULL, -- ISO 8601 timestamp
282
+ PRIMARY KEY (user_id, note_path)
283
+ );
284
+ CREATE INDEX idx_metadata_user ON note_metadata(user_id);
285
+ CREATE INDEX idx_metadata_updated ON note_metadata(user_id, updated DESC);
286
+
287
+ -- Full-text search index (FTS5, contentless)
288
+ CREATE VIRTUAL TABLE IF NOT EXISTS note_fts USING fts5(
289
+ user_id UNINDEXED,
290
+ note_path UNINDEXED,
291
+ title,
292
+ body,
293
+ content='', -- Contentless (we don't store the actual text)
294
+ tokenize='porter unicode61', -- Stemming + Unicode support
295
+ prefix='2 3' -- Prefix indexes for autocomplete
296
+ );
297
+
298
+ -- Tag index (many-to-many: notes <-> tags)
299
+ CREATE TABLE IF NOT EXISTS note_tags (
300
+ user_id TEXT NOT NULL,
301
+ note_path TEXT NOT NULL,
302
+ tag TEXT NOT NULL,
303
+ PRIMARY KEY (user_id, note_path, tag)
304
+ );
305
+ CREATE INDEX idx_tags_user_tag ON note_tags(user_id, tag);
306
+ CREATE INDEX idx_tags_user_path ON note_tags(user_id, note_path);
307
+
308
+ -- Link graph (outgoing links from notes)
309
+ CREATE TABLE IF NOT EXISTS note_links (
310
+ user_id TEXT NOT NULL,
311
+ source_path TEXT NOT NULL,
312
+ target_path TEXT, -- NULL if unresolved
313
+ link_text TEXT NOT NULL, -- Original [[link text]]
314
+ is_resolved INTEGER NOT NULL DEFAULT 0, -- Boolean: 0=broken, 1=resolved
315
+ PRIMARY KEY (user_id, source_path, link_text)
316
+ );
317
+ CREATE INDEX idx_links_user_source ON note_links(user_id, source_path);
318
+ CREATE INDEX idx_links_user_target ON note_links(user_id, target_path);
319
+ CREATE INDEX idx_links_unresolved ON note_links(user_id, is_resolved);
320
+
321
+ -- Index health tracking
322
+ CREATE TABLE IF NOT EXISTS index_health (
323
+ user_id TEXT PRIMARY KEY,
324
+ note_count INTEGER NOT NULL DEFAULT 0,
325
+ last_full_rebuild TEXT, -- ISO 8601 timestamp
326
+ last_incremental_update TEXT -- ISO 8601 timestamp
327
+ );
328
+ ```
329
+
330
+ **Query patterns**:
331
+ ```python
332
+ # Full-text search with ranking
333
+ cursor.execute("""
334
+ SELECT
335
+ note_path,
336
+ title,
337
+ bm25(note_fts, 3.0, 1.0) AS rank -- Title weight=3, body weight=1
338
+ FROM note_fts
339
+ WHERE user_id = ? AND note_fts MATCH ?
340
+ ORDER BY rank DESC
341
+ LIMIT 50
342
+ """, (user_id, query))
343
+
344
+ # Get all notes with a specific tag
345
+ cursor.execute("""
346
+ SELECT DISTINCT note_path, title
347
+ FROM note_tags t
348
+ JOIN note_metadata m USING (user_id, note_path)
349
+ WHERE t.user_id = ? AND t.tag = ?
350
+ ORDER BY m.updated DESC
351
+ """, (user_id, tag))
352
+
353
+ # Get backlinks for a note
354
+ cursor.execute("""
355
+ SELECT DISTINCT l.source_path, m.title
356
+ FROM note_links l
357
+ JOIN note_metadata m ON l.user_id = m.user_id AND l.source_path = m.note_path
358
+ WHERE l.user_id = ? AND l.target_path = ?
359
+ ORDER BY m.updated DESC
360
+ """, (user_id, target_path))
361
+
362
+ # Get all unresolved links for UI display
363
+ cursor.execute("""
364
+ SELECT source_path, link_text
365
+ FROM note_links
366
+ WHERE user_id = ? AND is_resolved = 0
367
+ """, (user_id,))
368
+ ```
369
+
370
+ **Incremental update strategy**:
371
+ 1. On `write_note`: Delete all existing rows for `(user_id, note_path)`, then insert new rows
372
+ 2. Use transactions to ensure atomicity (delete old + insert new = single atomic operation)
373
+ 3. Update `index_health.last_incremental_update` on every write
374
+
375
+ **Full rebuild strategy**:
376
+ 1. Delete all index rows for `user_id`
377
+ 2. Scan all `.md` files in vault directory
378
+ 3. Parse each file and insert into all indexes
379
+ 4. Update `index_health.note_count` and `last_full_rebuild`
380
+
381
+ **Key points**:
382
+ - FTS5 with `content=''` is contentless - we must manually INSERT/DELETE rows (no automatic synchronization)
383
+ - Use `porter` tokenizer for English stemming (search "running" matches "run")
384
+ - `bm25()` function provides relevance ranking (better than simple MATCH count)
385
+ - Prefix indexes (`prefix='2 3'`) enable fast `MATCH 'prefix*'` queries
386
+ - `UNINDEXED` columns in FTS5 are retrievable but not searchable (good for IDs)
387
+
388
+ **References**:
389
+ - SQLite FTS5 docs: https://www.sqlite.org/fts5.html
390
+ - FTS5 structure deep dive: https://darksi.de/13.sqlite-fts5-structure/
391
+
392
+ ---
393
+
394
+ ## 4. Wikilink Normalization and Resolution
395
+
396
+ ### Decision
397
+
398
+ Implement case-insensitive normalized slug matching with deterministic ambiguity resolution based on Obsidian's behavior.
399
+
400
+ **Normalization algorithm**:
401
+ 1. Extract link text from `[[link text]]`
402
+ 2. Normalize: lowercase, replace spaces/hyphens/underscores with single dash, remove non-alphanumeric except dashes
403
+ 3. Match normalized slug against normalized filename stems AND normalized frontmatter titles
404
+ 4. If multiple matches: prefer same-folder match, then lexicographically smallest path
405
+
406
+ **Slug normalization function**:
407
+ ```python
408
+ import re
409
+
410
+ def normalize_slug(text: str) -> str:
411
+ """Normalize text to slug for case-insensitive matching."""
412
+ text = text.lower()
413
+ text = re.sub(r'[\s_]+', '-', text) # Spaces/underscores β†’ dash
414
+ text = re.sub(r'[^a-z0-9-]', '', text) # Keep only alphanumeric + dash
415
+ text = re.sub(r'-+', '-', text) # Collapse multiple dashes
416
+ return text.strip('-')
417
+ ```
418
+
419
+ ### Rationale
420
+
421
+ 1. **Obsidian compatibility**: Matches Obsidian's link resolution behavior (case-insensitive, flexible matching)
422
+ 2. **User-friendly**: Users don't need to remember exact case or spacing (e.g., `[[API Design]]` matches `api-design.md`)
423
+ 3. **Deterministic**: Same-folder preference + lexicographic tiebreaker ensures consistent resolution
424
+ 4. **Efficient indexing**: Normalized slugs can be pre-computed and indexed for O(1) lookup
425
+ 5. **Graceful fallback**: Broken links are tracked and displayed distinctly in UI
426
+
427
+ ### Alternatives Considered
428
+
429
+ **Alternative 1: Exact case-sensitive matching**
430
+ - **Rejected**: Require `[[exact-filename]]` to match `exact-filename.md`
431
+ - **Why rejected**: Brittle user experience, doesn't match Obsidian behavior, forces users to remember exact capitalization
432
+
433
+ **Alternative 2: Fuzzy matching (Levenshtein distance)**
434
+ - **Rejected**: Use string similarity to find "close enough" matches
435
+ - **Why rejected**: Non-deterministic, slower, can match wrong notes ("Setup" matches "Startup"), confusing UX
436
+
437
+ **Alternative 3: Path-based links only**
438
+ - **Rejected**: Require full paths like `[[guides/setup]]` instead of `[[Setup]]`
439
+ - **Why rejected**: Verbose, doesn't match Obsidian's short-link paradigm, poor UX for large vaults
440
+
441
+ **Alternative 4: UUID-based links**
442
+ - **Rejected**: Use unique IDs like `[[#uuid-123]]` for stable references
443
+ - **Why rejected**: Not human-readable, requires additional metadata, doesn't match Obsidian convention
444
+
445
+ ### Implementation Notes
446
+
447
+ **Resolution algorithm** (priority order):
448
+ ```python
449
+ def resolve_wikilink(user_id: str, link_text: str, current_note_folder: str) -> str | None:
450
+ """Resolve wikilink to note path, or None if unresolved."""
451
+ normalized = normalize_slug(link_text)
452
+
453
+ # Build candidate index: normalized_slug -> [note_paths]
454
+ candidates = defaultdict(list)
455
+
456
+ # Scan all notes for this user
457
+ for note in list_all_notes(user_id):
458
+ # Match against filename stem
459
+ stem = Path(note.path).stem
460
+ if normalize_slug(stem) == normalized:
461
+ candidates[note.path].append(note.path)
462
+
463
+ # Match against frontmatter title
464
+ if note.title and normalize_slug(note.title) == normalized:
465
+ candidates[note.path].append(note.path)
466
+
467
+ if not candidates:
468
+ return None # Unresolved link
469
+
470
+ paths = list(set(candidates.keys())) # Deduplicate
471
+
472
+ if len(paths) == 1:
473
+ return paths[0] # Unique match
474
+
475
+ # Ambiguity resolution
476
+ # 1. Prefer same-folder match
477
+ same_folder = [p for p in paths if Path(p).parent == current_note_folder]
478
+ if same_folder:
479
+ return sorted(same_folder)[0] # Lexicographic tiebreaker
480
+
481
+ # 2. Lexicographically smallest path
482
+ return sorted(paths)[0]
483
+ ```
484
+
485
+ **Index optimization**:
486
+ Pre-compute normalized slugs for all notes and store in `note_metadata` table:
487
+ ```sql
488
+ ALTER TABLE note_metadata ADD COLUMN normalized_title_slug TEXT;
489
+ ALTER TABLE note_metadata ADD COLUMN normalized_path_slug TEXT;
490
+ CREATE INDEX idx_metadata_title_slug ON note_metadata(user_id, normalized_title_slug);
491
+ CREATE INDEX idx_metadata_path_slug ON note_metadata(user_id, normalized_path_slug);
492
+ ```
493
+
494
+ **Link extraction from Markdown**:
495
+ ```python
496
+ import re
497
+
498
+ def extract_wikilinks(markdown_body: str) -> list[str]:
499
+ """Extract all wikilink texts from markdown body."""
500
+ pattern = r'\[\[([^\]]+)\]\]'
501
+ return re.findall(pattern, markdown_body)
502
+ ```
503
+
504
+ **Update link graph on write**:
505
+ ```python
506
+ def update_link_graph(user_id: str, note_path: str, body: str):
507
+ """Update outgoing links and backlinks for a note."""
508
+ current_folder = str(Path(note_path).parent)
509
+
510
+ # Extract wikilinks from body
511
+ link_texts = extract_wikilinks(body)
512
+
513
+ # Delete old links from this note
514
+ db.execute("DELETE FROM note_links WHERE user_id=? AND source_path=?",
515
+ (user_id, note_path))
516
+
517
+ # Insert new links
518
+ for link_text in link_texts:
519
+ target_path = resolve_wikilink(user_id, link_text, current_folder)
520
+ is_resolved = 1 if target_path else 0
521
+
522
+ db.execute("""
523
+ INSERT INTO note_links (user_id, source_path, target_path, link_text, is_resolved)
524
+ VALUES (?, ?, ?, ?, ?)
525
+ """, (user_id, note_path, target_path, link_text, is_resolved))
526
+ ```
527
+
528
+ **UI rendering**:
529
+ ```typescript
530
+ // Transform wikilinks to clickable links in rendered Markdown
531
+ function transformWikilinks(markdown: string, linkIndex: Record<string, string>): string {
532
+ return markdown.replace(/\[\[([^\]]+)\]\]/g, (match, linkText) => {
533
+ const targetPath = linkIndex[linkText];
534
+
535
+ if (targetPath) {
536
+ // Resolved link
537
+ return `<a href="#/note/${encodeURIComponent(targetPath)}" class="wikilink">${linkText}</a>`;
538
+ } else {
539
+ // Broken link
540
+ return `<a href="#/create/${encodeURIComponent(linkText)}" class="wikilink broken">${linkText}</a>`;
541
+ }
542
+ });
543
+ }
544
+ ```
545
+
546
+ **Key points**:
547
+ - Pre-compute and cache slug mappings for performance (avoid re-scanning on every link resolution)
548
+ - Same-folder preference matches Obsidian's behavior (local references are intuitive)
549
+ - Lexicographic tiebreaker ensures determinism (same input always resolves to same output)
550
+ - Track `is_resolved` flag to identify broken links for UI warnings/affordances
551
+ - Update entire link graph on every note write (incremental update, not rebuild)
552
+
553
+ **Edge cases**:
554
+ - Empty link text `[[]]` - ignore/skip
555
+ - Nested brackets `[[foo [[bar]]]]` - naive regex fails; use proper parser or limit to non-nested pattern
556
+ - Link with pipe `[[link|display]]` - out of scope for MVP; treat entire string as link text
557
+
558
+ ---
559
+
560
+ ## 5. React + shadcn/ui Directory Tree Component
561
+
562
+ ### Decision
563
+
564
+ Use **shadcn-extension Tree View** component with built-in virtualization via `@tanstack/react-virtual` for directory tree rendering.
565
+
566
+ **Component choice**: `shadcn-extension` Tree View
567
+ - **Installation**: Available at https://shadcn-extension.vercel.app/docs/tree-view
568
+ - **Features**: Virtualization, accordion-based expand/collapse, keyboard navigation, selection, custom icons
569
+ - **Why this one**: Only shadcn tree component with native virtualization support; critical for large vaults (5,000 notes)
570
+
571
+ ### Rationale
572
+
573
+ 1. **Virtualization required**: 5,000 notes would create 5,000+ DOM nodes without virtualization; TanStack Virtual renders only visible rows (~20-50 nodes)
574
+ 2. **Performance**: Virtualization reduces initial render from ~2s to <100ms for large trees
575
+ 3. **shadcn ecosystem**: Consistent styling with other shadcn/ui components (Button, ScrollArea, etc.)
576
+ 4. **Accessibility**: Built on Radix UI primitives with keyboard navigation and ARIA support
577
+ 5. **Customizable**: Supports custom icons per node, expand/collapse callbacks, and selection handling
578
+
579
+ ### Alternatives Considered
580
+
581
+ **Alternative 1: MrLightful's shadcn Tree View**
582
+ - **Rejected**: Feature-rich component with drag-and-drop, custom icons
583
+ - **Why rejected**: No virtualization support; would cause performance issues with 1,000+ notes
584
+
585
+ **Alternative 2: Neigebaie's shadcn Tree View**
586
+ - **Rejected**: Advanced features (multi-select, checkboxes, context menus)
587
+ - **Why rejected**: No virtualization; overkill for simple directory browsing
588
+
589
+ **Alternative 3: react-arborist**
590
+ - **Rejected**: Powerful tree view library with virtualization and drag-and-drop
591
+ - **Why rejected**: Not part of shadcn ecosystem; requires custom styling to match UI; heavier dependency
592
+
593
+ **Alternative 4: Custom implementation with react-window**
594
+ - **Rejected**: Build tree view from scratch using `react-window` or `react-virtual`
595
+ - **Why rejected**: Significant development effort; reinventing the wheel; shadcn-extension already provides this
596
+
597
+ ### Implementation Notes
598
+
599
+ **Installation**:
600
+ ```bash
601
+ npx shadcn add https://shadcn-extension.vercel.app/registry/tree-view.json
602
+ ```
603
+
604
+ **Component usage**:
605
+ ```tsx
606
+ import { Tree, TreeNode } from "@/components/ui/tree-view";
607
+
608
+ interface FileTreeNode {
609
+ id: string;
610
+ name: string;
611
+ path: string;
612
+ isFolder: boolean;
613
+ children?: FileTreeNode[];
614
+ }
615
+
616
+ function DirectoryTree({ vault, onSelectNote }: Props) {
617
+ // Transform vault notes into tree structure
618
+ const treeData = useMemo(() => buildTree(vault.notes), [vault.notes]);
619
+
620
+ return (
621
+ <Tree
622
+ data={treeData}
623
+ onSelectChange={(nodeId) => {
624
+ const node = findNode(treeData, nodeId);
625
+ if (!node.isFolder) {
626
+ onSelectNote(node.path);
627
+ }
628
+ }}
629
+ // Virtualization is enabled by default
630
+ className="w-full h-full"
631
+ />
632
+ );
633
+ }
634
+
635
+ // Transform flat list of note paths into hierarchical tree
636
+ function buildTree(notes: Note[]): TreeNode[] {
637
+ const root: Map<string, TreeNode> = new Map();
638
+
639
+ for (const note of notes) {
640
+ const parts = note.path.split('/');
641
+ let currentLevel = root;
642
+
643
+ for (let i = 0; i < parts.length; i++) {
644
+ const part = parts[i];
645
+ const isFile = i === parts.length - 1;
646
+ const id = parts.slice(0, i + 1).join('/');
647
+
648
+ if (!currentLevel.has(part)) {
649
+ currentLevel.set(part, {
650
+ id,
651
+ name: isFile ? note.title : part,
652
+ path: id,
653
+ isFolder: !isFile,
654
+ children: isFile ? undefined : new Map()
655
+ });
656
+ }
657
+
658
+ if (!isFile) {
659
+ currentLevel = currentLevel.get(part)!.children!;
660
+ }
661
+ }
662
+ }
663
+
664
+ return Array.from(root.values());
665
+ }
666
+ ```
667
+
668
+ **Styling for Obsidian-like appearance**:
669
+ ```css
670
+ /* Custom styles for file tree */
671
+ .tree-view-node {
672
+ @apply py-1 px-2 rounded hover:bg-accent transition-colors;
673
+ }
674
+
675
+ .tree-view-node.selected {
676
+ @apply bg-accent text-accent-foreground font-medium;
677
+ }
678
+
679
+ .tree-view-folder {
680
+ @apply flex items-center gap-2;
681
+ }
682
+
683
+ .tree-view-file {
684
+ @apply flex items-center gap-2 text-sm;
685
+ }
686
+
687
+ /* Icons */
688
+ .folder-icon {
689
+ @apply text-yellow-500;
690
+ }
691
+
692
+ .file-icon {
693
+ @apply text-gray-500;
694
+ }
695
+ ```
696
+
697
+ **Collapsible behavior**:
698
+ ```tsx
699
+ // Track expanded folders in state
700
+ const [expanded, setExpanded] = useState<Set<string>>(new Set(['root']));
701
+
702
+ <Tree
703
+ data={treeData}
704
+ expanded={expanded}
705
+ onExpandedChange={setExpanded}
706
+ // Auto-expand to selected note's folder
707
+ onSelectChange={(nodeId) => {
708
+ const path = nodeId.split('/');
709
+ const folders = path.slice(0, -1);
710
+ setExpanded(new Set([...expanded, ...folders]));
711
+ }}
712
+ />
713
+ ```
714
+
715
+ **Key points**:
716
+ - Virtualization is automatic with shadcn-extension Tree View (uses TanStack Virtual internally)
717
+ - Must transform flat note list into nested tree structure (use `buildTree` utility)
718
+ - Track expanded/collapsed state separately from tree data
719
+ - Custom icons per node type (folder vs file) via `icon` prop
720
+ - Use `ScrollArea` component from shadcn to wrap tree for custom scrollbars
721
+
722
+ **Performance targets**:
723
+ - Initial render: <200ms for 5,000 notes
724
+ - Expand/collapse: <50ms per folder
725
+ - Search filter: <100ms to re-render filtered tree
726
+
727
+ **Accessibility**:
728
+ - Keyboard navigation: Arrow keys to navigate, Enter to select, Space to expand/collapse
729
+ - Screen reader support: ARIA labels for folders/files, expand/collapse state
730
+ - Focus management: Visible focus indicators, focus restoration after selection
731
+
732
+ ---
733
+
734
+ ## 6. Optimistic Concurrency Implementation
735
+
736
+ ### Decision
737
+
738
+ Use **version counter** (integer) stored in SQLite index with `if_version` parameter for UI writes. Implement **ETag-like validation** via `If-Match` header in HTTP API.
739
+
740
+ **Approach**:
741
+ - Version counter: Integer field in `note_metadata` table, incremented on every write
742
+ - UI writes: Include `if_version: N` in `PUT /api/notes/{path}` body
743
+ - Server validation: Compare `if_version` with current version; return `409 Conflict` if mismatch
744
+ - MCP writes: No version checking (last-write-wins)
745
+ - ETag header: Return `ETag: "<version>"` in `GET /api/notes/{path}` response for HTTP compliance
746
+
747
+ ### Rationale
748
+
749
+ 1. **Simple implementation**: Integer counter is trivial to increment and compare
750
+ 2. **Explicit versioning**: Version in request body makes intent clear ("I'm updating version 5")
751
+ 3. **Database-backed**: Version persists in index, not frontmatter (keeps note content clean)
752
+ 4. **HTTP-friendly**: Can expose as ETag header for standards compliance
753
+ 5. **Performance**: Integer comparison is O(1), no hash computation needed
754
+
755
+ ### Alternatives Considered
756
+
757
+ **Alternative 1: ETag with content hash**
758
+ - **Rejected**: Compute MD5/SHA hash of note content, return as ETag header
759
+ - **Why rejected**: Hash computation on every read adds latency; version counter is sufficient and faster
760
+
761
+ **Alternative 2: Last-Modified timestamps**
762
+ - **Rejected**: Use `updated` timestamp + `If-Unmodified-Since` header
763
+ - **Why rejected**: Timestamp precision issues (SQLite stores ISO strings, not microsecond precision); race conditions if multiple updates within same second
764
+
765
+ **Alternative 3: Version in frontmatter**
766
+ - **Rejected**: Store `version: 5` in YAML frontmatter
767
+ - **Why rejected**: Pollutes user-facing metadata; incrementing version requires parsing/re-serializing frontmatter; harder to manage
768
+
769
+ **Alternative 4: MVCC (Multi-Version Concurrency Control)**
770
+ - **Rejected**: Store multiple versions of each note, allow rollback
771
+ - **Why rejected**: Complex implementation; storage overhead; out of scope for MVP (no version history requirement)
772
+
773
+ ### Implementation Notes
774
+
775
+ **Schema addition**:
776
+ ```sql
777
+ -- Version counter in note_metadata table
778
+ ALTER TABLE note_metadata ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
779
+ ```
780
+
781
+ **API endpoint implementation**:
782
+ ```python
783
+ from fastapi import HTTPException, Header
784
+ from typing import Optional
785
+
786
+ @app.put("/api/notes/{path}")
787
+ async def update_note(
788
+ path: str,
789
+ body: NoteUpdateRequest,
790
+ user_id: str = Depends(get_current_user),
791
+ if_match: Optional[str] = Header(None) # ETag header support
792
+ ):
793
+ # Get current version
794
+ current = get_note_metadata(user_id, path)
795
+
796
+ # Check if_version in body OR If-Match header
797
+ expected_version = body.if_version or (int(if_match.strip('"')) if if_match else None)
798
+
799
+ if expected_version is not None and current.version != expected_version:
800
+ raise HTTPException(
801
+ status_code=409,
802
+ detail={
803
+ "error": "version_conflict",
804
+ "message": "Note was updated by another process",
805
+ "current_version": current.version,
806
+ "provided_version": expected_version
807
+ }
808
+ )
809
+
810
+ # Update note and increment version
811
+ new_version = current.version + 1
812
+ save_note(user_id, path, body.content)
813
+ update_metadata(user_id, path, version=new_version, updated=now())
814
+
815
+ return {
816
+ "status": "ok",
817
+ "version": new_version
818
+ }
819
+
820
+ @app.get("/api/notes/{path}")
821
+ async def get_note(
822
+ path: str,
823
+ user_id: str = Depends(get_current_user)
824
+ ):
825
+ note = load_note(user_id, path)
826
+
827
+ return JSONResponse(
828
+ content={
829
+ "path": note.path,
830
+ "title": note.title,
831
+ "metadata": note.metadata,
832
+ "body": note.body,
833
+ "version": note.version,
834
+ "created": note.created,
835
+ "updated": note.updated
836
+ },
837
+ headers={
838
+ "ETag": f'"{note.version}"', # Expose version as ETag
839
+ "Cache-Control": "no-cache" # Prevent stale reads
840
+ }
841
+ )
842
+ ```
843
+
844
+ **Frontend implementation** (React):
845
+ ```typescript
846
+ interface Note {
847
+ path: string;
848
+ title: string;
849
+ body: string;
850
+ version: number;
851
+ // ...
852
+ }
853
+
854
+ async function saveNote(note: Note, newBody: string) {
855
+ try {
856
+ const response = await fetch(`/api/notes/${encodeURIComponent(note.path)}`, {
857
+ method: 'PUT',
858
+ headers: {
859
+ 'Content-Type': 'application/json',
860
+ 'Authorization': `Bearer ${token}`,
861
+ // Option 1: Version in body
862
+ },
863
+ body: JSON.stringify({
864
+ body: newBody,
865
+ if_version: note.version // Optimistic concurrency check
866
+ })
867
+ });
868
+
869
+ if (response.status === 409) {
870
+ const error = await response.json();
871
+ alert(`Conflict: Note was updated (current version: ${error.current_version}). Please reload and try again.`);
872
+ return;
873
+ }
874
+
875
+ const updated = await response.json();
876
+ // Update local state with new version
877
+ setNote({ ...note, body: newBody, version: updated.version });
878
+
879
+ } catch (error) {
880
+ console.error('Save failed:', error);
881
+ }
882
+ }
883
+ ```
884
+
885
+ **MCP tool implementation** (last-write-wins):
886
+ ```python
887
+ @mcp.tool()
888
+ async def write_note(path: str, body: str, title: str = None) -> dict:
889
+ """Write note via MCP (no version checking)."""
890
+ user_id = get_user_from_context()
891
+
892
+ # Load existing note to get current version (if exists)
893
+ try:
894
+ current = get_note_metadata(user_id, path)
895
+ new_version = current.version + 1
896
+ except NotFoundError:
897
+ new_version = 1 # New note
898
+
899
+ # Write without version check (last-write-wins)
900
+ save_note(user_id, path, body, title)
901
+ update_metadata(user_id, path, version=new_version, updated=now())
902
+
903
+ return {"status": "ok", "path": path, "version": new_version}
904
+ ```
905
+
906
+ **Conflict resolution UI**:
907
+ ```tsx
908
+ function ConflictDialog({ currentVersion, serverVersion }: Props) {
909
+ return (
910
+ <Alert variant="destructive">
911
+ <AlertTitle>Version Conflict</AlertTitle>
912
+ <AlertDescription>
913
+ This note was updated while you were editing (version {currentVersion} β†’ {serverVersion}).
914
+ <div className="mt-4 space-x-2">
915
+ <Button onClick={reload}>Reload and Discard Changes</Button>
916
+ <Button variant="outline" onClick={saveAsCopy}>Save as Copy</Button>
917
+ </div>
918
+ </AlertDescription>
919
+ </Alert>
920
+ );
921
+ }
922
+ ```
923
+
924
+ **Key points**:
925
+ - Version counter starts at 1 for new notes, increments on every write
926
+ - HTTP API returns `409 Conflict` with detailed error message (current vs provided version)
927
+ - ETag header is optional but recommended for HTTP standards compliance
928
+ - MCP writes skip version check (AI agents don't need optimistic concurrency)
929
+ - Frontend shows clear error message with options: reload, save as copy, or manual merge
930
+
931
+ **Performance considerations**:
932
+ - Version check is single integer comparison (O(1))
933
+ - No need to read entire note content for validation
934
+ - Version update is atomic (SQLite transaction)
935
+
936
+ **References**:
937
+ - Optimistic concurrency patterns: https://event-driven.io/en/how_to_use_etag_header_for_optimistic_concurrency/
938
+ - HTTP conditional requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests
939
+
940
+ ---
941
+
942
+ ## 7. Markdown Frontmatter Parsing with Fallback
943
+
944
+ ### Decision
945
+
946
+ Use `python-frontmatter` library for YAML parsing with try-except wrapper to handle malformed frontmatter gracefully. Implement fallback strategy: malformed YAML β†’ treat as no frontmatter, extract title from first `# Heading` or filename stem.
947
+
948
+ **Parsing approach**:
949
+ ```python
950
+ import frontmatter
951
+ from pathlib import Path
952
+
953
+ def parse_note(file_path: str) -> dict:
954
+ """Parse note with frontmatter fallback."""
955
+ try:
956
+ # Attempt to parse frontmatter
957
+ post = frontmatter.load(file_path)
958
+ metadata = dict(post.metadata)
959
+ body = post.content
960
+
961
+ except (yaml.scanner.ScannerError, yaml.parser.ParserError) as e:
962
+ # Malformed YAML - treat entire file as body
963
+ with open(file_path, 'r', encoding='utf-8') as f:
964
+ full_text = f.read()
965
+
966
+ metadata = {}
967
+ body = full_text
968
+
969
+ # Log warning for debugging
970
+ logger.warning(f"Malformed frontmatter in {file_path}: {e}")
971
+
972
+ # Extract title (priority: frontmatter > first H1 > filename)
973
+ title = (
974
+ metadata.get('title') or
975
+ extract_first_heading(body) or
976
+ Path(file_path).stem
977
+ )
978
+
979
+ return {
980
+ 'title': title,
981
+ 'metadata': metadata,
982
+ 'body': body
983
+ }
984
+
985
+ def extract_first_heading(markdown: str) -> str | None:
986
+ """Extract first # Heading from markdown body."""
987
+ match = re.match(r'^#\s+(.+)$', markdown, re.MULTILINE)
988
+ return match.group(1).strip() if match else None
989
+ ```
990
+
991
+ ### Rationale
992
+
993
+ 1. **Graceful degradation**: Malformed YAML doesn't break the system; note is still readable
994
+ 2. **User-friendly**: Non-technical users may create invalid YAML; system should be forgiving
995
+ 3. **Simple implementation**: Try-except wrapper is minimal code; `python-frontmatter` handles valid cases
996
+ 4. **Fallback chain**: Title extraction has clear priority order (explicit > inferred > default)
997
+ 5. **Debugging support**: Log warnings for malformed YAML so admins can fix source files
998
+
999
+ ### Alternatives Considered
1000
+
1001
+ **Alternative 1: Strict parsing (fail on malformed YAML)**
1002
+ - **Rejected**: Raise error and reject note with invalid frontmatter
1003
+ - **Why rejected**: Poor UX; users may accidentally create invalid YAML (e.g., unquoted colons); breaks read-first workflow
1004
+
1005
+ **Alternative 2: TOML or JSON frontmatter**
1006
+ - **Rejected**: Use `+++` TOML or `{{{ }}}` JSON delimiters instead of YAML
1007
+ - **Why rejected**: Obsidian uses YAML exclusively; compatibility is critical
1008
+
1009
+ **Alternative 3: Lenient YAML parser**
1010
+ - **Rejected**: Use `ruamel.yaml` with error recovery instead of PyYAML
1011
+ - **Why rejected**: Adds complexity; `python-frontmatter` uses PyYAML internally; fallback strategy is simpler
1012
+
1013
+ **Alternative 4: Partial frontmatter extraction**
1014
+ - **Rejected**: Parse valid keys, ignore malformed keys
1015
+ - **Why rejected**: Difficult to implement; unclear semantics (which keys are valid?); safer to treat all as invalid
1016
+
1017
+ ### Implementation Notes
1018
+
1019
+ **Error types to catch**:
1020
+ ```python
1021
+ import yaml
1022
+
1023
+ try:
1024
+ post = frontmatter.load(file_path)
1025
+ except (
1026
+ yaml.scanner.ScannerError, # Invalid YAML syntax (e.g., unmatched quotes)
1027
+ yaml.parser.ParserError, # Invalid YAML structure
1028
+ UnicodeDecodeError # Non-UTF8 file encoding
1029
+ ) as e:
1030
+ # Fallback to no frontmatter
1031
+ pass
1032
+ ```
1033
+
1034
+ **Common malformed YAML examples**:
1035
+ ```yaml
1036
+ ---
1037
+ title: API Design: Overview # Unquoted colon - INVALID
1038
+ tags: [backend, api]
1039
+ ---
1040
+
1041
+ ---
1042
+ title: "Setup Guide
1043
+ description: Installation steps # Unclosed quote - INVALID
1044
+ ---
1045
+
1046
+ ---
1047
+ title: Indented incorrectly # Bad indentation - INVALID
1048
+ tags:
1049
+ - frontend
1050
+ ---
1051
+ ```
1052
+
1053
+ **Auto-fix on write** (optional enhancement):
1054
+ ```python
1055
+ def save_note(user_id: str, path: str, title: str, metadata: dict, body: str):
1056
+ """Save note with valid frontmatter (auto-fix on write)."""
1057
+ # Merge title into metadata
1058
+ metadata['title'] = title
1059
+
1060
+ # Create Post object with validated metadata
1061
+ post = frontmatter.Post(body, **metadata)
1062
+
1063
+ # Serialize with valid YAML
1064
+ file_content = frontmatter.dumps(post)
1065
+
1066
+ # Write to file
1067
+ full_path = get_vault_path(user_id) / path
1068
+ full_path.write_text(file_content, encoding='utf-8')
1069
+ ```
1070
+
1071
+ **Title extraction regex**:
1072
+ ```python
1073
+ def extract_first_heading(markdown: str) -> str | None:
1074
+ """Extract first # Heading (must be H1, not H2/H3)."""
1075
+ # Match # Heading (H1 only, not ## or ###)
1076
+ pattern = r'^#\s+(.+?)(?:\s+\{[^}]+\})?\s*$'
1077
+ match = re.search(pattern, markdown, re.MULTILINE)
1078
+
1079
+ if match:
1080
+ heading = match.group(1).strip()
1081
+ # Remove Markdown formatting (e.g., **bold**, *italic*)
1082
+ heading = re.sub(r'[*_`]', '', heading)
1083
+ return heading
1084
+
1085
+ return None
1086
+ ```
1087
+
1088
+ **Fallback priority**:
1089
+ 1. `metadata.get('title')` - Explicit frontmatter title
1090
+ 2. `extract_first_heading(body)` - First `# Heading` in body
1091
+ 3. `Path(file_path).stem` - Filename without `.md` extension
1092
+
1093
+ **Validation warnings**:
1094
+ ```python
1095
+ # Add validation warnings to API response
1096
+ if malformed_frontmatter:
1097
+ warnings.append({
1098
+ "type": "malformed_frontmatter",
1099
+ "message": "YAML frontmatter is invalid and was ignored",
1100
+ "line": error.problem_mark.line if hasattr(error, 'problem_mark') else None
1101
+ })
1102
+ ```
1103
+
1104
+ **UI display for warnings**:
1105
+ ```tsx
1106
+ function NoteViewer({ note, warnings }: Props) {
1107
+ return (
1108
+ <div>
1109
+ {warnings.map(w => (
1110
+ <Alert key={w.type} variant="warning">
1111
+ <AlertTitle>Warning</AlertTitle>
1112
+ <AlertDescription>{w.message}</AlertDescription>
1113
+ </Alert>
1114
+ ))}
1115
+ <Markdown>{note.body}</Markdown>
1116
+ </div>
1117
+ );
1118
+ }
1119
+ ```
1120
+
1121
+ **Key points**:
1122
+ - Always catch `yaml.scanner.ScannerError` and `yaml.parser.ParserError` from PyYAML
1123
+ - Log warnings with file path and error details for admin debugging
1124
+ - Prefer graceful fallback over strict validation (read-first workflow)
1125
+ - Auto-fix on write ensures newly saved notes have valid frontmatter
1126
+ - Extract title from first `# Heading`, not `## Subheading` (H1 only)
1127
+
1128
+ **References**:
1129
+ - python-frontmatter docs: https://python-frontmatter.readthedocs.io/
1130
+ - PyYAML error handling: https://pyyaml.org/wiki/PyYAMLDocumentation
1131
+
1132
+ ---
1133
+
1134
+ ## 8. JWT Token Management in React
1135
+
1136
+ ### Decision
1137
+
1138
+ Use **hybrid approach**: Store short-lived access token (JWT) in **memory** (React state/context), store long-lived refresh token in **HttpOnly cookie** (server-managed). For MVP without refresh tokens, store JWT in **memory only** with 90-day expiration.
1139
+
1140
+ **MVP approach** (no refresh tokens):
1141
+ - Store JWT in React Context (memory)
1142
+ - Token expires after 90 days (long-lived)
1143
+ - On app load, check if token exists in memory β†’ if not, redirect to login
1144
+ - No localStorage (XSS vulnerability mitigation)
1145
+ - No refresh flow (acceptable for MVP scale)
1146
+
1147
+ **Production approach** (with refresh tokens):
1148
+ - Access token: 15-minute expiration, stored in memory
1149
+ - Refresh token: 90-day expiration, stored in HttpOnly cookie
1150
+ - Automatic refresh before access token expires
1151
+ - Refresh endpoint: `POST /api/auth/refresh` (validates cookie, issues new access token)
1152
+
1153
+ ### Rationale
1154
+
1155
+ 1. **XSS protection**: Memory storage prevents JavaScript-based token theft (localStorage is vulnerable to XSS)
1156
+ 2. **CSRF protection**: HttpOnly cookies can't be accessed by JS, mitigating CSRF (when combined with SameSite attribute)
1157
+ 3. **Industry best practice (2025)**: Hybrid approach is current security standard for React SPAs
1158
+ 4. **Acceptable UX**: User logs in once per 90 days (or once per session if memory-only)
1159
+ 5. **No additional dependencies**: Built-in React Context API handles memory storage
1160
+
1161
+ ### Alternatives Considered
1162
+
1163
+ **Alternative 1: localStorage for JWT**
1164
+ - **Rejected**: Store JWT in `localStorage.setItem('token', jwt)`
1165
+ - **Why rejected**: Vulnerable to XSS attacks (malicious scripts can read localStorage); still in OWASP Top 10; unacceptable security risk for multi-tenant system
1166
+
1167
+ **Alternative 2: sessionStorage for JWT**
1168
+ - **Rejected**: Store JWT in `sessionStorage` (cleared on tab close)
1169
+ - **Why rejected**: Poor UX (re-login on every new tab); still vulnerable to XSS
1170
+
1171
+ **Alternative 3: Cookies for both access and refresh tokens**
1172
+ - **Rejected**: Store JWT in regular cookies (not HttpOnly)
1173
+ - **Why rejected**: Vulnerable to CSRF if not using HttpOnly; vulnerable to XSS if accessible to JS
1174
+
1175
+ **Alternative 4: No token storage (re-authenticate on every request)**
1176
+ - **Rejected**: Use HF OAuth on every API call
1177
+ - **Why rejected**: Unacceptable latency; OAuth flow is slow (~2-3s per request)
1178
+
1179
+ ### Implementation Notes
1180
+
1181
+ **MVP implementation** (memory-only, 90-day JWT):
1182
+
1183
+ ```typescript
1184
+ // Auth context (memory storage)
1185
+ import { createContext, useContext, useState, useEffect } from 'react';
1186
+
1187
+ interface AuthContextType {
1188
+ token: string | null;
1189
+ setToken: (token: string) => void;
1190
+ logout: () => void;
1191
+ }
1192
+
1193
+ const AuthContext = createContext<AuthContextType | null>(null);
1194
+
1195
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
1196
+ const [token, setTokenState] = useState<string | null>(null);
1197
+
1198
+ const setToken = (newToken: string) => {
1199
+ setTokenState(newToken);
1200
+ };
1201
+
1202
+ const logout = () => {
1203
+ setTokenState(null);
1204
+ window.location.href = '/oauth/huggingface/logout';
1205
+ };
1206
+
1207
+ return (
1208
+ <AuthContext.Provider value={{ token, setToken, logout }}>
1209
+ {children}
1210
+ </AuthContext.Provider>
1211
+ );
1212
+ }
1213
+
1214
+ export function useAuth() {
1215
+ const context = useContext(AuthContext);
1216
+ if (!context) throw new Error('useAuth must be used within AuthProvider');
1217
+ return context;
1218
+ }
1219
+ ```
1220
+
1221
+ ```typescript
1222
+ // App initialization (fetch token after OAuth)
1223
+ function App() {
1224
+ const { token, setToken } = useAuth();
1225
+ const [loading, setLoading] = useState(true);
1226
+
1227
+ useEffect(() => {
1228
+ // Check if authenticated via HF OAuth
1229
+ fetch('/api/me')
1230
+ .then(res => {
1231
+ if (!res.ok) throw new Error('Not authenticated');
1232
+ return res.json();
1233
+ })
1234
+ .then(user => {
1235
+ // Issue JWT token for API access
1236
+ return fetch('/api/tokens', { method: 'POST' });
1237
+ })
1238
+ .then(res => res.json())
1239
+ .then(data => {
1240
+ setToken(data.token);
1241
+ setLoading(false);
1242
+ })
1243
+ .catch(() => {
1244
+ // Redirect to OAuth login
1245
+ window.location.href = '/oauth/huggingface/login';
1246
+ });
1247
+ }, []);
1248
+
1249
+ if (loading) return <div>Loading...</div>;
1250
+
1251
+ return <MainApp />;
1252
+ }
1253
+ ```
1254
+
1255
+ ```typescript
1256
+ // API client (include token in headers)
1257
+ async function apiRequest(endpoint: string, options: RequestInit = {}) {
1258
+ const { token } = useAuth();
1259
+
1260
+ const response = await fetch(`/api${endpoint}`, {
1261
+ ...options,
1262
+ headers: {
1263
+ 'Content-Type': 'application/json',
1264
+ 'Authorization': `Bearer ${token}`,
1265
+ ...options.headers
1266
+ }
1267
+ });
1268
+
1269
+ if (response.status === 401) {
1270
+ // Token expired or invalid
1271
+ logout();
1272
+ throw new Error('Unauthorized');
1273
+ }
1274
+
1275
+ return response;
1276
+ }
1277
+ ```
1278
+
1279
+ **Production implementation** (with refresh tokens):
1280
+
1281
+ ```typescript
1282
+ // Token refresh logic
1283
+ let refreshPromise: Promise<string> | null = null;
1284
+
1285
+ async function refreshAccessToken(): Promise<string> {
1286
+ // Prevent multiple concurrent refresh calls
1287
+ if (refreshPromise) return refreshPromise;
1288
+
1289
+ refreshPromise = fetch('/api/auth/refresh', {
1290
+ method: 'POST',
1291
+ credentials: 'include' // Send HttpOnly cookie
1292
+ })
1293
+ .then(res => {
1294
+ if (!res.ok) throw new Error('Refresh failed');
1295
+ return res.json();
1296
+ })
1297
+ .then(data => {
1298
+ setToken(data.access_token);
1299
+ refreshPromise = null;
1300
+ return data.access_token;
1301
+ })
1302
+ .catch(err => {
1303
+ refreshPromise = null;
1304
+ logout();
1305
+ throw err;
1306
+ });
1307
+
1308
+ return refreshPromise;
1309
+ }
1310
+
1311
+ // Automatic refresh before token expires
1312
+ useEffect(() => {
1313
+ if (!token) return;
1314
+
1315
+ // Parse token to get expiration
1316
+ const payload = JSON.parse(atob(token.split('.')[1]));
1317
+ const expiresAt = payload.exp * 1000;
1318
+ const now = Date.now();
1319
+ const refreshAt = expiresAt - (5 * 60 * 1000); // 5 minutes before expiry
1320
+
1321
+ const timeoutId = setTimeout(() => {
1322
+ refreshAccessToken();
1323
+ }, refreshAt - now);
1324
+
1325
+ return () => clearTimeout(timeoutId);
1326
+ }, [token]);
1327
+ ```
1328
+
1329
+ **Backend refresh endpoint**:
1330
+ ```python
1331
+ from fastapi import Cookie, HTTPException
1332
+
1333
+ @app.post("/api/auth/refresh")
1334
+ async def refresh_token(
1335
+ refresh_token: str = Cookie(None, httponly=True, samesite='strict')
1336
+ ):
1337
+ if not refresh_token:
1338
+ raise HTTPException(status_code=401, detail="No refresh token")
1339
+
1340
+ # Validate refresh token
1341
+ try:
1342
+ payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=["HS256"])
1343
+ user_id = payload["sub"]
1344
+ except jwt.ExpiredSignatureError:
1345
+ raise HTTPException(status_code=401, detail="Refresh token expired")
1346
+ except jwt.InvalidTokenError:
1347
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
1348
+
1349
+ # Issue new access token (15-minute expiry)
1350
+ access_token = create_jwt(user_id, expiration_minutes=15)
1351
+
1352
+ return {"access_token": access_token, "token_type": "bearer"}
1353
+ ```
1354
+
1355
+ **Key points**:
1356
+ - Memory storage = token lost on page refresh (re-login required) β†’ acceptable for MVP
1357
+ - HttpOnly cookies cannot be accessed by JavaScript (XSS protection)
1358
+ - Set `SameSite=strict` on refresh token cookie (CSRF protection)
1359
+ - Refresh token rotation: issue new refresh token on each refresh (advanced security)
1360
+ - Use `credentials: 'include'` in fetch to send HttpOnly cookies
1361
+ - Parse JWT client-side to schedule refresh (or use server-sent expiry hint)
1362
+
1363
+ **Security checklist**:
1364
+ - βœ… Access token in memory (XSS-resistant)
1365
+ - βœ… Refresh token in HttpOnly cookie (XSS-resistant)
1366
+ - βœ… SameSite=strict on cookies (CSRF-resistant)
1367
+ - βœ… HTTPS required (prevent MITM)
1368
+ - βœ… Short access token expiry (limit blast radius)
1369
+ - βœ… Token refresh before expiry (seamless UX)
1370
+ - βœ… Logout clears both tokens
1371
+
1372
+ **MVP vs Production tradeoff**:
1373
+ - **MVP**: 90-day JWT in memory β†’ simpler, acceptable for hackathon/PoC
1374
+ - **Production**: 15-min access + 90-day refresh β†’ better security, more complex
1375
+
1376
+ **References**:
1377
+ - JWT storage best practices: https://www.descope.com/blog/post/developer-guide-jwt-storage
1378
+ - HttpOnly cookies vs localStorage: https://www.wisp.blog/blog/understanding-token-storage-local-storage-vs-httponly-cookies
1379
+ - React authentication patterns: https://marmelab.com/blog/2020/07/02/manage-your-jwt-react-admin-authentication-in-memory.html
1380
+
1381
+ ---
1382
+
1383
+ ## Summary of Key Decisions
1384
+
1385
+ | Topic | Decision | Primary Rationale |
1386
+ |-------|----------|-------------------|
1387
+ | **FastMCP Auth** | Bearer token with JWT validation | Native FastMCP support, minimal config, standard-compliant |
1388
+ | **HF OAuth** | `attach_huggingface_oauth` + `parse_huggingface_oauth` | Zero-config, local dev friendly, official HF support |
1389
+ | **SQLite Schema** | FTS5 for full-text + separate tables for tags/links | Performance, per-user isolation, optimized query patterns |
1390
+ | **Wikilink Resolution** | Case-insensitive slug matching + same-folder preference | Obsidian compatibility, user-friendly, deterministic |
1391
+ | **Directory Tree** | shadcn-extension Tree View with virtualization | Only shadcn option with virtualization for 5K+ notes |
1392
+ | **Optimistic Concurrency** | Version counter in SQLite + `if_version` param | Simple, fast, HTTP-friendly, no content hashing overhead |
1393
+ | **Frontmatter Parsing** | `python-frontmatter` + fallback to no frontmatter | Graceful degradation, user-friendly error handling |
1394
+ | **JWT Management** | Memory storage (MVP) or memory + HttpOnly cookie (prod) | XSS protection, industry best practice (2025) |
1395
+
1396
+ ---
1397
+
1398
+ ## Next Steps
1399
+
1400
+ With research complete, proceed to **Phase 1: Data Model & Contracts**:
1401
+
1402
+ 1. Create `data-model.md` with detailed Pydantic models and SQLite schemas
1403
+ 2. Create `contracts/http-api.yaml` with OpenAPI 3.1 specification
1404
+ 3. Create `contracts/mcp-tools.json` with MCP tool schemas (JSON Schema format)
1405
+ 4. Create `quickstart.md` with setup instructions and testing workflows
1406
+
1407
+ After Phase 1, run `/speckit.tasks` to generate dependency-ordered implementation tasks.
specs/001-obsidian-docs-viewer/tasks.md ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Implementation Tasks: Multi-Tenant Obsidian-Like Docs Viewer
2
+
3
+ **Feature Branch**: `001-obsidian-docs-viewer`
4
+ **Created**: 2025-11-15
5
+ **Status**: Ready for Implementation
6
+
7
+ ## Implementation Strategy
8
+
9
+ **MVP = User Story 1 (AI Agent Writes) + User Story 2 (Human Reads UI)**
10
+
11
+ The MVP delivers immediate value:
12
+ - AI agents (via MCP STDIO) can create and maintain documentation
13
+ - Humans can browse, search, and read documentation in the web UI
14
+ - Full-text search, wikilinks, tags, and backlinks work end-to-end
15
+
16
+ **Post-MVP enhancements**:
17
+ - User Story 3: Human editing with version conflict detection
18
+ - User Story 4: Multi-tenant HF OAuth for production deployment
19
+ - User Story 5: Advanced search ranking and index health monitoring
20
+
21
+ ---
22
+
23
+ ## Phase 1: Setup
24
+
25
+ **Goal**: Initialize project structure, dependencies, and database schema.
26
+
27
+ - [ ] [T001] Create project directory structure at /home/wolfe/Projects/Document-MCP
28
+ - [ ] [T002] [P] Create backend/src/models/ directory and __init__.py
29
+ - [ ] [T003] [P] Create backend/src/services/ directory and __init__.py
30
+ - [ ] [T004] [P] Create backend/src/api/routes/ directory and __init__.py
31
+ - [ ] [T005] [P] Create backend/src/api/middleware/ directory and __init__.py
32
+ - [ ] [T006] [P] Create backend/src/mcp/ directory and __init__.py
33
+ - [ ] [T007] [P] Create backend/tests/unit/ directory and __init__.py
34
+ - [ ] [T008] [P] Create backend/tests/integration/ directory and __init__.py
35
+ - [ ] [T009] [P] Create backend/tests/contract/ directory and __init__.py
36
+ - [ ] [T010] [P] Create frontend/src/components/ui/ directory
37
+ - [ ] [T011] [P] Create frontend/src/pages/ directory
38
+ - [ ] [T012] [P] Create frontend/src/services/ directory
39
+ - [ ] [T013] [P] Create frontend/src/lib/ directory
40
+ - [ ] [T014] [P] Create frontend/src/types/ directory
41
+ - [ ] [T015] [P] Create frontend/tests/unit/ directory
42
+ - [ ] [T016] [P] Create frontend/tests/e2e/ directory
43
+ - [ ] [T017] [P] Create data/vaults/ directory for runtime vault storage
44
+ - [ ] [T018] Create backend/pyproject.toml with dependencies: fastapi, fastmcp, python-frontmatter, pyjwt, huggingface_hub, uvicorn
45
+ - [ ] [T019] Create frontend/package.json with dependencies: react, vite, typescript, shadcn/ui, react-markdown
46
+ - [ ] [T020] Create frontend/vite.config.ts with proxy to backend API
47
+ - [ ] [T021] Create .env.example with JWT_SECRET_KEY, HF_OAUTH_CLIENT_ID, HF_OAUTH_CLIENT_SECRET, VAULT_BASE_PATH
48
+ - [ ] [T022] Create .gitignore to exclude data/, .env, node_modules/, __pycache__, dist/
49
+ - [ ] [T023] Create backend/src/services/database.py with SQLite initialization DDL from data-model.md
50
+ - [ ] [T024] Execute SQLite schema initialization (note_metadata, note_fts, note_tags, note_links, index_health tables)
51
+
52
+ ---
53
+
54
+ ## Phase 2: Foundational
55
+
56
+ **Goal**: Build core infrastructure required by all user stories.
57
+
58
+ - [ ] [T025] Create backend/src/services/config.py to load env vars: JWT_SECRET_KEY, VAULT_BASE_PATH, HF_OAUTH_CLIENT_ID, HF_OAUTH_CLIENT_SECRET
59
+ - [ ] [T026] [P] Create backend/src/models/user.py with User and HFProfile Pydantic models from data-model.md
60
+ - [ ] [T027] [P] Create backend/src/models/note.py with Note, NoteMetadata, NoteCreate, NoteUpdate, NoteSummary Pydantic models from data-model.md
61
+ - [ ] [T028] [P] Create backend/src/models/index.py with Wikilink, Tag, IndexHealth Pydantic models from data-model.md
62
+ - [ ] [T029] [P] Create backend/src/models/search.py with SearchResult, SearchRequest Pydantic models from data-model.md
63
+ - [ ] [T030] [P] Create backend/src/models/auth.py with TokenResponse, JWTPayload Pydantic models from data-model.md
64
+ - [ ] [T031] [P] Create frontend/src/types/user.ts with User and HFProfile TypeScript types from data-model.md
65
+ - [ ] [T032] [P] Create frontend/src/types/note.ts with Note, NoteMetadata, NoteSummary, NoteCreateRequest, NoteUpdateRequest TypeScript types from data-model.md
66
+ - [ ] [T033] [P] Create frontend/src/types/search.ts with SearchResult, Tag, IndexHealth TypeScript types from data-model.md
67
+ - [ ] [T034] [P] Create frontend/src/types/auth.ts with TokenResponse, APIError TypeScript types from data-model.md
68
+ - [ ] [T035] Create backend/src/services/vault.py with VaultService class: path validation, sanitization (sanitize_path function from data-model.md), vault directory initialization
69
+ - [ ] [T036] Create backend/src/services/auth.py with AuthService class: JWT creation (create_jwt), validation (validate_jwt), placeholder for HF OAuth
70
+ - [ ] [T037] Create backend/src/api/middleware/auth_middleware.py with extract_user_id_from_jwt function to validate Authorization: Bearer header
71
+ - [ ] [T038] Create backend/src/api/middleware/error_handlers.py with FastAPI exception handlers for 400, 401, 403, 404, 409, 413, 500 from http-api.yaml
72
+
73
+ ---
74
+
75
+ ## Phase 3: User Story 1 - AI Agent Writes (P1)
76
+
77
+ **Goal**: Enable AI agents to write/update docs via MCP STDIO, with automatic indexing.
78
+
79
+ - [ ] [T039] [US1] Create backend/src/services/vault.py VaultService.read_note method: read file, parse frontmatter with python-frontmatter, extract title (priority: frontmatter > H1 > filename stem)
80
+ - [ ] [T040] [US1] Create backend/src/services/vault.py VaultService.write_note method: validate path/content, create parent dirs, write frontmatter + body, return absolute path
81
+ - [ ] [T041] [US1] Create backend/src/services/vault.py VaultService.delete_note method: validate path, remove file, handle FileNotFoundError
82
+ - [ ] [T042] [US1] Create backend/src/services/vault.py VaultService.list_notes method: walk vault tree, filter by folder param, return paths and titles
83
+ - [ ] [T043] [US1] Create backend/src/services/indexer.py IndexerService class with db connection management
84
+ - [ ] [T044] [US1] Create backend/src/services/indexer.py IndexerService.index_note method: delete old rows for (user_id, note_path), insert into note_metadata, note_fts, note_tags, note_links
85
+ - [ ] [T045] [US1] Create backend/src/services/indexer.py IndexerService.extract_wikilinks method: regex pattern \[\[([^\]]+)\]\] to extract link_text from body
86
+ - [ ] [T046] [US1] Create backend/src/services/indexer.py IndexerService.resolve_wikilinks method: normalize slug (data-model.md algorithm), match against normalized_title_slug and normalized_path_slug, prefer same-folder, update is_resolved
87
+ - [ ] [T047] [US1] Create backend/src/services/indexer.py IndexerService.increment_version method: get current version or default to 1, increment, return new version
88
+ - [ ] [T048] [US1] Create backend/src/services/indexer.py IndexerService.update_index_health method: update note_count, last_incremental_update timestamp
89
+ - [ ] [T049] [US1] Create backend/src/services/indexer.py IndexerService.delete_note_index method: delete rows from all index tables, update backlinks to set is_resolved=false
90
+ - [ ] [T050] [US1] Create backend/src/mcp/server.py FastMCP server initialization with name="obsidian-docs-viewer"
91
+ - [ ] [T051] [US1] Create backend/src/mcp/server.py list_notes MCP tool: call VaultService.list_notes, return [{path, title, last_modified}]
92
+ - [ ] [T052] [US1] Create backend/src/mcp/server.py read_note MCP tool: call VaultService.read_note, return {path, title, metadata, body}
93
+ - [ ] [T053] [US1] Create backend/src/mcp/server.py write_note MCP tool: call VaultService.write_note, then IndexerService.index_note, return {status: "ok", path}
94
+ - [ ] [T054] [US1] Create backend/src/mcp/server.py delete_note MCP tool: call VaultService.delete_note, then IndexerService.delete_note_index, return {status: "ok"}
95
+ - [ ] [T055] [US1] Create backend/src/mcp/server.py search_notes MCP tool: query note_fts with bm25 ranking (3.0 title weight, 1.0 body weight), add recency bonus, return [{path, title, snippet}]
96
+ - [ ] [T056] [US1] Create backend/src/mcp/server.py get_backlinks MCP tool: query note_links WHERE target_path=?, join note_metadata, return [{path, title}]
97
+ - [ ] [T057] [US1] Create backend/src/mcp/server.py get_tags MCP tool: query note_tags GROUP BY tag, return [{tag, count}]
98
+ - [ ] [T058] [US1] Create backend/src/mcp/server.py STDIO transport mode: if __name__ == "__main__", run FastMCP with stdio transport for local development
99
+ - [ ] [T059] [US1] Add recency bonus calculation to search_notes: +1.0 for updated in last 7 days, +0.5 for last 30 days, 0 otherwise
100
+
101
+ ---
102
+
103
+ ## Phase 4: User Story 2 - Human Reads UI (P1)
104
+
105
+ **Goal**: Web UI for browsing, searching, and reading notes with wikilinks and backlinks.
106
+
107
+ - [ ] [T060] [US2] Create backend/src/api/routes/notes.py with GET /api/notes endpoint: call VaultService.list_notes, return NoteSummary[] from http-api.yaml
108
+ - [ ] [T061] [US2] Create backend/src/api/routes/notes.py with GET /api/notes/{path} endpoint: URL-decode path, call VaultService.read_note, return Note from http-api.yaml
109
+ - [ ] [T062] [US2] Create backend/src/api/routes/search.py with GET /api/search endpoint: call IndexerService search with query param, return SearchResult[] from http-api.yaml
110
+ - [ ] [T063] [US2] Create backend/src/api/routes/search.py with GET /api/backlinks/{path} endpoint: URL-decode path, query note_links, return BacklinkResult[] from http-api.yaml
111
+ - [ ] [T064] [US2] Create backend/src/api/routes/search.py with GET /api/tags endpoint: query note_tags, return Tag[] from http-api.yaml
112
+ - [ ] [T065] [US2] Create backend/src/api/main.py FastAPI app with CORS middleware, mount routes, include error handlers
113
+ - [ ] [T066] [US2] Create frontend/src/services/api.ts API client with fetch wrapper: add Authorization: Bearer header, handle JSON responses, throw APIError on non-200
114
+ - [ ] [T067] [US2] Create frontend/src/services/api.ts listNotes function: GET /api/notes?folder=, return NoteSummary[]
115
+ - [ ] [T068] [US2] Create frontend/src/services/api.ts getNote function: GET /api/notes/{encodeURIComponent(path)}, return Note
116
+ - [ ] [T069] [US2] Create frontend/src/services/api.ts searchNotes function: GET /api/search?q=, return SearchResult[]
117
+ - [ ] [T070] [US2] Create frontend/src/services/api.ts getBacklinks function: GET /api/backlinks/{encodeURIComponent(path)}, return BacklinkResult[]
118
+ - [ ] [T071] [US2] Create frontend/src/services/api.ts getTags function: GET /api/tags, return Tag[]
119
+ - [ ] [T072] [US2] Create frontend/src/lib/wikilink.ts with extractWikilinks function: regex /\[\[([^\]]+)\]\]/g
120
+ - [ ] [T073] [US2] Create frontend/src/lib/wikilink.ts with normalizeSlug function: lowercase, replace spaces/underscores with dash, strip non-alphanumeric
121
+ - [ ] [T074] [US2] Create frontend/src/lib/markdown.ts with react-markdown config: code highlighting, wikilink custom renderer
122
+ - [ ] [T075] [US2] Initialize shadcn/ui in frontend/: run npx shadcn-ui@latest init, select default theme
123
+ - [ ] [T076] [US2] Install shadcn/ui components: ScrollArea, Button, Input, Card, Badge
124
+ - [ ] [T077] [US2] Create frontend/src/components/DirectoryTree.tsx: recursive tree view with collapsible folders, leaf items for notes, onClick handler to load note
125
+ - [ ] [T078] [US2] Create frontend/src/components/NoteViewer.tsx: render note title, metadata (tags as badges, timestamps), react-markdown body with wikilink links, backlinks section in footer
126
+ - [ ] [T079] [US2] Create frontend/src/components/SearchBar.tsx: Input with debounced onChange (300ms), dropdown results with onClick to navigate to note
127
+ - [ ] [T080] [US2] Create frontend/src/pages/App.tsx: two-pane layout (left: DirectoryTree + SearchBar in ScrollArea, right: NoteViewer), state management for selected note path
128
+ - [ ] [T081] [US2] Add wikilink click handler in NoteViewer: onClick [[link]] β†’ normalizeSlug β†’ API lookup β†’ navigate to resolved note
129
+ - [ ] [T082] [US2] Add broken wikilink styling in NoteViewer: render unresolved [[links]] with distinct color/style
130
+ - [ ] [T083] [US2] Create frontend/src/pages/App.tsx useEffect to load directory tree on mount: call listNotes()
131
+ - [ ] [T084] [US2] Create frontend/src/pages/App.tsx useEffect to load note when path changes: call getNote(path) and getBacklinks(path)
132
+
133
+ ---
134
+
135
+ ## Phase 5: User Story 3 - Human Edits UI (P2)
136
+
137
+ **Goal**: Split-pane editor with optimistic concurrency protection.
138
+
139
+ - [ ] [T085] [US3] Create backend/src/api/routes/notes.py with PUT /api/notes/{path} endpoint: URL-decode path, validate request body (NoteUpdate), check if_version, call VaultService.write_note, return NoteResponse from http-api.yaml
140
+ - [ ] [T086] [US3] Add optimistic concurrency check in IndexerService.increment_version: if if_version provided and != current version, raise ConflictError
141
+ - [ ] [T087] [US3] Create ConflictError exception in backend/src/models/errors.py, map to 409 Conflict in error_handlers.py
142
+ - [ ] [T088] [US3] Create frontend/src/services/api.ts updateNote function: PUT /api/notes/{encodeURIComponent(path)} with {title?, metadata?, body, if_version?}, handle 409 response
143
+ - [ ] [T089] [US3] Create frontend/src/components/NoteEditor.tsx: split-pane layout (left: textarea for markdown source, right: live preview with react-markdown)
144
+ - [ ] [T090] [US3] Create frontend/src/components/NoteEditor.tsx with Save button: onClick β†’ call updateNote with if_version from initial note load, handle success β†’ switch to read mode
145
+ - [ ] [T091] [US3] Create frontend/src/components/NoteEditor.tsx with Cancel button: onClick β†’ discard changes, switch to read mode
146
+ - [ ] [T092] [US3] Add 409 Conflict error handling in NoteEditor: display alert "Note changed since you opened it, please reload before saving"
147
+ - [ ] [T093] [US3] Add Edit button to NoteViewer: onClick β†’ switch main pane to NoteEditor mode, pass current note version
148
+ - [ ] [T094] [US3] Update frontend/src/pages/App.tsx to toggle between NoteViewer and NoteEditor based on edit mode state
149
+
150
+ ---
151
+
152
+ ## Phase 6: User Story 4 - Multi-Tenant OAuth (P2)
153
+
154
+ **Goal**: HF OAuth login, per-user vaults, JWT tokens for API and MCP HTTP.
155
+
156
+ - [ ] [T095] [US4] Create backend/src/services/auth.py HF OAuth integration: use huggingface_hub.attach_huggingface_oauth and parse_huggingface_oauth helpers
157
+ - [ ] [T096] [US4] Create backend/src/api/routes/auth.py with GET /auth/login endpoint: redirect to HF OAuth authorize URL
158
+ - [ ] [T097] [US4] Create backend/src/api/routes/auth.py with GET /auth/callback endpoint: parse_huggingface_oauth, map HF username to user_id, create vault if new user, set session cookie
159
+ - [ ] [T098] [US4] Create backend/src/api/routes/auth.py with POST /api/tokens endpoint: validate authenticated user, call AuthService.create_jwt, return TokenResponse from http-api.yaml
160
+ - [ ] [T099] [US4] Create backend/src/api/routes/auth.py with GET /api/me endpoint: validate Bearer token, return User from http-api.yaml
161
+ - [ ] [T100] [US4] Update backend/src/api/middleware/auth_middleware.py to extract user_id from JWT sub claim, attach to request.state.user_id
162
+ - [ ] [T101] [US4] Update backend/src/services/vault.py to scope all operations by user_id: vault path = VAULT_BASE_PATH / user_id
163
+ - [ ] [T102] [US4] Update backend/src/services/indexer.py to scope all queries by user_id: WHERE user_id = ?
164
+ - [ ] [T103] [US4] Initialize vault and index on first user login: create vault dir, insert initial index_health row
165
+ - [ ] [T104] [US4] Create backend/src/mcp/server.py HTTP transport mode: FastMCP with http transport, BearerAuth validation, extract user_id from JWT
166
+ - [ ] [T105] [US4] Create frontend/src/services/auth.ts with login function: redirect to /auth/login
167
+ - [ ] [T106] [US4] Create frontend/src/services/auth.ts with getCurrentUser function: GET /api/me, return User
168
+ - [ ] [T107] [US4] Create frontend/src/services/auth.ts with getToken function: POST /api/tokens, return TokenResponse, store token in memory
169
+ - [ ] [T108] [US4] Create frontend/src/pages/Login.tsx: "Sign in with Hugging Face" button β†’ onClick call auth.login()
170
+ - [ ] [T109] [US4] Create frontend/src/pages/Settings.tsx: display user profile (user_id, HF avatar), API token with copy button for MCP config
171
+ - [ ] [T110] [US4] Update frontend/src/pages/App.tsx to call getCurrentUser on mount, redirect to Login if 401
172
+ - [ ] [T111] [US4] Update frontend/src/services/api.ts to include token from auth.getToken() in Authorization header
173
+
174
+ ---
175
+
176
+ ## Phase 7: User Story 5 - Advanced Search (P3)
177
+
178
+ **Goal**: Enhanced search ranking and index health monitoring.
179
+
180
+ - [ ] [T112] [US5] Update backend/src/services/indexer.py search_notes to calculate recency bonus: +1.0 for updated in last 7 days, +0.5 for last 30 days, 0 otherwise
181
+ - [ ] [T113] [US5] Update backend/src/services/indexer.py search_notes to calculate final score: (3 * title_bm25) + (1 * body_bm25) + recency_bonus
182
+ - [ ] [T114] [US5] Create backend/src/api/routes/index.py with GET /api/index/health endpoint: query index_health, return IndexHealth from http-api.yaml
183
+ - [ ] [T115] [US5] Create backend/src/api/routes/index.py with POST /api/index/rebuild endpoint: call IndexerService.rebuild_index, return RebuildResponse from http-api.yaml
184
+ - [ ] [T116] [US5] Create backend/src/services/indexer.py IndexerService.rebuild_index method: delete all user rows, walk vault, parse all notes, re-insert into all index tables, update index_health
185
+ - [ ] [T117] [US5] Create frontend/src/services/api.ts getIndexHealth function: GET /api/index/health, return IndexHealth
186
+ - [ ] [T118] [US5] Create frontend/src/services/api.ts rebuildIndex function: POST /api/index/rebuild, return RebuildResponse
187
+ - [ ] [T119] [US5] Add index health indicator to frontend/src/pages/App.tsx: display note count and last updated timestamp in footer
188
+ - [ ] [T120] [US5] Add "Rebuild Index" button to frontend/src/pages/Settings.tsx: onClick β†’ call rebuildIndex, show progress/completion message
189
+
190
+ ---
191
+
192
+ ## Phase 8: Polish & Cross-Cutting
193
+
194
+ **Goal**: Documentation, configuration, logging, error handling improvements.
195
+
196
+ - [ ] [T121] Create README.md with project overview, tech stack, local setup instructions (backend venv + npm install)
197
+ - [ ] [T122] Add README.md section: "Running Backend" with uvicorn command for HTTP API and python -m backend.src.mcp.server for MCP STDIO
198
+ - [ ] [T123] Add README.md section: "Running Frontend" with npm run dev command
199
+ - [ ] [T124] Add README.md section: "MCP Client Configuration" with Claude Code/Desktop STDIO example from mcp-tools.json
200
+ - [ ] [T125] Add README.md section: "Deploying to Hugging Face Space" with environment variables and OAuth setup
201
+ - [ ] [T126] Update .env.example with all variables: JWT_SECRET_KEY, VAULT_BASE_PATH, HF_OAUTH_CLIENT_ID, HF_OAUTH_CLIENT_SECRET, DATABASE_PATH
202
+ - [ ] [T127] Add structured logging to backend/src/services/vault.py: log file operations with user_id, note_path, operation type
203
+ - [ ] [T128] Add structured logging to backend/src/services/indexer.py: log index updates with user_id, note_path, duration_ms
204
+ - [ ] [T129] Add structured logging to backend/src/mcp/server.py: log MCP tool calls with tool_name, user_id, duration_ms
205
+ - [ ] [T130] Improve error messages in backend/src/api/middleware/error_handlers.py: include detail objects with field names and reasons
206
+ - [ ] [T131] Add input validation to all HTTP API routes: validate path format, content size, required fields
207
+ - [ ] [T132] Add input validation to all MCP tools: validate path format, content size via Pydantic models
208
+ - [ ] [T133] Add rate limiting consideration to README.md: note potential need for per-user rate limits in production
209
+ - [ ] [T134] Add performance optimization notes to README.md: FTS5 prefix indexes, SQLite WAL mode for concurrency
210
+
211
+ ---
212
+
213
+ ## Dependencies
214
+
215
+ ### Story Completion Order
216
+
217
+ **Must complete in this order**:
218
+ 1. **Phase 1** (Setup) β†’ **Phase 2** (Foundational) β†’ **Phase 3** (US1) + **Phase 4** (US2) in parallel
219
+ 2. **Phase 5** (US3) depends on Phase 4 (needs HTTP API routes from US2)
220
+ 3. **Phase 6** (US4) depends on Phase 2 (needs auth foundation) and Phase 3 (needs vault/indexer)
221
+ 4. **Phase 7** (US5) depends on Phase 3 (needs indexer) and Phase 4 (needs HTTP API)
222
+ 5. **Phase 8** (Polish) can run anytime after Phase 4 (MVP complete)
223
+
224
+ ### Task Dependencies
225
+
226
+ **Critical path** (must be sequential):
227
+ - T023 (SQLite schema) β†’ T043 (IndexerService) β†’ T044 (index_note)
228
+ - T035 (VaultService foundation) β†’ T039 (read_note) β†’ T040 (write_note)
229
+ - T050 (FastMCP init) β†’ T051-T057 (MCP tools)
230
+ - T065 (FastAPI app) β†’ T060-T064 (HTTP routes)
231
+
232
+ **Parallelizable within phases** (marked with [P]):
233
+ - All directory creation tasks (T002-T017)
234
+ - All Pydantic model tasks (T026-T030)
235
+ - All TypeScript type tasks (T031-T034)
236
+ - All frontend component tasks within US2 (T077-T079)
237
+
238
+ ---
239
+
240
+ ## Parallel Execution Examples
241
+
242
+ ### User Story 1 (AI Agent Writes) - Parallel Work
243
+
244
+ **Team A**: VaultService implementation (T039-T042)
245
+ **Team B**: IndexerService implementation (T043-T049)
246
+ **Team C**: MCP tools implementation (T051-T057)
247
+
248
+ After T050 (FastMCP init) completes, Team C can implement all 7 MCP tools in parallel since they're independent endpoints.
249
+
250
+ ### User Story 2 (Human Reads UI) - Parallel Work
251
+
252
+ **Team A**: Backend HTTP API routes (T060-T064)
253
+ **Team B**: Frontend API client (T066-T071)
254
+ **Team C**: Frontend components (T077-T079)
255
+
256
+ After T065 (FastAPI app) and T075-T076 (shadcn/ui setup) complete, all three teams can work in parallel.
257
+
258
+ ### User Story 4 (Multi-Tenant OAuth) - Parallel Work
259
+
260
+ **Team A**: Backend OAuth integration (T095-T104)
261
+ **Team B**: Frontend auth flow (T105-T111)
262
+
263
+ Both teams can work in parallel after Phase 2 (Foundational) completes.
264
+
265
+ ---
266
+
267
+ ## Summary
268
+
269
+ **Total Tasks**: 134
270
+ - **Phase 1 (Setup)**: 24 tasks
271
+ - **Phase 2 (Foundational)**: 14 tasks
272
+ - **Phase 3 (US1 - AI Agent Writes)**: 21 tasks
273
+ - **Phase 4 (US2 - Human Reads UI)**: 25 tasks
274
+ - **Phase 5 (US3 - Human Edits UI)**: 10 tasks
275
+ - **Phase 6 (US4 - Multi-Tenant OAuth)**: 17 tasks
276
+ - **Phase 7 (US5 - Advanced Search)**: 9 tasks
277
+ - **Phase 8 (Polish)**: 14 tasks
278
+
279
+ **MVP Tasks** (US1 + US2): 84 tasks (Phases 1-4)
280
+ **Post-MVP Tasks** (US3 + US4 + US5): 36 tasks (Phases 5-7)
281
+ **Polish Tasks**: 14 tasks (Phase 8)
282
+
283
+ **Estimated Effort**:
284
+ - MVP (US1 + US2): ~2-3 weeks (1-2 developers)
285
+ - Post-MVP (US3 + US4 + US5): ~1-2 weeks
286
+ - Polish: ~3-5 days
287
+ - **Total**: ~4-6 weeks for complete implementation
288
+
289
+ **Key Milestones**:
290
+ 1. **Week 1**: Complete Phase 1-2 (Setup + Foundational)
291
+ 2. **Week 2**: Complete Phase 3 (US1 - MCP STDIO working)
292
+ 3. **Week 3**: Complete Phase 4 (US2 - Web UI working, MVP delivered)
293
+ 4. **Week 4**: Complete Phase 5-6 (US3 editing + US4 multi-tenant)
294
+ 5. **Week 5**: Complete Phase 7-8 (US5 advanced search + Polish)
295
+
296
+ **Next Steps**:
297
+ 1. Review this task breakdown with stakeholders
298
+ 2. Assign initial tasks (Phase 1 Setup) to team
299
+ 3. Create GitHub issues from tasks using `/speckit.taskstoissues`
300
+ 4. Begin implementation with T001 (project structure)