Wothmag07 commited on
Commit
1e6a9db
·
1 Parent(s): 91b7c2e

Doc-MCP Application

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +64 -0
  2. Dockerfile +39 -0
  3. backend/.env +13 -0
  4. backend/.python-version +1 -0
  5. backend/README.md +0 -0
  6. backend/__pycache__/main.cpython-311.pyc +0 -0
  7. backend/main.py +11 -0
  8. backend/pyproject.toml +23 -0
  9. backend/requirements.txt +12 -0
  10. backend/src/api/__init__.py +1 -0
  11. backend/src/api/__pycache__/__init__.cpython-311.pyc +0 -0
  12. backend/src/api/__pycache__/__init__.cpython-313.pyc +0 -0
  13. backend/src/api/__pycache__/main.cpython-311.pyc +0 -0
  14. backend/src/api/__pycache__/main.cpython-313.pyc +0 -0
  15. backend/src/api/main.py +196 -0
  16. backend/src/api/middleware/__init__.py +19 -0
  17. backend/src/api/middleware/__pycache__/__init__.cpython-311.pyc +0 -0
  18. backend/src/api/middleware/__pycache__/__init__.cpython-313.pyc +0 -0
  19. backend/src/api/middleware/__pycache__/auth_middleware.cpython-311.pyc +0 -0
  20. backend/src/api/middleware/__pycache__/auth_middleware.cpython-313.pyc +0 -0
  21. backend/src/api/middleware/__pycache__/error_handlers.cpython-311.pyc +0 -0
  22. backend/src/api/middleware/__pycache__/error_handlers.cpython-313.pyc +0 -0
  23. backend/src/api/middleware/auth_middleware.py +65 -0
  24. backend/src/api/middleware/error_handlers.py +113 -0
  25. backend/src/api/routes/__init__.py +5 -0
  26. backend/src/api/routes/__pycache__/__init__.cpython-311.pyc +0 -0
  27. backend/src/api/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  28. backend/src/api/routes/__pycache__/auth.cpython-311.pyc +0 -0
  29. backend/src/api/routes/__pycache__/auth.cpython-313.pyc +0 -0
  30. backend/src/api/routes/__pycache__/index.cpython-311.pyc +0 -0
  31. backend/src/api/routes/__pycache__/index.cpython-313.pyc +0 -0
  32. backend/src/api/routes/__pycache__/notes.cpython-311.pyc +0 -0
  33. backend/src/api/routes/__pycache__/notes.cpython-313.pyc +0 -0
  34. backend/src/api/routes/__pycache__/search.cpython-311.pyc +0 -0
  35. backend/src/api/routes/__pycache__/search.cpython-313.pyc +0 -0
  36. backend/src/api/routes/auth.py +315 -0
  37. backend/src/api/routes/index.py +135 -0
  38. backend/src/api/routes/notes.py +288 -0
  39. backend/src/api/routes/search.py +107 -0
  40. backend/src/mcp/__init__.py +5 -0
  41. backend/src/mcp/__pycache__/__init__.cpython-311.pyc +0 -0
  42. backend/src/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  43. backend/src/mcp/__pycache__/server.cpython-311.pyc +0 -0
  44. backend/src/mcp/__pycache__/server.cpython-313.pyc +0 -0
  45. backend/src/mcp/server.py +284 -0
  46. backend/src/models/__init__.py +25 -0
  47. backend/src/models/__pycache__/__init__.cpython-311.pyc +0 -0
  48. backend/src/models/__pycache__/__init__.cpython-313.pyc +0 -0
  49. backend/src/models/__pycache__/auth.cpython-311.pyc +0 -0
  50. backend/src/models/__pycache__/auth.cpython-313.pyc +0 -0
.dockerignore ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ **/__pycache__
3
+ **/*.pyc
4
+ **/*.pyo
5
+ **/*.pyd
6
+ **/.Python
7
+ **/env
8
+ **/venv
9
+ **/.venv
10
+ **/ENV
11
+ **/env.bak/
12
+ **/venv.bak/
13
+ **/*.egg-info/
14
+ **/.pytest_cache/
15
+ **/.mypy_cache/
16
+ **/.ruff_cache/
17
+
18
+ # Node.js
19
+ **/node_modules
20
+ **/npm-debug.log*
21
+ **/yarn-debug.log*
22
+ **/yarn-error.log*
23
+ **/pnpm-debug.log*
24
+ **/lerna-debug.log*
25
+ frontend/dist
26
+ frontend/.vite
27
+
28
+ # Git
29
+ .git
30
+ .gitignore
31
+ .gitattributes
32
+
33
+ # IDE
34
+ **/.vscode
35
+ **/.idea
36
+ **/*.swp
37
+ **/*.swo
38
+ **/*~
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Project specific
45
+ *.log
46
+ .backend.pid
47
+ .frontend.pid
48
+ backend.log
49
+ frontend.log
50
+ data/index.db
51
+ data/vaults/*
52
+ !data/vaults/.gitkeep
53
+
54
+ # Documentation (not needed in container)
55
+ *.md
56
+ !backend/README.md
57
+ specs/
58
+ ai-notes/
59
+
60
+ # Scripts
61
+ start-dev.sh
62
+ stop-dev.sh
63
+ status-dev.sh
64
+
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Space deployment
2
+ FROM python:3.11-slim
3
+
4
+ WORKDIR /app
5
+
6
+ # Install Node.js for frontend build
7
+ RUN apt-get update && \
8
+ apt-get install -y nodejs npm curl && \
9
+ rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy and install frontend dependencies
12
+ COPY frontend/package*.json frontend/
13
+ RUN cd frontend && npm ci
14
+
15
+ # Copy frontend source and build
16
+ COPY frontend/ frontend/
17
+ RUN cd frontend && npm run build
18
+
19
+ # Install Python dependencies
20
+ COPY backend/pyproject.toml backend/README.md backend/
21
+ RUN pip install --no-cache-dir -e backend/
22
+
23
+ # Copy backend source
24
+ COPY backend/ backend/
25
+
26
+ # Create data directory for vaults and database
27
+ RUN mkdir -p /app/data/vaults
28
+
29
+ # Expose port 7860 (required by Hugging Face Spaces)
30
+ EXPOSE 7860
31
+
32
+ # Set environment variables for production
33
+ ENV PYTHONUNBUFFERED=1 \
34
+ VAULT_BASE_PATH=/app/data/vaults \
35
+ DATABASE_PATH=/app/data/index.db
36
+
37
+ # Start the FastAPI server
38
+ CMD ["uvicorn", "backend.src.api.main:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
39
+
backend/.env ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ JWT_SECRET_KEY=61012f17c920190990c90bfc9c810e24bbed86382df3cc573e78fd82e8809661
2
+ # JWT_SECRET_KEY="local-dev-secret-key-123"
3
+ HF_OAUTH_CLIENT_ID=9a79980a-74ce-4e06-9fce-ed787b2242c8
4
+ HF_OAUTH_CLIENT_SECRET=05a8a57e-6940-4bd4-a7f8-7f0a6db1460
5
+ HF_SPACE_URL=https://huggingface.co/spaces/Wothmag07/Document-MCP
6
+ MCP_HOST=0.0.0.0
7
+ MCP_PORT=8001
8
+
9
+ ENABLE_LOCAL_MODE=False
10
+ LOCAL_DEV_TOKEN=local-dev-token
11
+ MCP_TRANSPORT = "http"
12
+
13
+ VAULT_BASE_PATH=../data/vaults
backend/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
backend/README.md ADDED
File without changes
backend/__pycache__/main.cpython-311.pyc ADDED
Binary file (451 Bytes). View file
 
backend/main.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for running the FastAPI application."""
2
+
3
+ import uvicorn
4
+
5
+ if __name__ == "__main__":
6
+ uvicorn.run(
7
+ "src.api.main:app",
8
+ host="0.0.0.0",
9
+ port=8000,
10
+ reload=True,
11
+ )
backend/pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "Documentation-MCP"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.121.2",
9
+ "fastmcp>=2.13.1",
10
+ "huggingface-hub>=1.1.4",
11
+ "mcp>=1.21.0",
12
+ "pyjwt>=2.10.1",
13
+ "python-dotenv>=1.0.0",
14
+ "python-frontmatter>=1.1.0",
15
+ "uvicorn[standard]>=0.38.0",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "httpx>=0.28.1",
21
+ "pytest>=9.0.1",
22
+ "pytest-asyncio>=1.3.0",
23
+ ]
backend/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ fastmcp
3
+ python-frontmatter
4
+ pyjwt
5
+ huggingface_hub
6
+ uvicorn
7
+ httpx
8
+ pytest
9
+ pytest-asyncio
10
+ python-dotenv
11
+ requests
12
+ sqlite
backend/src/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FastAPI application and routing."""
backend/src/api/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (218 Bytes). View file
 
backend/src/api/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (207 Bytes). View file
 
backend/src/api/__pycache__/main.cpython-311.pyc ADDED
Binary file (8.95 kB). View file
 
backend/src/api/__pycache__/main.cpython-313.pyc ADDED
Binary file (7.77 kB). View file
 
backend/src/api/main.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application main entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+ from fastapi import Depends, FastAPI, HTTPException, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ from fastmcp.server.http import StreamableHTTPSessionManager
16
+ from starlette.responses import FileResponse, Response
17
+
18
+ from .middleware import AuthContext, get_auth_context
19
+ from .routes import auth, index, notes, search
20
+ from ..mcp.server import mcp
21
+ from ..services.seed import init_and_seed
22
+
23
+ load_dotenv(dotenv_path="backend/.env")
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ session_manager = StreamableHTTPSessionManager(
29
+ app=mcp._mcp_server,
30
+ event_store=None,
31
+ json_response=False,
32
+ stateless=False,
33
+ )
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ """Start and stop the Streamable HTTP session manager."""
39
+ async with session_manager.run():
40
+ yield
41
+
42
+
43
+ app = FastAPI(
44
+ title="Document Viewer API",
45
+ description="Multi-tenant Obsidian-like documentation system",
46
+ version="0.1.0",
47
+ lifespan=lifespan,
48
+ )
49
+
50
+ # CORS middleware
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=[
54
+ "http://localhost:5173",
55
+ "http://localhost:3000",
56
+ "https://huggingface.co",
57
+ ],
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+
64
+ # Startup event: Initialize database and seed demo data
65
+ @app.on_event("startup")
66
+ async def startup_event():
67
+ """Initialize database schema and seed demo vault on startup."""
68
+ logger.info("Running startup: initializing database and seeding demo vault...")
69
+ try:
70
+ init_and_seed(user_id="demo-user")
71
+ logger.info("Startup complete: database and demo vault ready")
72
+ except Exception as e:
73
+ logger.exception(f"Startup failed: {e}")
74
+ # Don't crash the app, but log the error
75
+ logger.error("App starting without demo data due to initialization error")
76
+
77
+
78
+ # Error handlers
79
+ @app.exception_handler(404)
80
+ async def not_found_handler(request: Request, exc: Exception):
81
+ """Handle 404 errors."""
82
+ return JSONResponse(
83
+ status_code=404,
84
+ content={"error": "Not found", "detail": str(exc)},
85
+ )
86
+
87
+
88
+ @app.exception_handler(409)
89
+ async def conflict_handler(request: Request, exc: Exception):
90
+ """Handle 409 Conflict errors."""
91
+ return JSONResponse(
92
+ status_code=409,
93
+ content={"error": "Conflict", "detail": str(exc)},
94
+ )
95
+
96
+
97
+ @app.exception_handler(500)
98
+ async def internal_error_handler(request: Request, exc: Exception):
99
+ """Handle 500 errors."""
100
+ return JSONResponse(
101
+ status_code=500,
102
+ content={"error": "Internal server error", "detail": str(exc)},
103
+ )
104
+
105
+
106
+ # Mount routers (auth must come first for /auth/login and /auth/callback)
107
+ app.include_router(auth.router, tags=["auth"])
108
+ app.include_router(notes.router, tags=["notes"])
109
+ app.include_router(search.router, tags=["search"])
110
+ app.include_router(index.router, tags=["index"])
111
+
112
+
113
+ @app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
114
+ async def mcp_http_bridge(
115
+ request: Request, auth: AuthContext = Depends(get_auth_context)
116
+ ) -> Response:
117
+ """Forward HTTP requests to the FastMCP streamable HTTP session manager."""
118
+
119
+ send_queue: asyncio.Queue = asyncio.Queue()
120
+
121
+ async def send(message):
122
+ await send_queue.put(message)
123
+
124
+ await session_manager.handle_request(request.scope, request.receive, send)
125
+ await send_queue.put(None)
126
+
127
+ result_body = b""
128
+ headers = {}
129
+ status = 200
130
+
131
+ while True:
132
+ message = await send_queue.get()
133
+ if message is None:
134
+ break
135
+ msg_type = message["type"]
136
+ if msg_type == "http.response.start":
137
+ status = message.get("status", 200)
138
+ raw_headers = message.get("headers", [])
139
+ headers = {key.decode(): value.decode() for key, value in raw_headers}
140
+ elif msg_type == "http.response.body":
141
+ result_body += message.get("body", b"")
142
+ if not message.get("more_body"):
143
+ break
144
+
145
+ return Response(content=result_body, status_code=status, headers=headers)
146
+
147
+
148
+ logger.info("MCP HTTP endpoint mounted at /mcp via StreamableHTTPSessionManager")
149
+
150
+
151
+ @app.get("/health")
152
+ async def health():
153
+ """Health check endpoint for HF Spaces."""
154
+ return {"status": "healthy"}
155
+
156
+
157
+ frontend_dist = Path(__file__).resolve().parents[3] / "frontend" / "dist"
158
+ if frontend_dist.exists():
159
+ # Mount static assets
160
+ app.mount(
161
+ "/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets"
162
+ )
163
+
164
+ # Catch-all route for SPA - serve index.html for all non-API routes
165
+ @app.get("/{full_path:path}")
166
+ async def serve_spa(full_path: str):
167
+ """Serve the SPA for all non-API routes."""
168
+ # Don't intercept API or auth routes
169
+ if (
170
+ full_path.startswith(("api/", "auth/"))
171
+ or full_path == "health"
172
+ or full_path.startswith("mcp/")
173
+ or full_path == "mcp"
174
+ ):
175
+ # Let FastAPI's 404 handler take over
176
+ raise HTTPException(status_code=404, detail="Not found")
177
+
178
+ # If the path looks like a file (has extension), try to serve it
179
+ file_path = frontend_dist / full_path
180
+ if file_path.is_file():
181
+ return FileResponse(file_path)
182
+ # Otherwise serve index.html for SPA routing
183
+ return FileResponse(frontend_dist / "index.html")
184
+
185
+ logger.info(f"Serving frontend SPA from: {frontend_dist}")
186
+ else:
187
+ logger.warning(f"Frontend dist not found at: {frontend_dist}")
188
+
189
+ # Fallback health endpoint if no frontend
190
+ @app.get("/")
191
+ async def root():
192
+ """API health check endpoint."""
193
+ return {"status": "ok", "service": "Document Viewer API"}
194
+
195
+
196
+ __all__ = ["app"]
backend/src/api/middleware/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI middleware for authentication and error handling."""
2
+
3
+ from .auth_middleware import AuthContext, extract_user_id_from_jwt, get_auth_context
4
+ from .error_handlers import (
5
+ http_exception_handler,
6
+ internal_exception_handler,
7
+ register_error_handlers,
8
+ validation_exception_handler,
9
+ )
10
+
11
+ __all__ = [
12
+ "AuthContext",
13
+ "extract_user_id_from_jwt",
14
+ "get_auth_context",
15
+ "register_error_handlers",
16
+ "validation_exception_handler",
17
+ "http_exception_handler",
18
+ "internal_exception_handler",
19
+ ]
backend/src/api/middleware/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (698 Bytes). View file
 
backend/src/api/middleware/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (605 Bytes). View file
 
backend/src/api/middleware/__pycache__/auth_middleware.cpython-311.pyc ADDED
Binary file (3.16 kB). View file
 
backend/src/api/middleware/__pycache__/auth_middleware.cpython-313.pyc ADDED
Binary file (2.87 kB). View file
 
backend/src/api/middleware/__pycache__/error_handlers.cpython-311.pyc ADDED
Binary file (6.12 kB). View file
 
backend/src/api/middleware/__pycache__/error_handlers.cpython-313.pyc ADDED
Binary file (5.66 kB). View file
 
backend/src/api/middleware/auth_middleware.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication dependency helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Annotated, Optional
7
+
8
+ from fastapi import Header, HTTPException, status
9
+
10
+ from ...models.auth import JWTPayload
11
+ from ...services.auth import AuthError, AuthService
12
+
13
+ auth_service = AuthService()
14
+
15
+
16
+ def _unauthorized(message: str, error: str = "unauthorized") -> HTTPException:
17
+ return HTTPException(
18
+ status_code=status.HTTP_401_UNAUTHORIZED,
19
+ detail={"error": error, "message": message},
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class AuthContext:
25
+ """Context extracted from a bearer token."""
26
+
27
+ user_id: str
28
+ token: str
29
+ payload: JWTPayload
30
+
31
+
32
+ def get_auth_context(
33
+ authorization: Annotated[Optional[str], Header(alias="Authorization")] = None,
34
+ ) -> AuthContext:
35
+ """
36
+ Extract and validate the user_id from a Bearer token.
37
+
38
+ Raises HTTPException if the header is missing/invalid.
39
+ """
40
+ if not authorization:
41
+ raise _unauthorized("Authorization header required")
42
+
43
+ scheme, _, token = authorization.partition(" ")
44
+ if scheme.lower() != "bearer" or not token:
45
+ raise _unauthorized("Authorization header must be in format: Bearer <token>")
46
+
47
+ try:
48
+ payload = auth_service.validate_jwt(token)
49
+ except AuthError as exc:
50
+ raise HTTPException(
51
+ status_code=exc.status_code,
52
+ detail={"error": exc.error, "message": exc.message, "detail": exc.detail},
53
+ ) from exc
54
+
55
+ return AuthContext(user_id=payload.sub, token=token, payload=payload)
56
+
57
+
58
+ def extract_user_id_from_jwt(
59
+ authorization: Annotated[Optional[str], Header(alias="Authorization")] = None,
60
+ ) -> str:
61
+ """Compatibility helper that returns only the user_id."""
62
+ return get_auth_context(authorization).user_id
63
+
64
+
65
+ __all__ = ["AuthContext", "extract_user_id_from_jwt", "get_auth_context"]
backend/src/api/middleware/error_handlers.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI exception handlers aligned with HTTP API contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ from fastapi import FastAPI, Request, status
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from starlette.exceptions import HTTPException as StarletteHTTPException
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DEFAULT_ERRORS: Dict[int, Tuple[str, str]] = {
16
+ status.HTTP_400_BAD_REQUEST: ("validation_error", "Invalid request payload"),
17
+ status.HTTP_401_UNAUTHORIZED: ("unauthorized", "Authorization required"),
18
+ status.HTTP_403_FORBIDDEN: ("forbidden", "Forbidden"),
19
+ status.HTTP_404_NOT_FOUND: ("not_found", "Resource not found"),
20
+ status.HTTP_409_CONFLICT: ("version_conflict", "Resource version conflict"),
21
+ status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: (
22
+ "payload_too_large",
23
+ "Payload exceeds allowed size",
24
+ ),
25
+ status.HTTP_500_INTERNAL_SERVER_ERROR: ("internal_error", "Internal server error"),
26
+ }
27
+
28
+
29
+ def _normalize_error(
30
+ status_code: int, detail: Any
31
+ ) -> Tuple[str, str, Optional[Dict[str, Any]]]:
32
+ default_error, default_message = DEFAULT_ERRORS.get(
33
+ status_code, DEFAULT_ERRORS[status.HTTP_500_INTERNAL_SERVER_ERROR]
34
+ )
35
+ if isinstance(detail, dict):
36
+ error = detail.get("error", default_error)
37
+ message = detail.get("message", default_message)
38
+ detail_payload = detail.get("detail")
39
+ if detail_payload is None:
40
+ remainder = {
41
+ k: v for k, v in detail.items() if k not in {"error", "message", "detail"}
42
+ }
43
+ detail_payload = remainder or None
44
+ return error, message, detail_payload
45
+ if isinstance(detail, str) and detail:
46
+ return default_error, detail, None
47
+ return default_error, default_message, None
48
+
49
+
50
+ def _response(status_code: int, detail: Any) -> JSONResponse:
51
+ error, message, extra = _normalize_error(status_code, detail)
52
+ return JSONResponse(
53
+ status_code=status_code, content={"error": error, "message": message, "detail": extra}
54
+ )
55
+
56
+
57
+ async def validation_exception_handler(
58
+ request: Request, exc: RequestValidationError
59
+ ) -> JSONResponse:
60
+ # Transform pydantic errors into more user-friendly format
61
+ errors = []
62
+ for error in exc.errors():
63
+ field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
64
+ errors.append({
65
+ "field": field or "request",
66
+ "reason": error["msg"],
67
+ "type": error["type"]
68
+ })
69
+
70
+ detail = {
71
+ "error": "validation_error",
72
+ "message": "Request validation failed",
73
+ "detail": {"fields": errors}
74
+ }
75
+ logger.warning(
76
+ "Validation error",
77
+ extra={"url": str(request.url), "errors": errors}
78
+ )
79
+ return _response(status.HTTP_400_BAD_REQUEST, detail)
80
+
81
+
82
+ async def http_exception_handler(
83
+ request: Request, exc: StarletteHTTPException
84
+ ) -> JSONResponse:
85
+ logger.warning(
86
+ "HTTP exception",
87
+ extra={
88
+ "url": str(request.url),
89
+ "status_code": exc.status_code,
90
+ "detail": exc.detail
91
+ }
92
+ )
93
+ return _response(exc.status_code, exc.detail)
94
+
95
+
96
+ async def internal_exception_handler(request: Request, exc: Exception) -> JSONResponse:
97
+ logger.exception("Unhandled exception: %s", exc)
98
+ return _response(status.HTTP_500_INTERNAL_SERVER_ERROR, exc.args[0] if exc.args else None)
99
+
100
+
101
+ def register_error_handlers(app: FastAPI) -> None:
102
+ """Attach shared exception handlers to the FastAPI application."""
103
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
104
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
105
+ app.add_exception_handler(Exception, internal_exception_handler)
106
+
107
+
108
+ __all__ = [
109
+ "register_error_handlers",
110
+ "validation_exception_handler",
111
+ "http_exception_handler",
112
+ "internal_exception_handler",
113
+ ]
backend/src/api/routes/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """HTTP API route handlers."""
2
+
3
+ from . import auth, index, notes, search
4
+
5
+ __all__ = ["auth", "notes", "search", "index"]
backend/src/api/routes/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (382 Bytes). View file
 
backend/src/api/routes/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (337 Bytes). View file
 
backend/src/api/routes/__pycache__/auth.cpython-311.pyc ADDED
Binary file (13.4 kB). View file
 
backend/src/api/routes/__pycache__/auth.cpython-313.pyc ADDED
Binary file (11.7 kB). View file
 
backend/src/api/routes/__pycache__/index.cpython-311.pyc ADDED
Binary file (6.82 kB). View file
 
backend/src/api/routes/__pycache__/index.cpython-313.pyc ADDED
Binary file (5.83 kB). View file
 
backend/src/api/routes/__pycache__/notes.cpython-311.pyc ADDED
Binary file (13.4 kB). View file
 
backend/src/api/routes/__pycache__/notes.cpython-313.pyc ADDED
Binary file (11.6 kB). View file
 
backend/src/api/routes/__pycache__/search.cpython-311.pyc ADDED
Binary file (5.55 kB). View file
 
backend/src/api/routes/__pycache__/search.cpython-313.pyc ADDED
Binary file (4.61 kB). View file
 
backend/src/api/routes/auth.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OAuth and authentication routes for Hugging Face integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import secrets
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+ from urllib.parse import urlencode
11
+
12
+ import httpx
13
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
14
+ from fastapi.responses import RedirectResponse
15
+
16
+ from ...models.auth import TokenResponse
17
+ from ...models.user import HFProfile, User
18
+ from ...services.auth import AuthError, AuthService
19
+ from ...services.config import get_config
20
+ from ...services.seed import ensure_welcome_note
21
+ from ...services.vault import VaultService
22
+ from ..middleware import AuthContext, get_auth_context
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter()
27
+
28
+ OAUTH_STATE_TTL_SECONDS = 300
29
+ oauth_states: dict[str, float] = {}
30
+
31
+ auth_service = AuthService()
32
+
33
+
34
+ def _create_oauth_state() -> str:
35
+ """Generate a state token and store it with a timestamp."""
36
+ now = time.time()
37
+ # Garbage collect expired states
38
+ expired = [
39
+ state
40
+ for state, ts in oauth_states.items()
41
+ if now - ts > OAUTH_STATE_TTL_SECONDS
42
+ ]
43
+ for state in expired:
44
+ oauth_states.pop(state, None)
45
+
46
+ state = secrets.token_urlsafe(32)
47
+ oauth_states[state] = now
48
+ return state
49
+
50
+
51
+ def _consume_oauth_state(state: str | None) -> None:
52
+ """Validate and remove the state token; raise if invalid."""
53
+ if not state or state not in oauth_states:
54
+ raise HTTPException(status_code=400, detail="Invalid or expired OAuth state.")
55
+ # Remove to prevent reuse
56
+ del oauth_states[state]
57
+
58
+
59
+ def get_base_url(request: Request) -> str:
60
+ """
61
+ Get the base URL for OAuth redirects.
62
+
63
+ Uses the actual request URL scheme and hostname from FastAPI's request.url.
64
+ HF Spaces doesn't set X-Forwarded-Host, but the 'host' header is correct.
65
+ """
66
+ # Get scheme from X-Forwarded-Proto or request
67
+ forwarded_proto = request.headers.get("x-forwarded-proto")
68
+ scheme = forwarded_proto if forwarded_proto else str(request.url.scheme)
69
+
70
+ # Get hostname from request URL (this comes from the 'host' header)
71
+ hostname = str(request.url.hostname)
72
+
73
+ # Check for port (but HF Spaces uses standard 443 for HTTPS)
74
+ port = request.url.port
75
+ if port and port not in (80, 443):
76
+ base_url = f"{scheme}://{hostname}:{port}"
77
+ else:
78
+ base_url = f"{scheme}://{hostname}"
79
+
80
+ logger.info(
81
+ f"OAuth base URL detected: {base_url}",
82
+ extra={
83
+ "scheme": scheme,
84
+ "hostname": hostname,
85
+ "port": port,
86
+ "request_url": str(request.url),
87
+ },
88
+ )
89
+
90
+ return base_url
91
+
92
+
93
+ @router.get("/auth/login")
94
+ async def login(request: Request):
95
+ """Redirect to Hugging Face OAuth authorization page."""
96
+ config = get_config()
97
+
98
+ if not config.hf_oauth_client_id:
99
+ raise HTTPException(
100
+ status_code=501,
101
+ detail="OAuth not configured. Set HF_OAUTH_CLIENT_ID and HF_OAUTH_CLIENT_SECRET environment variables.",
102
+ )
103
+
104
+ # Get base URL from request (handles HF Spaces proxy)
105
+ base_url = get_base_url(request)
106
+ redirect_uri = f"{base_url}/auth/callback"
107
+
108
+ state = _create_oauth_state()
109
+
110
+ # Construct HF OAuth URL
111
+ oauth_base = "https://huggingface.co/oauth/authorize"
112
+ params = {
113
+ "client_id": config.hf_oauth_client_id,
114
+ "redirect_uri": redirect_uri,
115
+ "scope": "openid profile email",
116
+ "response_type": "code",
117
+ "state": state,
118
+ }
119
+
120
+ auth_url = f"{oauth_base}?{urlencode(params)}"
121
+ logger.info(
122
+ "Initiating OAuth flow",
123
+ extra={
124
+ "redirect_uri": redirect_uri,
125
+ "auth_url": auth_url,
126
+ "client_id": config.hf_oauth_client_id[:8] + "...",
127
+ "state": state,
128
+ },
129
+ )
130
+
131
+ return RedirectResponse(url=auth_url, status_code=302)
132
+
133
+
134
+ @router.get("/auth/callback")
135
+ async def callback(
136
+ request: Request,
137
+ code: str = Query(..., description="OAuth authorization code"),
138
+ state: Optional[str] = Query(
139
+ None, description="State parameter for CSRF protection"
140
+ ),
141
+ ):
142
+ """Handle OAuth callback from Hugging Face."""
143
+ config = get_config()
144
+
145
+ if not config.hf_oauth_client_id or not config.hf_oauth_client_secret:
146
+ raise HTTPException(status_code=501, detail="OAuth not configured")
147
+
148
+ # Get base URL from request (must match the one sent to HF)
149
+ base_url = get_base_url(request)
150
+ redirect_uri = f"{base_url}/auth/callback"
151
+
152
+ # Validate state token to prevent CSRF and replay attacks
153
+ _consume_oauth_state(state)
154
+
155
+ logger.info(
156
+ "OAuth callback received",
157
+ extra={
158
+ "redirect_uri": redirect_uri,
159
+ "state": state,
160
+ "code_length": len(code) if code else 0,
161
+ },
162
+ )
163
+
164
+ try:
165
+ # Exchange authorization code for access token
166
+ async with httpx.AsyncClient() as client:
167
+ token_response = await client.post(
168
+ "https://huggingface.co/oauth/token",
169
+ data={
170
+ "grant_type": "authorization_code",
171
+ "code": code,
172
+ "redirect_uri": redirect_uri,
173
+ "client_id": config.hf_oauth_client_id,
174
+ "client_secret": config.hf_oauth_client_secret,
175
+ },
176
+ )
177
+
178
+ if token_response.status_code != 200:
179
+ logger.error(f"Token exchange failed: {token_response.text}")
180
+ raise HTTPException(
181
+ status_code=400,
182
+ detail="Failed to exchange authorization code for token",
183
+ )
184
+
185
+ token_data = token_response.json()
186
+ access_token = token_data.get("access_token")
187
+
188
+ if not access_token:
189
+ raise HTTPException(
190
+ status_code=400, detail="No access token in response"
191
+ )
192
+
193
+ # Get user profile from HF
194
+ user_response = await client.get(
195
+ "https://huggingface.co/api/whoami-v2",
196
+ headers={"Authorization": f"Bearer {access_token}"},
197
+ )
198
+
199
+ if user_response.status_code != 200:
200
+ logger.error(f"User profile fetch failed: {user_response.text}")
201
+ raise HTTPException(
202
+ status_code=400, detail="Failed to fetch user profile"
203
+ )
204
+
205
+ user_data = user_response.json()
206
+ username = user_data.get("name")
207
+ email = user_data.get("email")
208
+ hf_id = user_data.get("id") or user_data.get("uid")
209
+
210
+ if not username and not hf_id:
211
+ raise HTTPException(
212
+ status_code=400, detail="No user identity in user profile"
213
+ )
214
+
215
+ # Prefer stable HF account id; fall back to username if missing
216
+ user_id = str(hf_id or username)
217
+
218
+ # Create JWT for our application
219
+ import jwt
220
+ from datetime import datetime, timedelta, timezone
221
+
222
+ # Ensure the user has an initialized vault with a welcome note
223
+ try:
224
+ created = ensure_welcome_note(user_id)
225
+ logger.info(
226
+ "Ensured welcome note for user",
227
+ extra={"user_id": user_id, "created": created},
228
+ )
229
+ except Exception as seed_exc:
230
+ logger.exception(
231
+ "Failed to seed welcome note for user",
232
+ extra={"user_id": user_id},
233
+ )
234
+
235
+ payload = {
236
+ "sub": user_id,
237
+ "username": username,
238
+ "email": email,
239
+ "exp": datetime.now(timezone.utc) + timedelta(days=7),
240
+ "iat": datetime.now(timezone.utc),
241
+ }
242
+
243
+ try:
244
+ jwt_secret = auth_service._require_secret()
245
+ except AuthError as exc:
246
+ raise HTTPException(status_code=exc.status_code, detail=exc.message)
247
+
248
+ jwt_token = jwt.encode(payload, jwt_secret, algorithm="HS256")
249
+
250
+ logger.info(
251
+ "OAuth successful",
252
+ extra={
253
+ "username": username,
254
+ "user_id": user_id,
255
+ "email": email,
256
+ },
257
+ )
258
+
259
+ # Redirect to frontend with token in URL hash
260
+ frontend_url = base_url
261
+ redirect_url = f"{frontend_url}/#token={jwt_token}"
262
+ logger.info(f"Redirecting to frontend: {redirect_url}")
263
+ return RedirectResponse(url=redirect_url, status_code=302)
264
+
265
+ except httpx.HTTPError as e:
266
+ logger.exception(f"HTTP error during OAuth: {e}")
267
+ raise HTTPException(
268
+ status_code=500, detail="OAuth flow failed due to network error"
269
+ )
270
+ except Exception as e:
271
+ logger.exception(f"Unexpected error during OAuth: {e}")
272
+ raise HTTPException(status_code=500, detail="OAuth flow failed")
273
+
274
+
275
+ @router.post("/api/tokens", response_model=TokenResponse)
276
+ async def create_api_token(auth: AuthContext = Depends(get_auth_context)):
277
+ """Issue a new JWT for the authenticated user."""
278
+ token, expires_at = auth_service.issue_token_response(auth.user_id)
279
+ return TokenResponse(token=token, token_type="bearer", expires_at=expires_at)
280
+
281
+
282
+ @router.get("/api/me", response_model=User)
283
+ async def get_current_user(auth: AuthContext = Depends(get_auth_context)):
284
+ """Return profile metadata for the authenticated user."""
285
+ user_id = auth.user_id
286
+ vault_service = VaultService()
287
+ vault_path = vault_service.initialize_vault(user_id)
288
+
289
+ # Attempt to derive a stable "created" timestamp from the vault directory
290
+ try:
291
+ stat = vault_path.stat()
292
+ created_dt = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
293
+ except Exception:
294
+ created_dt = datetime.now(timezone.utc)
295
+
296
+ profile: Optional[HFProfile] = None
297
+ if user_id.startswith("hf-"):
298
+ username = user_id[len("hf-") :]
299
+ profile = HFProfile(
300
+ username=username,
301
+ name=username.replace("-", " ").title(),
302
+ avatar_url=f"https://api.dicebear.com/7.x/initials/svg?seed={username}",
303
+ )
304
+ elif user_id not in {"local-dev", "demo-user"}:
305
+ profile = HFProfile(username=user_id)
306
+
307
+ return User(
308
+ user_id=user_id,
309
+ hf_profile=profile,
310
+ vault_path=str(vault_path),
311
+ created=created_dt,
312
+ )
313
+
314
+
315
+ __all__ = ["router"]
backend/src/api/routes/index.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP API routes for index operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+ from ...models.index import IndexHealth
11
+ from ...services.database import DatabaseService
12
+ from ...services.indexer import IndexerService
13
+ from ...services.vault import VaultService
14
+ from ..middleware import AuthContext, get_auth_context
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class RebuildResponse(BaseModel):
20
+ """Response from index rebuild."""
21
+
22
+ status: str
23
+ notes_indexed: int
24
+
25
+
26
+ @router.get("/api/index/health", response_model=IndexHealth)
27
+ async def get_index_health(auth: AuthContext = Depends(get_auth_context)):
28
+ """Get index health statistics."""
29
+ user_id = auth.user_id
30
+ db_service = DatabaseService()
31
+
32
+ try:
33
+ conn = db_service.connect()
34
+ try:
35
+ cursor = conn.execute(
36
+ """
37
+ SELECT note_count, last_full_rebuild, last_incremental_update
38
+ FROM index_health
39
+ WHERE user_id = ?
40
+ """,
41
+ (user_id,),
42
+ )
43
+ row = cursor.fetchone()
44
+
45
+ if not row:
46
+ # Initialize if not exists
47
+ return IndexHealth(
48
+ user_id=user_id,
49
+ note_count=0,
50
+ last_full_rebuild=None,
51
+ last_incremental_update=None,
52
+ )
53
+
54
+ last_full_rebuild = row["last_full_rebuild"]
55
+ last_incremental_update = row["last_incremental_update"]
56
+
57
+ if last_full_rebuild and isinstance(last_full_rebuild, str):
58
+ last_full_rebuild = datetime.fromisoformat(last_full_rebuild.replace("Z", "+00:00"))
59
+
60
+ if last_incremental_update and isinstance(last_incremental_update, str):
61
+ last_incremental_update = datetime.fromisoformat(last_incremental_update.replace("Z", "+00:00"))
62
+
63
+ return IndexHealth(
64
+ user_id=user_id,
65
+ note_count=row["note_count"],
66
+ last_full_rebuild=last_full_rebuild,
67
+ last_incremental_update=last_incremental_update,
68
+ )
69
+ finally:
70
+ conn.close()
71
+ except Exception as e:
72
+ raise HTTPException(status_code=500, detail=f"Failed to get index health: {str(e)}")
73
+
74
+
75
+ @router.post("/api/index/rebuild", response_model=RebuildResponse)
76
+ async def rebuild_index(auth: AuthContext = Depends(get_auth_context)):
77
+ """Rebuild the entire index from scratch."""
78
+ user_id = auth.user_id
79
+ vault_service = VaultService()
80
+ indexer_service = IndexerService()
81
+
82
+ try:
83
+ # Get all notes
84
+ notes = vault_service.list_notes(user_id)
85
+
86
+ # Clear existing index entries
87
+ db_service = DatabaseService()
88
+ conn = db_service.connect()
89
+ try:
90
+ with conn:
91
+ conn.execute("DELETE FROM note_metadata WHERE user_id = ?", (user_id,))
92
+ conn.execute("DELETE FROM note_fts WHERE user_id = ?", (user_id,))
93
+ conn.execute("DELETE FROM note_tags WHERE user_id = ?", (user_id,))
94
+ conn.execute("DELETE FROM note_links WHERE user_id = ?", (user_id,))
95
+ finally:
96
+ conn.close()
97
+
98
+ # Re-index all notes
99
+ indexed_count = 0
100
+ for note in notes:
101
+ try:
102
+ note_data = vault_service.read_note(user_id, note["path"])
103
+ indexer_service.index_note(user_id, note_data)
104
+ indexed_count += 1
105
+ except Exception as e:
106
+ print(f"Failed to index {note['path']}: {e}")
107
+
108
+ # Update index health
109
+ conn = db_service.connect()
110
+ try:
111
+ with conn:
112
+ conn.execute(
113
+ """
114
+ INSERT INTO index_health (user_id, note_count, last_full_rebuild, last_incremental_update)
115
+ VALUES (?, ?, datetime('now'), datetime('now'))
116
+ ON CONFLICT(user_id) DO UPDATE SET
117
+ note_count = excluded.note_count,
118
+ last_full_rebuild = excluded.last_full_rebuild,
119
+ last_incremental_update = excluded.last_incremental_update
120
+ """,
121
+ (user_id, indexed_count),
122
+ )
123
+ finally:
124
+ conn.close()
125
+
126
+ return RebuildResponse(
127
+ status="completed",
128
+ notes_indexed=indexed_count,
129
+ )
130
+ except Exception as e:
131
+ raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
132
+
133
+
134
+ __all__ = ["router", "RebuildResponse"]
135
+
backend/src/api/routes/notes.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP API routes for note operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+ from urllib.parse import unquote
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query
10
+
11
+ from ...models.note import Note, NoteSummary, NoteUpdate, NoteCreate
12
+ from ...services.database import DatabaseService
13
+ from ...services.indexer import IndexerService
14
+ from ...services.vault import VaultService
15
+ from ..middleware import AuthContext, get_auth_context
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ class ConflictError(Exception):
21
+ """Raised when optimistic concurrency check fails."""
22
+
23
+ def __init__(self, message: str = "Version conflict detected"):
24
+ self.message = message
25
+ super().__init__(self.message)
26
+
27
+
28
+ @router.get("/api/notes", response_model=list[NoteSummary])
29
+ async def list_notes(
30
+ folder: Optional[str] = Query(None, description="Optional folder filter"),
31
+ auth: AuthContext = Depends(get_auth_context),
32
+ ):
33
+ """List all notes in the vault."""
34
+ user_id = auth.user_id
35
+ vault_service = VaultService()
36
+
37
+ try:
38
+ notes = vault_service.list_notes(user_id, folder=folder)
39
+
40
+ summaries = []
41
+ for note in notes:
42
+ # list_notes returns {path, title, last_modified}
43
+ updated = note.get("last_modified")
44
+ if not isinstance(updated, datetime):
45
+ updated = datetime.now()
46
+
47
+ summaries.append(
48
+ NoteSummary(
49
+ note_path=note["path"],
50
+ title=note["title"],
51
+ updated=updated,
52
+ )
53
+ )
54
+ return summaries
55
+ except Exception as e:
56
+ raise HTTPException(status_code=500, detail=f"Failed to list notes: {str(e)}")
57
+
58
+
59
+ @router.post("/api/notes", response_model=Note, status_code=201)
60
+ async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)):
61
+ """Create a new note."""
62
+ user_id = auth.user_id
63
+ vault_service = VaultService()
64
+ indexer_service = IndexerService()
65
+ db_service = DatabaseService()
66
+
67
+ try:
68
+ note_path = create.note_path
69
+
70
+ # Check if note already exists
71
+ try:
72
+ vault_service.read_note(user_id, note_path)
73
+ raise HTTPException(status_code=409, detail=f"Note already exists: {note_path}")
74
+ except FileNotFoundError:
75
+ pass # Good, note doesn't exist
76
+
77
+ # Prepare metadata
78
+ metadata = create.metadata.model_dump() if create.metadata else {}
79
+ if create.title:
80
+ metadata["title"] = create.title
81
+
82
+ # Write note to vault
83
+ written_note = vault_service.write_note(
84
+ user_id,
85
+ note_path,
86
+ body=create.body,
87
+ metadata=metadata,
88
+ title=create.title
89
+ )
90
+
91
+ # Index the note
92
+ new_version = indexer_service.index_note(user_id, written_note)
93
+
94
+ # Update index health
95
+ conn = db_service.connect()
96
+ try:
97
+ with conn:
98
+ indexer_service.update_index_health(conn, user_id)
99
+ finally:
100
+ conn.close()
101
+
102
+ # Return created note
103
+ created = written_note["metadata"].get("created")
104
+ updated_ts = written_note["metadata"].get("updated")
105
+
106
+ if isinstance(created, str):
107
+ created = datetime.fromisoformat(created.replace("Z", "+00:00"))
108
+ elif not isinstance(created, datetime):
109
+ created = datetime.now()
110
+
111
+ if isinstance(updated_ts, str):
112
+ updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
113
+ elif not isinstance(updated_ts, datetime):
114
+ updated_ts = created
115
+
116
+ return Note(
117
+ user_id=user_id,
118
+ note_path=note_path,
119
+ version=new_version,
120
+ title=written_note["title"],
121
+ metadata=written_note["metadata"],
122
+ body=written_note["body"],
123
+ created=created,
124
+ updated=updated_ts,
125
+ size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
126
+ )
127
+ except HTTPException:
128
+ raise
129
+ except ValueError as e:
130
+ raise HTTPException(status_code=400, detail=str(e))
131
+ except Exception as e:
132
+ raise HTTPException(status_code=500, detail=f"Failed to create note: {str(e)}")
133
+
134
+
135
+ @router.get("/api/notes/{path:path}", response_model=Note)
136
+ async def get_note(path: str, auth: AuthContext = Depends(get_auth_context)):
137
+ """Get a specific note by path."""
138
+ user_id = auth.user_id
139
+ vault_service = VaultService()
140
+ db_service = DatabaseService()
141
+
142
+ try:
143
+ # URL decode the path
144
+ note_path = unquote(path)
145
+
146
+ # Read note from vault
147
+ note_data = vault_service.read_note(user_id, note_path)
148
+
149
+ # Get version from index
150
+ conn = db_service.connect()
151
+ try:
152
+ cursor = conn.execute(
153
+ "SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
154
+ (user_id, note_path),
155
+ )
156
+ row = cursor.fetchone()
157
+ version = row["version"] if row else 1
158
+ finally:
159
+ conn.close()
160
+
161
+ # Parse metadata
162
+ metadata = note_data.get("metadata", {})
163
+ created = metadata.get("created")
164
+ updated = metadata.get("updated")
165
+
166
+ if isinstance(created, str):
167
+ created = datetime.fromisoformat(created.replace("Z", "+00:00"))
168
+ elif not isinstance(created, datetime):
169
+ created = datetime.now()
170
+
171
+ if isinstance(updated, str):
172
+ updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
173
+ elif not isinstance(updated, datetime):
174
+ updated = created
175
+
176
+ return Note(
177
+ user_id=user_id,
178
+ note_path=note_path,
179
+ version=version,
180
+ title=note_data["title"],
181
+ metadata=metadata,
182
+ body=note_data["body"],
183
+ created=created,
184
+ updated=updated,
185
+ size_bytes=note_data.get("size_bytes", len(note_data["body"].encode("utf-8"))),
186
+ )
187
+ except FileNotFoundError:
188
+ raise HTTPException(status_code=404, detail=f"Note not found: {path}")
189
+ except Exception as e:
190
+ raise HTTPException(status_code=500, detail=f"Failed to read note: {str(e)}")
191
+
192
+
193
+ @router.put("/api/notes/{path:path}", response_model=Note)
194
+ async def update_note(
195
+ path: str,
196
+ update: NoteUpdate,
197
+ auth: AuthContext = Depends(get_auth_context),
198
+ ):
199
+ """Update a note with optimistic concurrency control."""
200
+ user_id = auth.user_id
201
+ vault_service = VaultService()
202
+ indexer_service = IndexerService()
203
+ db_service = DatabaseService()
204
+
205
+ try:
206
+ # URL decode the path
207
+ note_path = unquote(path)
208
+
209
+ # Check version if provided
210
+ if update.if_version is not None:
211
+ conn = db_service.connect()
212
+ try:
213
+ cursor = conn.execute(
214
+ "SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
215
+ (user_id, note_path),
216
+ )
217
+ row = cursor.fetchone()
218
+ current_version = row["version"] if row else 0
219
+
220
+ if current_version != update.if_version:
221
+ raise ConflictError(
222
+ f"Version conflict: expected {update.if_version}, got {current_version}"
223
+ )
224
+ finally:
225
+ conn.close()
226
+
227
+ # Prepare metadata
228
+ metadata = update.metadata.model_dump() if update.metadata else {}
229
+ if update.title:
230
+ metadata["title"] = update.title
231
+
232
+ # Write note to vault
233
+ written_note = vault_service.write_note(
234
+ user_id,
235
+ note_path,
236
+ body=update.body,
237
+ metadata=metadata,
238
+ title=update.title
239
+ )
240
+
241
+ # Index the note
242
+ new_version = indexer_service.index_note(user_id, written_note)
243
+
244
+ # Update index health
245
+ conn = db_service.connect()
246
+ try:
247
+ with conn:
248
+ indexer_service.update_index_health(conn, user_id)
249
+ finally:
250
+ conn.close()
251
+
252
+ # Return updated note
253
+ created = written_note["metadata"].get("created")
254
+ updated_ts = written_note["metadata"].get("updated")
255
+
256
+ if isinstance(created, str):
257
+ created = datetime.fromisoformat(created.replace("Z", "+00:00"))
258
+ elif not isinstance(created, datetime):
259
+ created = datetime.now()
260
+
261
+ if isinstance(updated_ts, str):
262
+ updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
263
+ elif not isinstance(updated_ts, datetime):
264
+ updated_ts = created
265
+
266
+ return Note(
267
+ user_id=user_id,
268
+ note_path=note_path,
269
+ version=new_version,
270
+ title=written_note["title"],
271
+ metadata=written_note["metadata"],
272
+ body=written_note["body"],
273
+ created=created,
274
+ updated=updated_ts,
275
+ size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
276
+ )
277
+ except ConflictError as e:
278
+ raise HTTPException(status_code=409, detail=str(e))
279
+ except FileNotFoundError:
280
+ raise HTTPException(status_code=404, detail=f"Note not found: {path}")
281
+ except ValueError as e:
282
+ raise HTTPException(status_code=400, detail=str(e))
283
+ except Exception as e:
284
+ raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}")
285
+
286
+
287
+ __all__ = ["router", "ConflictError"]
288
+
backend/src/api/routes/search.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP API routes for search operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+ from urllib.parse import unquote
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query
10
+ from pydantic import BaseModel
11
+
12
+ from ...models.index import Tag
13
+ from ...models.search import SearchResult
14
+ from ...services.database import DatabaseService
15
+ from ...services.indexer import IndexerService
16
+ from ..middleware import AuthContext, get_auth_context
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ class BacklinkResult(BaseModel):
22
+ """Result from backlinks query."""
23
+
24
+ note_path: str
25
+ title: str
26
+
27
+
28
+ @router.get("/api/search", response_model=list[SearchResult])
29
+ async def search_notes(
30
+ q: str = Query(..., min_length=1, max_length=256),
31
+ auth: AuthContext = Depends(get_auth_context),
32
+ ):
33
+ """Full-text search across all notes."""
34
+ user_id = auth.user_id
35
+ indexer_service = IndexerService()
36
+
37
+ try:
38
+ results = indexer_service.search_notes(user_id, q, limit=50)
39
+
40
+ search_results = []
41
+ for result in results:
42
+ # Use snippet from search results
43
+ snippet = result.get("snippet", "")
44
+
45
+ updated = result.get("updated")
46
+ if isinstance(updated, str):
47
+ updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
48
+ elif not isinstance(updated, datetime):
49
+ updated = datetime.now()
50
+
51
+ search_results.append(
52
+ SearchResult(
53
+ note_path=result["path"],
54
+ title=result["title"],
55
+ snippet=snippet,
56
+ score=result.get("score", 0.0),
57
+ updated=updated,
58
+ )
59
+ )
60
+
61
+ return search_results
62
+ except Exception as e:
63
+ raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
64
+
65
+
66
+ @router.get("/api/backlinks/{path:path}", response_model=list[BacklinkResult])
67
+ async def get_backlinks(path: str, auth: AuthContext = Depends(get_auth_context)):
68
+ """Get all notes that link to this note."""
69
+ user_id = auth.user_id
70
+ indexer_service = IndexerService()
71
+
72
+ try:
73
+ # URL decode the path
74
+ note_path = unquote(path)
75
+
76
+ backlinks = indexer_service.get_backlinks(user_id, note_path)
77
+
78
+ return [
79
+ BacklinkResult(
80
+ note_path=backlink["path"],
81
+ title=backlink["title"],
82
+ )
83
+ for backlink in backlinks
84
+ ]
85
+ except Exception as e:
86
+ raise HTTPException(status_code=500, detail=f"Failed to get backlinks: {str(e)}")
87
+
88
+
89
+ @router.get("/api/tags", response_model=list[Tag])
90
+ async def get_tags(auth: AuthContext = Depends(get_auth_context)):
91
+ """Get all tags with usage counts."""
92
+ user_id = auth.user_id
93
+ indexer_service = IndexerService()
94
+
95
+ try:
96
+ tags = indexer_service.get_tags(user_id)
97
+
98
+ return [
99
+ Tag(tag_name=tag["tag"], count=tag["count"])
100
+ for tag in tags
101
+ ]
102
+ except Exception as e:
103
+ raise HTTPException(status_code=500, detail=f"Failed to get tags: {str(e)}")
104
+
105
+
106
+ __all__ = ["router", "BacklinkResult"]
107
+
backend/src/mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) server implementation."""
2
+
3
+ from .server import mcp
4
+
5
+ __all__ = ["mcp"]
backend/src/mcp/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (317 Bytes). View file
 
backend/src/mcp/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (293 Bytes). View file
 
backend/src/mcp/__pycache__/server.cpython-311.pyc ADDED
Binary file (12.1 kB). View file
 
backend/src/mcp/__pycache__/server.cpython-313.pyc ADDED
Binary file (10.3 kB). View file
 
backend/src/mcp/server.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastMCP server exposing vault and indexing tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from typing import Any, Dict, List, Optional
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+ from fastmcp import FastMCP
13
+ from pydantic import Field
14
+
15
+ # Load environment variables from the backend/.env file regardless of CWD
16
+ ENV_PATH = Path(__file__).resolve().parents[2] / ".env"
17
+ load_dotenv(dotenv_path=ENV_PATH)
18
+
19
+ from ..services import IndexerService, VaultNote, VaultService
20
+ from ..services.auth import AuthError, AuthService
21
+
22
+ try:
23
+ from fastmcp.server.http import _current_http_request # type: ignore
24
+ except ImportError: # pragma: no cover
25
+ _current_http_request = None
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ mcp = FastMCP(
30
+ "obsidian-docs-viewer",
31
+ instructions=(
32
+ "Multi-tenant vault tools. STDIO uses user_id 'local-dev'; HTTP mode must validate each "
33
+ "request with JWT.sub. Note paths must be relative '.md' ≤256 chars without '..' or '\\'. "
34
+ "Frontmatter is YAML: tags are string arrays and 'version' is reserved. Notes must be ≤1 MiB; "
35
+ "writes refresh created/updated timestamps and synchronously update the search index; deletes "
36
+ "clear index rows and backlinks. Wikilinks use [[...]] slug matching (prefer same folder, else "
37
+ "lexicographic). Search ranking = bm25(title*3, body*1) + recency bonus (+1 ≤7d, +0.5 ≤30d)."
38
+ ),
39
+ )
40
+
41
+ vault_service = VaultService()
42
+ indexer_service = IndexerService()
43
+ auth_service = AuthService()
44
+
45
+
46
+ def _current_user_id() -> str:
47
+ """Resolve the acting user ID (local mode defaults to local-dev)."""
48
+ # HTTP transport (hosted) uses Authorization headers
49
+ if _current_http_request is not None:
50
+ try:
51
+ request = _current_http_request.get() # type: ignore[call-arg]
52
+ except LookupError:
53
+ request = None
54
+ if request is not None:
55
+ header = request.headers.get("Authorization")
56
+ if not header:
57
+ raise PermissionError("Authorization header required")
58
+ scheme, _, token = header.partition(" ")
59
+ if scheme.lower() != "bearer" or not token:
60
+ raise PermissionError("Authorization header must be 'Bearer <token>'")
61
+ try:
62
+ payload = auth_service.validate_jwt(token)
63
+ except AuthError as exc:
64
+ raise PermissionError(exc.message) from exc
65
+ os.environ.setdefault("LOCAL_USER_ID", payload.sub)
66
+ return payload.sub
67
+
68
+ # STDIO / local fall back
69
+ return os.getenv("LOCAL_USER_ID", "local-dev")
70
+
71
+
72
+ def _note_to_response(note: VaultNote) -> Dict[str, Any]:
73
+ return {
74
+ "path": note["path"],
75
+ "title": note["title"],
76
+ "metadata": dict(note.get("metadata") or {}),
77
+ "body": note.get("body", ""),
78
+ }
79
+
80
+
81
+ @mcp.tool(
82
+ name="list_notes",
83
+ description="List notes in the vault (optionally scoped to a folder).",
84
+ )
85
+ def list_notes(
86
+ folder: Optional[str] = Field(
87
+ default=None,
88
+ description="Optional relative folder (trim '/' ; no '..' or '\\').",
89
+ ),
90
+ ) -> List[Dict[str, Any]]:
91
+ start_time = time.time()
92
+ user_id = _current_user_id()
93
+
94
+ notes = vault_service.list_notes(user_id, folder=folder)
95
+
96
+ duration_ms = (time.time() - start_time) * 1000
97
+ logger.info(
98
+ "MCP tool called",
99
+ extra={
100
+ "tool_name": "list_notes",
101
+ "user_id": user_id,
102
+ "folder": folder or "(root)",
103
+ "result_count": len(notes),
104
+ "duration_ms": f"{duration_ms:.2f}",
105
+ },
106
+ )
107
+
108
+ return [
109
+ {
110
+ "path": entry["path"],
111
+ "title": entry["title"],
112
+ "last_modified": entry["last_modified"].isoformat(),
113
+ }
114
+ for entry in notes
115
+ ]
116
+
117
+
118
+ @mcp.tool(name="read_note", description="Read a Markdown note with metadata and body.")
119
+ def read_note(
120
+ path: str = Field(
121
+ ..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
122
+ ),
123
+ ) -> Dict[str, Any]:
124
+ start_time = time.time()
125
+ user_id = _current_user_id()
126
+
127
+ note = vault_service.read_note(user_id, path)
128
+
129
+ duration_ms = (time.time() - start_time) * 1000
130
+ logger.info(
131
+ "MCP tool called",
132
+ extra={
133
+ "tool_name": "read_note",
134
+ "user_id": user_id,
135
+ "note_path": path,
136
+ "duration_ms": f"{duration_ms:.2f}",
137
+ },
138
+ )
139
+
140
+ return _note_to_response(note)
141
+
142
+
143
+ @mcp.tool(
144
+ name="write_note",
145
+ description="Create or update a note. Automatically updates frontmatter timestamps and search index.",
146
+ )
147
+ def write_note(
148
+ path: str = Field(
149
+ ..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
150
+ ),
151
+ body: str = Field(..., description="Markdown body ≤1 MiB."),
152
+ title: Optional[str] = Field(
153
+ default=None,
154
+ description="Optional title override; otherwise frontmatter/H1/filename is used.",
155
+ ),
156
+ metadata: Optional[Dict[str, Any]] = Field(
157
+ default=None,
158
+ description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
159
+ ),
160
+ ) -> Dict[str, Any]:
161
+ start_time = time.time()
162
+ user_id = _current_user_id()
163
+
164
+ note = vault_service.write_note(
165
+ user_id,
166
+ path,
167
+ title=title,
168
+ metadata=metadata,
169
+ body=body,
170
+ )
171
+ indexer_service.index_note(user_id, note)
172
+
173
+ duration_ms = (time.time() - start_time) * 1000
174
+ logger.info(
175
+ "MCP tool called",
176
+ extra={
177
+ "tool_name": "write_note",
178
+ "user_id": user_id,
179
+ "note_path": path,
180
+ "duration_ms": f"{duration_ms:.2f}",
181
+ },
182
+ )
183
+
184
+ return {"status": "ok", "path": path}
185
+
186
+
187
+ @mcp.tool(name="delete_note", description="Delete a note and remove it from the index.")
188
+ def delete_note(
189
+ path: str = Field(
190
+ ..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
191
+ ),
192
+ ) -> Dict[str, str]:
193
+ start_time = time.time()
194
+ user_id = _current_user_id()
195
+
196
+ vault_service.delete_note(user_id, path)
197
+ indexer_service.delete_note_index(user_id, path)
198
+
199
+ duration_ms = (time.time() - start_time) * 1000
200
+ logger.info(
201
+ "MCP tool called",
202
+ extra={
203
+ "tool_name": "delete_note",
204
+ "user_id": user_id,
205
+ "note_path": path,
206
+ "duration_ms": f"{duration_ms:.2f}",
207
+ },
208
+ )
209
+
210
+ return {"status": "ok"}
211
+
212
+
213
+ @mcp.tool(
214
+ name="search_notes",
215
+ description="Full-text search with snippets and recency-aware scoring.",
216
+ )
217
+ def search_notes(
218
+ query: str = Field(..., description="Non-empty search query (bm25 + recency)."),
219
+ limit: int = Field(50, ge=1, le=100, description="Result cap between 1 and 100."),
220
+ ) -> List[Dict[str, Any]]:
221
+ start_time = time.time()
222
+ user_id = _current_user_id()
223
+
224
+ results = indexer_service.search_notes(user_id, query, limit=limit)
225
+
226
+ duration_ms = (time.time() - start_time) * 1000
227
+ logger.info(
228
+ "MCP tool called",
229
+ extra={
230
+ "tool_name": "search_notes",
231
+ "user_id": user_id,
232
+ "query": query,
233
+ "limit": limit,
234
+ "result_count": len(results),
235
+ "duration_ms": f"{duration_ms:.2f}",
236
+ },
237
+ )
238
+
239
+ return [
240
+ {
241
+ "path": row["path"],
242
+ "title": row["title"],
243
+ "snippet": row["snippet"],
244
+ }
245
+ for row in results
246
+ ]
247
+
248
+
249
+ @mcp.tool(
250
+ name="get_backlinks", description="List notes that reference the target note."
251
+ )
252
+ def get_backlinks(
253
+ path: str = Field(
254
+ ..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
255
+ ),
256
+ ) -> List[Dict[str, Any]]:
257
+ user_id = _current_user_id()
258
+ backlinks = indexer_service.get_backlinks(user_id, path)
259
+ return backlinks
260
+
261
+
262
+ @mcp.tool(name="get_tags", description="List tags and associated note counts.")
263
+ def get_tags() -> List[Dict[str, Any]]:
264
+ user_id = _current_user_id()
265
+ return indexer_service.get_tags(user_id)
266
+
267
+
268
+ if __name__ == "__main__":
269
+ transport = os.getenv("MCP_TRANSPORT", "stdio").strip().lower()
270
+
271
+ # Configure HTTP transport with custom port if specified
272
+ if transport == "http":
273
+ # Prefer MCP_PORT, then platform-provided PORT, with a sane default.
274
+ port = int(os.getenv("MCP_PORT"))
275
+ # Bind to all interfaces by default for hosted environments (HF Spaces).
276
+ host = os.getenv("MCP_HOST", "0.0.0.0")
277
+ logger.info(
278
+ "Starting MCP server",
279
+ extra={"transport": transport, "host": host, "port": port},
280
+ )
281
+ mcp.run(transport=transport, host=host, port=port)
282
+ else:
283
+ logger.info("Starting MCP server", extra={"transport": transport})
284
+ mcp.run(transport=transport)
backend/src/models/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for data validation and serialization."""
2
+
3
+ from .auth import JWTPayload, TokenResponse
4
+ from .index import IndexHealth, Tag, Wikilink
5
+ from .note import Note, NoteCreate, NoteMetadata, NoteSummary, NoteUpdate
6
+ from .search import SearchRequest, SearchResult
7
+ from .user import HFProfile, User
8
+
9
+ __all__ = [
10
+ "User",
11
+ "HFProfile",
12
+ "Note",
13
+ "NoteMetadata",
14
+ "NoteCreate",
15
+ "NoteUpdate",
16
+ "NoteSummary",
17
+ "Wikilink",
18
+ "Tag",
19
+ "IndexHealth",
20
+ "SearchResult",
21
+ "SearchRequest",
22
+ "TokenResponse",
23
+ "JWTPayload",
24
+ ]
25
+
backend/src/models/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (863 Bytes). View file
 
backend/src/models/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (721 Bytes). View file
 
backend/src/models/__pycache__/auth.cpython-311.pyc ADDED
Binary file (1.64 kB). View file
 
backend/src/models/__pycache__/auth.cpython-313.pyc ADDED
Binary file (1.39 kB). View file