Coverage for src / documint_mcp / server.py: 0%

592 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 22:30 -0400

1"""FastAPI application for the Documint control plane.""" 

2 

3from __future__ import annotations 

4 

5import hmac 

6import json 

7import time 

8from collections.abc import AsyncGenerator, Awaitable, Callable 

9from contextlib import asynccontextmanager 

10from dataclasses import dataclass 

11from datetime import UTC, datetime 

12from pathlib import Path 

13from typing import Any, cast 

14 

15import jwt 

16import structlog 

17from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response 

18from fastapi.encoders import jsonable_encoder 

19from fastapi.middleware.cors import CORSMiddleware 

20from fastapi.middleware.gzip import GZipMiddleware 

21from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 

22from prometheus_client import Counter, Histogram, generate_latest 

23from slowapi import Limiter, _rate_limit_exceeded_handler 

24from slowapi.errors import RateLimitExceeded 

25from slowapi.util import get_remote_address 

26 

27from .config import settings 

28from .db import EarlyAccessTokenRecord, WaitlistSignupRecord, get_engine, init_db 

29from .db import session_scope as db_session_scope 

30from .check_runs import CheckRunManager 

31from .github import analyze_github_webhook, github_app_manifest, verify_github_signature 

32from .jobs import ( 

33 close_arq_pool, 

34 dispatch_drift, 

35 dispatch_installation_sync, 

36 dispatch_patch, 

37 dispatch_publish, 

38 dispatch_pull_request, 

39 get_arq_pool, 

40) 

41from .models import ( 

42 CLIExchangeRequest, 

43 DriftJobRequest, 

44 MCPRequest, 

45 MCPTool, 

46 PatchCreateRequest, 

47 ProjectCreateRequest, 

48 PublishRequest, 

49 PullRequestCreateRequest, 

50 QueuedJob, 

51 RevalidateRequest, 

52 TokenCreateRequest, 

53 WorkspaceCreateRequest, 

54) 

55from .connectors.notifications import get_notification_dispatcher 

56from .repository import get_service 

57from .utils.cache import get_cache 

58 

59structlog.configure( 

60 processors=[ 

61 structlog.stdlib.filter_by_level, 

62 structlog.stdlib.add_logger_name, 

63 structlog.stdlib.add_log_level, 

64 structlog.stdlib.PositionalArgumentsFormatter(), 

65 structlog.processors.TimeStamper(fmt="iso"), 

66 structlog.processors.StackInfoRenderer(), 

67 structlog.processors.format_exc_info, 

68 structlog.processors.UnicodeDecoder(), 

69 structlog.processors.JSONRenderer(), 

70 ], 

71 context_class=dict, 

72 logger_factory=structlog.stdlib.LoggerFactory(), 

73 wrapper_class=structlog.stdlib.BoundLogger, 

74 cache_logger_on_first_use=True, 

75) 

76 

77logger = structlog.get_logger() 

78REQUEST_COUNT = Counter( 

79 "http_requests_total", "Total HTTP requests", ["method", "endpoint"] 

80) 

81REQUEST_DURATION = Histogram("http_request_duration_seconds", "HTTP request duration") 

82limiter = Limiter(key_func=get_remote_address) 

83security = HTTPBearer(auto_error=False) 

84_jwks_clients: dict[str, jwt.PyJWKClient] = {} 

85 

86 

87@dataclass(frozen=True) 

88class AuthContext: 

89 credential_source: str 

90 user_id: str | None = None 

91 workspace_ids: tuple[str, ...] = () 

92 scopes: tuple[str, ...] = () 

93 is_service: bool = False 

94 

95 

96def _tool_definitions() -> list[MCPTool]: 

97 return [ 

98 MCPTool( 

99 name="list_projects", 

100 description="List persisted Documint projects visible to the current workspace.", 

101 input_schema={ 

102 "type": "object", 

103 "properties": {"workspace_id": {"type": "string"}}, 

104 }, 

105 ), 

106 MCPTool( 

107 name="analyze_repository", 

108 description="Return the current project snapshot, including findings, PRs, and publish history.", 

109 input_schema={ 

110 "type": "object", 

111 "properties": {"project_id": {"type": "string"}}, 

112 }, 

113 ), 

114 MCPTool( 

115 name="detect_doc_drift", 

116 description="Run deterministic doc drift detection for a project.", 

117 input_schema={ 

118 "type": "object", 

119 "properties": { 

120 "project_id": {"type": "string"}, 

121 "signal_type": {"type": "string"}, 

122 "changed_files": {"type": "array", "items": {"type": "string"}}, 

123 }, 

124 "required": ["project_id"], 

125 }, 

126 ), 

127 MCPTool( 

128 name="generate_doc_patch", 

129 description="Draft a reviewable patch for a finding or artifact.", 

130 input_schema={ 

131 "type": "object", 

132 "properties": { 

133 "project_id": {"type": "string"}, 

134 "artifact_id": {"type": "string"}, 

135 "finding_id": {"type": "string"}, 

136 "policy": {"type": "string"}, 

137 }, 

138 }, 

139 ), 

140 MCPTool( 

141 name="review_doc_patch", 

142 description="Return the stored patch draft and its linked artifact trace.", 

143 input_schema={ 

144 "type": "object", 

145 "properties": { 

146 "project_id": {"type": "string"}, 

147 "patch_id": {"type": "string"}, 

148 "artifact_id": {"type": "string"}, 

149 }, 

150 "required": ["project_id"], 

151 }, 

152 ), 

153 MCPTool( 

154 name="publish_preview", 

155 description="Publish repo-backed markdown into Documint-hosted public docs pages.", 

156 input_schema={ 

157 "type": "object", 

158 "properties": {"project_id": {"type": "string"}}, 

159 "required": ["project_id"], 

160 }, 

161 ), 

162 MCPTool( 

163 name="explain_doc_trace", 

164 description="Explain which source files drive a documentation artifact.", 

165 input_schema={ 

166 "type": "object", 

167 "properties": { 

168 "project_id": {"type": "string"}, 

169 "artifact_id": {"type": "string"}, 

170 }, 

171 "required": ["artifact_id"], 

172 }, 

173 ), 

174 MCPTool( 

175 name="get_project_activity", 

176 description="Return the project activity timeline for scans, patches, PRs, and publishes.", 

177 input_schema={ 

178 "type": "object", 

179 "properties": {"project_id": {"type": "string"}}, 

180 "required": ["project_id"], 

181 }, 

182 ), 

183 ] 

184 

185 

186async def verify_token( 

187 request: Request, 

188 credentials: HTTPAuthorizationCredentials | None = Depends(security), 

189) -> AuthContext: 

190 """Verify operator credentials against service, API-token, or Clerk tokens.""" 

191 

192 if credentials is None or not credentials.credentials: 

193 _localhost_hosts = ("127.0.0.1", "::1", "localhost", "testclient") 

194 client_host = getattr(request.client, "host", "") if request.client else "" 

195 if settings.debug and client_host in _localhost_hosts: 

196 return AuthContext( 

197 credential_source="debug", 

198 user_id=( 

199 settings.default_user_id 

200 if settings.auto_bootstrap_defaults 

201 else None 

202 ), 

203 ) 

204 if not settings.auth_token and not settings.clerk_jwks_url: 

205 return AuthContext( 

206 credential_source="debug", 

207 user_id=( 

208 settings.default_user_id 

209 if settings.auto_bootstrap_defaults 

210 else None 

211 ), 

212 ) 

213 raise HTTPException(status_code=401, detail="Authentication required") 

214 

215 token = credentials.credentials 

216 if settings.auth_token and hmac.compare_digest(token, settings.auth_token): 

217 return AuthContext(credential_source="service", is_service=True) 

218 api_token = get_service().authenticate_api_token(token) 

219 if api_token is not None: 

220 return AuthContext( 

221 credential_source="api", 

222 user_id=api_token.user_id, 

223 workspace_ids=(api_token.workspace_id,), 

224 scopes=tuple(api_token.scopes), 

225 ) 

226 claims = _verify_clerk_jwt(token) 

227 if claims is not None: 

228 user = get_service().ensure_clerk_user(claims) 

229 workspaces = get_service().list_workspaces(user.id) 

230 return AuthContext( 

231 credential_source="clerk", 

232 user_id=user.id, 

233 workspace_ids=tuple(item.id for item in workspaces), 

234 ) 

235 raise HTTPException(status_code=401, detail="Invalid token") 

236 

237 

238def _verify_clerk_jwt(token: str) -> dict[str, Any] | None: 

239 if not settings.clerk_jwks_url or not settings.clerk_issuer: 

240 return None 

241 client = _jwks_clients.get(settings.clerk_jwks_url) 

242 if client is None: 

243 client = jwt.PyJWKClient(settings.clerk_jwks_url) 

244 _jwks_clients[settings.clerk_jwks_url] = client 

245 try: 

246 signing_key = client.get_signing_key_from_jwt(token) 

247 decoded = jwt.decode( 

248 token, 

249 signing_key.key, 

250 algorithms=["RS256"], 

251 audience=settings.clerk_audience, 

252 issuer=settings.clerk_issuer, 

253 ) 

254 except Exception: 

255 return None 

256 return decoded 

257 

258 

259def verify_internal_secret( 

260 x_documint_internal: str | None = Header(default=None), 

261) -> None: 

262 if not settings.internal_revalidate_secret: 

263 if settings.debug: 

264 return 

265 raise HTTPException(status_code=503, detail="Internal secret not configured") 

266 if not x_documint_internal or not hmac.compare_digest( 

267 x_documint_internal, settings.internal_revalidate_secret 

268 ): 

269 raise HTTPException(status_code=401, detail="Invalid internal secret") 

270 

271 

272async def _validate_runtime_dependencies() -> None: 

273 errors = settings.production_validation_errors() 

274 if errors: 

275 raise RuntimeError( 

276 "Production configuration is invalid: " + "; ".join(errors) 

277 ) 

278 if not settings.is_production_runtime(): 

279 return 

280 engine = get_engine() 

281 with engine.connect() as connection: 

282 connection.exec_driver_sql("SELECT 1") 

283 cache = await get_cache() 

284 redis_connected = bool(cache.get_stats()["redis_cache"].get("connected")) 

285 if not redis_connected: 

286 raise RuntimeError("Production requires a reachable Redis cache") 

287 await get_arq_pool() 

288 

289 

290@asynccontextmanager 

291async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 

292 logger.info("starting_documint_backend", repo_root=str(settings.repo_root)) 

293 await _validate_runtime_dependencies() 

294 init_db() 

295 cache = await get_cache() 

296 try: 

297 logger.info("cache_initialized", stats=cache.get_stats()) 

298 yield 

299 finally: 

300 await close_arq_pool() 

301 await cache.close() 

302 logger.info("shutting_down_documint_backend") 

303 

304 

305def create_app() -> FastAPI: 

306 app = FastAPI( 

307 title="Documint V1", 

308 description="Persistent control plane for repo-native documentation operations.", 

309 version="0.3.0", 

310 lifespan=lifespan, 

311 docs_url="/docs" if settings.debug else None, 

312 redoc_url="/redoc" if settings.debug else None, 

313 ) 

314 app.add_middleware( 

315 CORSMiddleware, 

316 allow_origins=( 

317 ["http://localhost:3000", "http://127.0.0.1:3000"] 

318 if settings.debug 

319 else [ 

320 "https://documint.xyz", 

321 "https://www.documint.xyz", 

322 settings.app_base_url, 

323 ] 

324 ), 

325 allow_credentials=True, 

326 allow_methods=["DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT"], 

327 allow_headers=["*"], 

328 ) 

329 app.add_middleware(GZipMiddleware, minimum_size=1000) 

330 app.state.limiter = limiter 

331 app.add_exception_handler( 

332 RateLimitExceeded, 

333 cast(Callable[[Request, Exception], Response], _rate_limit_exceeded_handler), 

334 ) 

335 app.add_exception_handler( 

336 PermissionError, 

337 lambda _request, exc: Response( 

338 content=json.dumps({"detail": str(exc)}), 

339 status_code=403, 

340 media_type="application/json", 

341 ), 

342 ) 

343 

344 @app.middleware("http") 

345 async def security_headers( 

346 request: Request, call_next: Callable[[Request], Awaitable[Response]] 

347 ) -> Response: 

348 response = await call_next(request) 

349 response.headers["X-Content-Type-Options"] = "nosniff" 

350 response.headers["X-Frame-Options"] = "DENY" 

351 response.headers["Strict-Transport-Security"] = ( 

352 "max-age=31536000; includeSubDomains" 

353 ) 

354 response.headers["Content-Security-Policy"] = "default-src 'self'" 

355 return response 

356 

357 @app.middleware("http") 

358 async def metrics_middleware( 

359 request: Request, call_next: Callable[[Request], Awaitable[Response]] 

360 ) -> Response: 

361 start_time = time.time() 

362 response = await call_next(request) 

363 # Use route template to avoid high-cardinality labels from path params 

364 route = request.scope.get("route") 

365 endpoint = route.path if route is not None else request.url.path 

366 REQUEST_COUNT.labels(method=request.method, endpoint=endpoint).inc() 

367 REQUEST_DURATION.observe(time.time() - start_time) 

368 return response 

369 

370 @app.get("/") 

371 async def root() -> dict[str, str]: 

372 return { 

373 "name": "Documint V1", 

374 "project": settings.project_name, 

375 "site": settings.public_base_url, 

376 } 

377 

378 @app.get("/health") 

379 async def health_check() -> dict[str, Any]: 

380 cache = await get_cache() 

381 runtime = get_service().runtime_status() 

382 return { 

383 "status": "healthy", 

384 "project_id": settings.project_id, 

385 "repo_root": runtime.repo_root, 

386 "docs_root": runtime.docs_root, 

387 "cache": cache.get_stats(), 

388 "runtime": jsonable_encoder(runtime), 

389 } 

390 

391 @app.post("/waitlist") 

392 @limiter.limit("5/minute") 

393 async def join_waitlist(request: Request) -> dict[str, str]: 

394 body = await request.json() 

395 email = body.get("email", "").strip().lower() 

396 source = body.get("source", "landing")[:64] 

397 if not email or "@" not in email: 

398 raise HTTPException(status_code=422, detail="Valid email required") 

399 try: 

400 from sqlalchemy import select as _select 

401 with db_session_scope() as session: 

402 existing = session.scalar( 

403 _select(WaitlistSignupRecord).where(WaitlistSignupRecord.email == email) 

404 ) 

405 if existing is None: 

406 session.add(WaitlistSignupRecord(email=email, source=source)) 

407 logger.info("waitlist_signup", email=email, source=source) 

408 except Exception as exc: 

409 logger.warning("waitlist_db_error", error=str(exc)) 

410 return {"status": "ok", "message": "You're on the list!"} 

411 

412 @app.post("/early-access") 

413 @limiter.limit("10/minute") 

414 async def early_access(request: Request) -> dict[str, str]: 

415 import secrets as _secrets 

416 body = await request.json() 

417 email = body.get("email", "").strip().lower() 

418 answer = body.get("answer", "").strip().replace(" ", "") 

419 

420 if not email or "@" not in email: 

421 raise HTTPException(status_code=422, detail="Valid email required.") 

422 

423 # The Look and Say sequence: 1 → 11 → 21 → 1211 → 111221 → 312211 

424 correct_answer = "312211" 

425 

426 if answer == correct_answer: 

427 token = "dm_early_" + _secrets.token_urlsafe(24) 

428 try: 

429 with db_session_scope() as session: 

430 existing = session.query(EarlyAccessTokenRecord).filter_by(email=email).first() 

431 if existing: 

432 return { 

433 "status": "access_granted", 

434 "token": existing.token, 

435 "message": "You already have access.", 

436 } 

437 import uuid as _uuid 

438 record = EarlyAccessTokenRecord( 

439 id=_uuid.uuid4().hex, 

440 token=token, 

441 email=email, 

442 source="brain_teaser", 

443 ) 

444 session.add(record) 

445 logger.info("early_access_granted", email=email, token=token[:12]) 

446 except Exception as exc: 

447 logger.warning("early_access_db_error", error=str(exc)) 

448 return { 

449 "status": "access_granted", 

450 "token": token, 

451 "message": "You think recursively. Access granted.", 

452 } 

453 else: 

454 # Wrong answer → add to waitlist 

455 try: 

456 with db_session_scope() as session: 

457 existing = session.query(WaitlistSignupRecord).filter_by(email=email).first() 

458 if existing is None: 

459 session.add(WaitlistSignupRecord(email=email, source="brain_teaser_failed")) 

460 logger.info("waitlist_signup_from_teaser", email=email) 

461 except Exception as exc: 

462 logger.warning("waitlist_db_error", error=str(exc)) 

463 return { 

464 "status": "waitlisted", 

465 "message": "Not quite. You're on the waitlist — we'll reach out when we expand access.", 

466 } 

467 

468 @app.get("/metrics") 

469 async def metrics() -> Response: 

470 return Response(generate_latest(), media_type="text/plain") 

471 

472 @app.get("/runtime") 

473 async def runtime() -> Any: 

474 return jsonable_encoder(get_service().runtime_status()) 

475 

476 @app.get("/snapshot") 

477 async def snapshot( 

478 project_id: str | None = None, 

479 actor: AuthContext = Depends(verify_token), 

480 ) -> Any: 

481 return jsonable_encoder( 

482 get_service().snapshot(project_id=project_id, user_id=_actor_user_id(actor)) 

483 ) 

484 

485 @app.get("/me") 

486 async def me(actor: AuthContext = Depends(verify_token)) -> Any: 

487 return jsonable_encoder(get_service().me(user_id=_actor_user_id(actor))) 

488 

489 @app.get("/workspaces") 

490 async def list_workspaces(actor: AuthContext = Depends(verify_token)) -> Any: 

491 return jsonable_encoder( 

492 get_service().list_workspaces(user_id=_actor_user_id(actor)) 

493 ) 

494 

495 @app.post("/workspaces") 

496 @limiter.limit("10/minute") 

497 async def create_workspace( 

498 request: Request, 

499 payload: WorkspaceCreateRequest, 

500 actor: AuthContext = Depends(verify_token), 

501 ) -> Any: 

502 del request 

503 return jsonable_encoder( 

504 get_service().create_workspace(payload, user_id=_actor_user_id(actor)) 

505 ) 

506 

507 @app.post("/admin/bootstrap-self") 

508 @limiter.limit("5/minute") 

509 async def bootstrap_self( 

510 request: Request, 

511 actor: AuthContext = Depends(verify_token), 

512 ) -> Any: 

513 del request 

514 if not actor.is_service and not settings.debug: 

515 raise HTTPException( 

516 status_code=403, detail="Bootstrap requires service auth" 

517 ) 

518 return jsonable_encoder(get_service().bootstrap_self()) 

519 

520 @app.get("/projects") 

521 async def list_projects( 

522 workspace_id: str | None = None, 

523 actor: AuthContext = Depends(verify_token), 

524 ) -> Any: 

525 return jsonable_encoder( 

526 get_service().list_projects( 

527 workspace_id=workspace_id, user_id=_actor_user_id(actor) 

528 ) 

529 ) 

530 

531 @app.post("/projects") 

532 @limiter.limit("10/minute") 

533 async def create_project( 

534 request: Request, 

535 payload: ProjectCreateRequest, 

536 actor: AuthContext = Depends(verify_token), 

537 ) -> Any: 

538 del request 

539 return jsonable_encoder( 

540 get_service().create_project(payload, user_id=_actor_user_id(actor)) 

541 ) 

542 

543 @app.get("/projects/{project_id}") 

544 async def get_project( 

545 project_id: str, 

546 actor: AuthContext = Depends(verify_token), 

547 ) -> Any: 

548 return jsonable_encoder( 

549 get_service().get_project(project_id, user_id=_actor_user_id(actor)) 

550 ) 

551 

552 @app.get("/jobs/{job_id}") 

553 async def get_job( 

554 job_id: str, 

555 actor: AuthContext = Depends(verify_token), 

556 ) -> Any: 

557 return jsonable_encoder( 

558 get_service().get_job(job_id, user_id=_actor_user_id(actor)) 

559 ) 

560 

561 @app.get("/projects/{project_id}/jobs") 

562 async def list_project_jobs( 

563 project_id: str, 

564 actor: AuthContext = Depends(verify_token), 

565 ) -> Any: 

566 return jsonable_encoder( 

567 get_service().list_jobs(project_id, user_id=_actor_user_id(actor)) 

568 ) 

569 

570 @app.get("/sources") 

571 async def list_sources(actor: AuthContext = Depends(verify_token)) -> Any: 

572 return jsonable_encoder( 

573 get_service().list_sources(user_id=_actor_user_id(actor)) 

574 ) 

575 

576 @app.get("/projects/{project_id}/findings") 

577 async def list_findings( 

578 project_id: str, 

579 actor: AuthContext = Depends(verify_token), 

580 ) -> Any: 

581 return jsonable_encoder( 

582 get_service().list_findings(project_id, user_id=_actor_user_id(actor)) 

583 ) 

584 

585 @app.post("/projects/{project_id}/rescan") 

586 @limiter.limit("30/minute") 

587 async def rescan_project( 

588 request: Request, 

589 project_id: str, 

590 payload: DriftJobRequest | None = None, 

591 actor: AuthContext = Depends(verify_token), 

592 ) -> Any: 

593 del request 

594 drift_request = payload or DriftJobRequest(project_id=project_id) 

595 drift_request.project_id = project_id 

596 return jsonable_encoder( 

597 await dispatch_drift(drift_request, user_id=_actor_user_id(actor)) 

598 ) 

599 

600 @app.get("/projects/{project_id}/patches") 

601 async def list_patches( 

602 project_id: str, 

603 actor: AuthContext = Depends(verify_token), 

604 ) -> Any: 

605 return jsonable_encoder( 

606 get_service().list_patches(project_id, user_id=_actor_user_id(actor)) 

607 ) 

608 

609 @app.get("/projects/{project_id}/patches/{patch_id}") 

610 async def get_patch( 

611 project_id: str, 

612 patch_id: str, 

613 actor: AuthContext = Depends(verify_token), 

614 ) -> Any: 

615 return jsonable_encoder( 

616 get_service().get_patch(project_id, patch_id, user_id=_actor_user_id(actor)) 

617 ) 

618 

619 @app.post("/projects/{project_id}/findings/{finding_id}/patch") 

620 @limiter.limit("20/minute") 

621 async def create_patch( 

622 request: Request, 

623 project_id: str, 

624 finding_id: str, 

625 payload: PatchCreateRequest | None = None, 

626 actor: AuthContext = Depends(verify_token), 

627 ) -> Any: 

628 del request 

629 return jsonable_encoder( 

630 await dispatch_patch( 

631 project_id=project_id, 

632 finding_id=finding_id, 

633 policy=payload.policy if payload is not None else "on_demand", 

634 user_id=_actor_user_id(actor), 

635 ) 

636 ) 

637 

638 @app.post("/projects/{project_id}/patches/{patch_id}/approve") 

639 @limiter.limit("20/minute") 

640 async def approve_patch( 

641 request: Request, 

642 project_id: str, 

643 patch_id: str, 

644 actor: AuthContext = Depends(verify_token), 

645 ) -> Any: 

646 del request 

647 try: 

648 return jsonable_encoder( 

649 get_service().approve_patch( 

650 project_id=project_id, 

651 patch_id=patch_id, 

652 user_id=_actor_user_id(actor), 

653 ) 

654 ) 

655 except KeyError as exc: 

656 raise HTTPException(status_code=404, detail=str(exc)) from exc 

657 

658 @app.post("/projects/{project_id}/findings/{finding_id}/approve") 

659 @limiter.limit("20/minute") 

660 async def approve_patch_by_finding( 

661 request: Request, 

662 project_id: str, 

663 finding_id: str, 

664 actor: AuthContext = Depends(verify_token), 

665 ) -> Any: 

666 """Approve the patch associated with a finding (convenience for MCP agents).""" 

667 del request 

668 from .db import DocPatchRecord as DPR 

669 

670 with db_session_scope() as session: 

671 patch_record = session.query(DPR).filter_by( 

672 project_id=project_id, finding_id=finding_id, 

673 ).order_by(DPR.created_at.desc()).first() 

674 if patch_record is None: 

675 raise HTTPException( 

676 status_code=404, 

677 detail=f"No patch found for finding {finding_id}", 

678 ) 

679 patch_id = patch_record.id 

680 try: 

681 return jsonable_encoder( 

682 get_service().approve_patch( 

683 project_id=project_id, 

684 patch_id=patch_id, 

685 user_id=_actor_user_id(actor), 

686 ) 

687 ) 

688 except KeyError as exc: 

689 raise HTTPException(status_code=404, detail=str(exc)) from exc 

690 

691 @app.get("/projects/{project_id}/findings/{finding_id}/symbol-diff") 

692 async def get_symbol_diff( 

693 project_id: str, 

694 finding_id: str, 

695 actor: AuthContext = Depends(verify_token), 

696 ) -> Any: 

697 """Return the symbol-level diff for a specific drift finding.""" 

698 from .db import DriftFindingRecord as DFR 

699 

700 with db_session_scope() as session: 

701 finding = session.get(DFR, finding_id) 

702 if finding is None or finding.project_id != project_id: 

703 raise HTTPException(status_code=404, detail="Finding not found") 

704 changed = getattr(finding, "changed_symbols", None) or [] 

705 return { 

706 "finding_id": finding_id, 

707 "project_id": project_id, 

708 "artifact_id": finding.artifact_key, 

709 "changes": changed, 

710 } 

711 

712 @app.post("/projects/{project_id}/patches/{patch_id}/pr") 

713 @limiter.limit("20/minute") 

714 async def open_pr( 

715 request: Request, 

716 project_id: str, 

717 patch_id: str, 

718 payload: PullRequestCreateRequest | None = None, 

719 actor: AuthContext = Depends(verify_token), 

720 ) -> Any: 

721 del request 

722 return jsonable_encoder( 

723 await dispatch_pull_request( 

724 project_id=project_id, 

725 patch_id=patch_id, 

726 title=payload.title if payload is not None else None, 

727 user_id=_actor_user_id(actor), 

728 ) 

729 ) 

730 

731 @app.get("/projects/{project_id}/publishes") 

732 async def list_publishes( 

733 project_id: str, 

734 actor: AuthContext = Depends(verify_token), 

735 ) -> Any: 

736 return jsonable_encoder( 

737 get_service().list_publishes(project_id, user_id=_actor_user_id(actor)) 

738 ) 

739 

740 @app.get("/projects/{project_id}/publishes/{deployment_id}") 

741 async def get_publish( 

742 project_id: str, 

743 deployment_id: str, 

744 actor: AuthContext = Depends(verify_token), 

745 ) -> Any: 

746 return jsonable_encoder( 

747 get_service().get_publish( 

748 project_id, deployment_id, user_id=_actor_user_id(actor) 

749 ) 

750 ) 

751 

752 @app.get("/projects/{project_id}/activity") 

753 async def get_project_activity( 

754 project_id: str, 

755 actor: AuthContext = Depends(verify_token), 

756 ) -> Any: 

757 return jsonable_encoder( 

758 get_service().list_activity(project_id, user_id=_actor_user_id(actor)) 

759 ) 

760 

761 @app.get("/projects/{project_id}/claude-md") 

762 async def get_claude_md( 

763 project_id: str, 

764 actor: AuthContext = Depends(verify_token), 

765 ) -> Any: 

766 """Get current CLAUDE.md content for a project.""" 

767 from .db import ProjectRecord 

768 

769 with db_session_scope() as session: 

770 project = session.get(ProjectRecord, project_id) 

771 if project is None: 

772 raise HTTPException(status_code=404, detail="Project not found") 

773 content = project.claude_md_content or ( 

774 "# CLAUDE.md\n\n> Generated by Documint\n\n" 

775 "No content yet — trigger a publish to generate." 

776 ) 

777 return {"content": content, "project_id": project_id} 

778 

779 @app.get("/projects/{project_id}/agents-md") 

780 async def get_agents_md( 

781 project_id: str, 

782 actor: AuthContext = Depends(verify_token), 

783 ) -> Any: 

784 """Get current AGENTS.md content for a project.""" 

785 from .db import ProjectRecord 

786 

787 with db_session_scope() as session: 

788 project = session.get(ProjectRecord, project_id) 

789 if project is None: 

790 raise HTTPException(status_code=404, detail="Project not found") 

791 content = project.agents_md_content or ( 

792 "# AGENTS.md\n\n> Generated by Documint\n\n" 

793 "No content yet — trigger a publish to generate." 

794 ) 

795 return {"content": content, "project_id": project_id} 

796 

797 @app.get("/projects/{project_id}/llms-txt") 

798 async def get_llms_txt( 

799 project_id: str, 

800 actor: AuthContext = Depends(verify_token), 

801 ) -> Any: 

802 """Get the llms.txt content for a project (per llmstxt.org spec).""" 

803 from .db import ProjectRecord 

804 

805 with db_session_scope() as session: 

806 project = session.get(ProjectRecord, project_id) 

807 if project is None: 

808 raise HTTPException(status_code=404, detail="Project not found") 

809 content = project.llms_txt_content or ( 

810 "# Project\n\n> Generated by Documint\n\n" 

811 "No content yet — trigger a publish to generate." 

812 ) 

813 return {"content": content, "project_id": project_id} 

814 

815 @app.get("/projects/{project_id}/llms-full-txt") 

816 async def get_llms_full_txt( 

817 project_id: str, 

818 actor: AuthContext = Depends(verify_token), 

819 ) -> Any: 

820 """Get the llms-full.txt content for a project (per llmstxt.org spec).""" 

821 from .db import ProjectRecord 

822 

823 with db_session_scope() as session: 

824 project = session.get(ProjectRecord, project_id) 

825 if project is None: 

826 raise HTTPException(status_code=404, detail="Project not found") 

827 content = project.llms_full_txt_content or ( 

828 "# Project (full)\n\n> Generated by Documint\n\n" 

829 "No content yet — trigger a publish to generate." 

830 ) 

831 return {"content": content, "project_id": project_id} 

832 

833 @app.get("/projects/{project_id}/status") 

834 async def get_project_status( 

835 project_id: str, 

836 actor: AuthContext = Depends(verify_token), 

837 ) -> Any: 

838 """Get a summary of project health: drift status, last scan, open findings count.""" 

839 from .db import DriftFindingRecord, ProjectRecord 

840 

841 with db_session_scope() as session: 

842 project = session.get(ProjectRecord, project_id) 

843 if project is None: 

844 raise HTTPException(status_code=404, detail="Project not found") 

845 open_findings = ( 

846 session.query(DriftFindingRecord) 

847 .filter_by(project_id=project_id, status="open") 

848 .count() 

849 ) 

850 return { 

851 "project_id": project_id, 

852 "name": project.name, 

853 "open_findings": open_findings, 

854 "last_scan": ( 

855 project.last_scanned_at.isoformat() 

856 if project.last_scanned_at 

857 else None 

858 ), 

859 } 

860 

861 @app.get("/projects/{project_id}/coverage") 

862 async def get_coverage( 

863 project_id: str, 

864 actor: AuthContext = Depends(verify_token), 

865 ) -> Any: 

866 """Return per-artifact documentation coverage: what % of symbols are documented.""" 

867 from .repository import DEFAULT_ARTIFACT_SPECS 

868 from .symbol_extractor import extract_symbols 

869 

870 _COVERAGE_EXTENSIONS = frozenset( 

871 {".py", ".rs", ".go", ".ts", ".tsx", ".js", ".jsx"} 

872 ) 

873 

874 service = get_service() 

875 # Verify project access (raises KeyError -> 404 if not found) 

876 try: 

877 service.snapshot(project_id=project_id, user_id=_actor_user_id(actor)) 

878 except KeyError as exc: 

879 raise HTTPException(status_code=404, detail=str(exc)) from exc 

880 

881 root = Path(settings.repo_root).resolve() 

882 total_documented = 0 

883 total_symbols = 0 

884 artifacts_result: list[dict[str, Any]] = [] 

885 

886 for spec in DEFAULT_ARTIFACT_SPECS: 

887 # Collect source files matching source_patterns 

888 source_files: dict[str, str] = {} 

889 for pattern in spec.source_patterns: 

890 for match_path in root.glob(pattern): 

891 if match_path.is_file() and match_path.suffix in _COVERAGE_EXTENSIONS: 

892 try: 

893 content = match_path.read_text(encoding="utf-8", errors="replace") 

894 rel = str(match_path.relative_to(root)) 

895 source_files[rel] = content 

896 except (OSError, ValueError): 

897 continue 

898 

899 # Extract symbols from source files 

900 symbols: list[str] = [] 

901 for path, content in source_files.items(): 

902 for entry in extract_symbols(content, path=path): 

903 if entry.name not in symbols: 

904 symbols.append(entry.name) 

905 

906 if not symbols: 

907 continue 

908 

909 # Read documentation files and check symbol coverage 

910 doc_contents: list[str] = [] 

911 for doc_path in spec.doc_paths: 

912 full_doc = root / doc_path 

913 try: 

914 doc_contents.append( 

915 full_doc.read_text(encoding="utf-8", errors="replace") 

916 ) 

917 except (FileNotFoundError, OSError): 

918 continue 

919 

920 merged_docs = "\n".join(doc_contents) 

921 documented_count = sum(1 for sym in symbols if sym in merged_docs) 

922 

923 pct = (documented_count / len(symbols) * 100) if symbols else 0.0 

924 artifacts_result.append({ 

925 "id": spec.artifact_key, 

926 "name": spec.title, 

927 "documented": documented_count, 

928 "total": len(symbols), 

929 "percentage": round(pct, 1), 

930 }) 

931 total_documented += documented_count 

932 total_symbols += len(symbols) 

933 

934 overall_pct = ( 

935 round(total_documented / total_symbols * 100, 1) 

936 if total_symbols > 0 

937 else 0.0 

938 ) 

939 return { 

940 "project_id": project_id, 

941 "artifacts": artifacts_result, 

942 "overall": { 

943 "documented": total_documented, 

944 "total": total_symbols, 

945 "percentage": overall_pct, 

946 }, 

947 } 

948 

949 @app.get("/workspaces/{workspace_id}/tokens") 

950 async def list_workspace_tokens( 

951 workspace_id: str, 

952 actor: AuthContext = Depends(verify_token), 

953 ) -> Any: 

954 return jsonable_encoder( 

955 get_service().list_api_tokens(workspace_id, user_id=_actor_user_id(actor)) 

956 ) 

957 

958 @app.post("/workspaces/{workspace_id}/tokens") 

959 @limiter.limit("10/minute") 

960 async def create_workspace_token( 

961 request: Request, 

962 workspace_id: str, 

963 payload: TokenCreateRequest, 

964 actor: AuthContext = Depends(verify_token), 

965 ) -> Any: 

966 del request 

967 payload.workspace_id = workspace_id 

968 return jsonable_encoder( 

969 get_service().create_api_token(payload, user_id=_actor_user_id(actor)) 

970 ) 

971 

972 @app.post("/workspaces/{workspace_id}/tokens/{token_id}/revoke") 

973 @limiter.limit("20/minute") 

974 async def revoke_workspace_token( 

975 request: Request, 

976 workspace_id: str, 

977 token_id: str, 

978 actor: AuthContext = Depends(verify_token), 

979 ) -> Any: 

980 del request 

981 try: 

982 return jsonable_encoder( 

983 get_service().revoke_api_token(workspace_id, token_id, user_id=_actor_user_id(actor)) 

984 ) 

985 except ValueError as exc: 

986 from fastapi import HTTPException 

987 raise HTTPException(status_code=404, detail=str(exc)) from exc 

988 

989 @app.get("/integrations/github/app") 

990 async def get_github_app() -> dict[str, Any]: 

991 return github_app_manifest() 

992 

993 @app.get("/integrations/github/installations") 

994 async def list_github_installations( 

995 workspace_id: str, 

996 actor: AuthContext = Depends(verify_token), 

997 ) -> Any: 

998 return jsonable_encoder( 

999 get_service().list_installations( 

1000 workspace_id, user_id=_actor_user_id(actor) 

1001 ) 

1002 ) 

1003 

1004 @app.get("/integrations/github/installations/{installation_id}/repositories") 

1005 async def get_github_installation_repositories( 

1006 installation_id: str, 

1007 actor: AuthContext = Depends(verify_token), 

1008 ) -> Any: 

1009 return jsonable_encoder( 

1010 get_service().list_installation_repositories( 

1011 installation_id, user_id=_actor_user_id(actor) 

1012 ) 

1013 ) 

1014 

1015 @app.post("/integrations/github/installations/{installation_id}/sync") 

1016 @limiter.limit("20/minute") 

1017 async def sync_github_installation( 

1018 request: Request, 

1019 installation_id: str, 

1020 actor: AuthContext = Depends(verify_token), 

1021 ) -> Any: 

1022 del request 

1023 return jsonable_encoder( 

1024 await dispatch_installation_sync( 

1025 installation_id, user_id=_actor_user_id(actor) 

1026 ) 

1027 ) 

1028 

1029 @app.post("/jobs/drift") 

1030 @limiter.limit("30/minute") 

1031 async def run_drift_job( 

1032 request: Request, 

1033 payload: DriftJobRequest, 

1034 actor: AuthContext = Depends(verify_token), 

1035 ) -> Any: 

1036 del request 

1037 return jsonable_encoder( 

1038 await dispatch_drift(payload, user_id=_actor_user_id(actor)) 

1039 ) 

1040 

1041 @app.post("/jobs/publish") 

1042 @limiter.limit("20/minute") 

1043 async def publish_preview( 

1044 request: Request, 

1045 payload: PublishRequest, 

1046 actor: AuthContext = Depends(verify_token), 

1047 ) -> Any: 

1048 del request 

1049 return jsonable_encoder( 

1050 await dispatch_publish(payload.project_id, user_id=_actor_user_id(actor)) 

1051 ) 

1052 

1053 @app.get("/artifacts/{artifact_id}/trace") 

1054 async def get_artifact_trace( 

1055 artifact_id: str, 

1056 project_id: str | None = None, 

1057 actor: AuthContext = Depends(verify_token), 

1058 ) -> Any: 

1059 try: 

1060 return jsonable_encoder( 

1061 get_service().get_artifact_trace( 

1062 artifact_id, 

1063 project_id=project_id, 

1064 user_id=_actor_user_id(actor), 

1065 ) 

1066 ) 

1067 except KeyError as exc: 

1068 raise HTTPException(status_code=404, detail=str(exc)) from exc 

1069 

1070 @app.get("/previews/{preview_id}") 

1071 async def get_preview( 

1072 preview_id: str, 

1073 actor: AuthContext = Depends(verify_token), 

1074 ) -> Any: 

1075 publishes = get_service().list_publishes( 

1076 settings.project_id, user_id=_actor_user_id(actor) 

1077 ) 

1078 for deployment in publishes: 

1079 if deployment.id == preview_id: 

1080 return jsonable_encoder(deployment) 

1081 raise HTTPException(status_code=404, detail="Preview not found") 

1082 

1083 @app.post("/auth/cli/exchange") 

1084 @limiter.limit("10/minute") 

1085 async def exchange_cli_token( 

1086 request: Request, 

1087 payload: CLIExchangeRequest, 

1088 actor: AuthContext = Depends(verify_token), 

1089 ) -> Any: 

1090 del request 

1091 return jsonable_encoder( 

1092 get_service().exchange_cli_token(payload, user_id=_actor_user_id(actor)) 

1093 ) 

1094 

1095 @app.post("/internal/revalidate") 

1096 @limiter.limit("60/minute") 

1097 async def revalidate_publish( 

1098 request: Request, 

1099 payload: RevalidateRequest, 

1100 internal: None = Depends(verify_internal_secret), 

1101 ) -> Any: 

1102 del request, internal 

1103 return jsonable_encoder( 

1104 get_service().revalidate_project(payload.project_id, payload.deployment_id) 

1105 ) 

1106 

1107 @app.get("/public/projects/{workspace_slug}/{project_slug}/docs") 

1108 async def get_public_doc_index(workspace_slug: str, project_slug: str) -> Any: 

1109 return jsonable_encoder( 

1110 get_service().get_public_doc_page(workspace_slug, project_slug, "index") 

1111 ) 

1112 

1113 @app.get("/public/projects/{workspace_slug}/{project_slug}/docs/{page_path:path}") 

1114 async def get_public_doc_page( 

1115 workspace_slug: str, project_slug: str, page_path: str 

1116 ) -> Any: 

1117 try: 

1118 return jsonable_encoder( 

1119 get_service().get_public_doc_page( 

1120 workspace_slug, project_slug, page_path 

1121 ) 

1122 ) 

1123 except KeyError as exc: 

1124 raise HTTPException(status_code=404, detail=str(exc)) from exc 

1125 

1126 @app.post("/integrations/github/webhooks") 

1127 @limiter.limit("60/minute") 

1128 async def handle_github_webhook(request: Request) -> Any: 

1129 body = await request.body() 

1130 verify_github_signature(body, request.headers.get("x-hub-signature-256")) 

1131 try: 

1132 payload = json.loads(body.decode("utf-8")) if body else {} 

1133 except json.JSONDecodeError as exc: 

1134 raise HTTPException( 

1135 status_code=400, detail="Invalid GitHub payload" 

1136 ) from exc 

1137 

1138 event_name = request.headers.get("x-github-event") 

1139 if not event_name: 

1140 raise HTTPException(status_code=400, detail="Missing GitHub event header") 

1141 

1142 action = analyze_github_webhook(event_name=event_name, payload=payload) 

1143 response: dict[str, Any] = { 

1144 "delivery_id": request.headers.get("x-github-delivery"), 

1145 "event": event_name, 

1146 "repository": action.repository, 

1147 "ref": action.ref, 

1148 "installation_id": action.installation_id, 

1149 } 

1150 if not action.should_process: 

1151 get_service().record_webhook_delivery( 

1152 delivery_id=request.headers.get("x-github-delivery"), 

1153 event_name=event_name, 

1154 repository=action.repository, 

1155 action=( 

1156 payload.get("action") 

1157 if isinstance(payload.get("action"), str) 

1158 else None 

1159 ), 

1160 ref=action.ref, 

1161 payload=cast(dict[str, object], payload), 

1162 status="ignored", 

1163 ) 

1164 response["status"] = "ignored" 

1165 response["reason"] = action.reason 

1166 return response 

1167 

1168 if action.kind == "installation_sync" and action.installation_id is not None: 

1169 account = payload.get("installation", {}).get("account", {}) 

1170 repositories = payload.get("repositories") 

1171 repository_list = ( 

1172 repositories 

1173 if isinstance(repositories, list) 

1174 else ( 

1175 payload.get("repositories_added") 

1176 if isinstance(payload.get("repositories_added"), list) 

1177 else None 

1178 ) 

1179 ) 

1180 installation = get_service().upsert_github_installation( 

1181 external_installation_id=action.installation_id, 

1182 account_login=( 

1183 account.get("login") 

1184 if isinstance(account, dict) 

1185 and isinstance(account.get("login"), str) 

1186 else None 

1187 ), 

1188 account_type=( 

1189 account.get("type") 

1190 if isinstance(account, dict) 

1191 and isinstance(account.get("type"), str) 

1192 else None 

1193 ), 

1194 repository_selection=str( 

1195 payload.get("repository_selection") or "selected" 

1196 ), 

1197 repositories=cast(list[dict[str, Any]] | None, repository_list), 

1198 ) 

1199 sync_result = await dispatch_installation_sync(installation.id) 

1200 get_service().record_webhook_delivery( 

1201 delivery_id=request.headers.get("x-github-delivery"), 

1202 event_name=event_name, 

1203 repository=action.repository, 

1204 action=( 

1205 payload.get("action") 

1206 if isinstance(payload.get("action"), str) 

1207 else None 

1208 ), 

1209 ref=action.ref, 

1210 payload=cast(dict[str, object], payload), 

1211 status="processed", 

1212 ) 

1213 response.update( 

1214 { 

1215 "status": "processed", 

1216 "kind": "installation_sync", 

1217 "installation_record_id": installation.id, 

1218 "sync": jsonable_encoder(sync_result), 

1219 } 

1220 ) 

1221 return response 

1222 

1223 if action.drift_request is None or not action.repository: 

1224 response["status"] = "ignored" 

1225 response["reason"] = action.reason or "missing_drift_request" 

1226 return response 

1227 project_id = get_service().resolve_project_id_for_repository(action.repository) 

1228 if project_id is None: 

1229 get_service().record_webhook_delivery( 

1230 delivery_id=request.headers.get("x-github-delivery"), 

1231 event_name=event_name, 

1232 repository=action.repository, 

1233 action=( 

1234 payload.get("action") 

1235 if isinstance(payload.get("action"), str) 

1236 else None 

1237 ), 

1238 ref=action.ref, 

1239 payload=cast(dict[str, object], payload), 

1240 status="ignored", 

1241 ) 

1242 response["status"] = "ignored" 

1243 response["reason"] = "repository_not_onboarded" 

1244 return response 

1245 

1246 action.drift_request.project_id = project_id 

1247 

1248 # Create an in-progress GitHub Check Run for pull_request events so that 

1249 # drift findings surface as inline annotations on the PR. 

1250 check_run_id: int | None = None 

1251 if ( 

1252 event_name == "pull_request" 

1253 and action.installation_id is not None 

1254 and action.ref is not None 

1255 and action.repository is not None 

1256 and "/" in action.repository 

1257 ): 

1258 _repo_owner, _repo_name = action.repository.split("/", 1) 

1259 try: 

1260 check_run_id = CheckRunManager().create_check_run( 

1261 owner=_repo_owner, 

1262 repo=_repo_name, 

1263 head_sha=action.ref, 

1264 installation_id=action.installation_id, 

1265 ) 

1266 except Exception as _check_run_err: 

1267 # Non-fatal: proceed with drift dispatch even if check run creation fails 

1268 logger.warning( 

1269 "check_run_create_failed", 

1270 error=str(_check_run_err), 

1271 repository=action.repository, 

1272 ) 

1273 

1274 run = await dispatch_drift(action.drift_request) 

1275 

1276 # Notify configured connectors (Slack, etc.) about new findings. 

1277 # In inline mode the drift run has already completed so findings 

1278 # are available in the database. In queue mode the job is just 

1279 # enqueued; worker-side notification would be a future enhancement. 

1280 try: 

1281 if isinstance(run, QueuedJob) and run.status == "completed": 

1282 _findings = get_service().list_findings(project_id) 

1283 if _findings: 

1284 dispatcher = get_notification_dispatcher() 

1285 await dispatcher.on_drift_detected(project_id, _findings) 

1286 except Exception: 

1287 # Non-fatal: never let a notification failure break the webhook. 

1288 logger.warning( 

1289 "notification_dispatch_failed", 

1290 project_id=project_id, 

1291 exc_info=True, 

1292 ) 

1293 

1294 get_service().record_webhook_delivery( 

1295 delivery_id=request.headers.get("x-github-delivery"), 

1296 event_name=event_name, 

1297 repository=action.repository, 

1298 action=( 

1299 payload.get("action") 

1300 if isinstance(payload.get("action"), str) 

1301 else None 

1302 ), 

1303 ref=action.ref, 

1304 payload=cast(dict[str, object], payload), 

1305 status="processed", 

1306 ) 

1307 response.update( 

1308 { 

1309 "status": "processed", 

1310 "kind": "drift", 

1311 "processed_at": datetime.now(tz=UTC).isoformat(), 

1312 "project_id": project_id, 

1313 "changed_files_count": len(action.changed_files), 

1314 "check_run_id": check_run_id, 

1315 } 

1316 ) 

1317 if isinstance(run, QueuedJob): 

1318 response.update( 

1319 { 

1320 "job_id": run.job_id, 

1321 "signal_type": action.drift_request.signal_type, 

1322 "job_status": run.status, 

1323 } 

1324 ) 

1325 else: 

1326 response.update( 

1327 { 

1328 "run_id": run.id, 

1329 "signal_type": run.signal.type, 

1330 "findings_count": run.findings_count, 

1331 } 

1332 ) 

1333 return jsonable_encoder(response) 

1334 

1335 @app.get("/mcp") 

1336 async def mcp_manifest() -> dict[str, Any]: 

1337 return { 

1338 "server": {"name": "documint", "version": "0.3.0"}, 

1339 "transport": "http-jsonrpc", 

1340 "tools": jsonable_encoder(_tool_definitions()), 

1341 } 

1342 

1343 @app.post("/mcp") 

1344 async def handle_mcp( 

1345 payload: MCPRequest, 

1346 actor: AuthContext = Depends(verify_token), 

1347 ) -> dict[str, Any]: 

1348 if payload.method == "tools/list": 

1349 return { 

1350 "jsonrpc": "2.0", 

1351 "id": payload.id, 

1352 "result": {"tools": jsonable_encoder(_tool_definitions())}, 

1353 } 

1354 if payload.method != "tools/call": 

1355 raise HTTPException(status_code=400, detail="Unsupported MCP method") 

1356 

1357 tool_name = payload.params.get("name") 

1358 if not isinstance(tool_name, str) or not tool_name: 

1359 raise HTTPException(status_code=400, detail="Missing MCP tool name") 

1360 arguments = payload.params.get("arguments", {}) 

1361 if not isinstance(arguments, dict): 

1362 raise HTTPException( 

1363 status_code=400, detail="MCP tool arguments must be an object" 

1364 ) 

1365 result = _call_tool(tool_name, arguments, user_id=_actor_user_id(actor)) 

1366 return { 

1367 "jsonrpc": "2.0", 

1368 "id": payload.id, 

1369 "result": { 

1370 "content": [{"type": "text", "text": json.dumps(result, indent=2)}], 

1371 "structuredContent": result, 

1372 }, 

1373 } 

1374 

1375 return app 

1376 

1377 

1378def _call_tool( 

1379 tool_name: str, arguments: dict[str, Any], user_id: str | None = None 

1380) -> dict[str, Any]: 

1381 # TODO: Consider making this async and using asyncio.to_thread() for 

1382 # long-running calls like generate_doc_patch to avoid blocking the event loop. 

1383 service = get_service() 

1384 if tool_name == "list_projects": 

1385 return { 

1386 "projects": jsonable_encoder( 

1387 service.list_projects(arguments.get("workspace_id"), user_id=user_id) 

1388 ) 

1389 } 

1390 if tool_name == "analyze_repository": 

1391 return jsonable_encoder( 

1392 service.snapshot(arguments.get("project_id"), user_id=user_id) 

1393 ) 

1394 if tool_name == "detect_doc_drift": 

1395 request = DriftJobRequest(**arguments) 

1396 return jsonable_encoder(service.run_drift(request, user_id=user_id)) 

1397 if tool_name == "generate_doc_patch": 

1398 return jsonable_encoder( 

1399 service.generate_doc_patch( 

1400 project_id=arguments.get("project_id"), 

1401 artifact_id=arguments.get("artifact_id"), 

1402 finding_id=arguments.get("finding_id"), 

1403 policy=arguments.get("policy", "on_demand"), 

1404 user_id=user_id, 

1405 ) 

1406 ) 

1407 if tool_name == "review_doc_patch": 

1408 project_id = cast(str, arguments["project_id"]) 

1409 patch_id = arguments.get("patch_id") 

1410 artifact_id = arguments.get("artifact_id") 

1411 patch = ( 

1412 service.get_patch(project_id, patch_id, user_id=user_id) 

1413 if isinstance(patch_id, str) 

1414 else None 

1415 ) 

1416 resolved_artifact_id = ( 

1417 artifact_id 

1418 if isinstance(artifact_id, str) 

1419 else patch.artifact_id if patch is not None else None 

1420 ) 

1421 if resolved_artifact_id is None: 

1422 raise HTTPException( 

1423 status_code=400, 

1424 detail="review_doc_patch requires patch_id or artifact_id", 

1425 ) 

1426 trace = service.get_artifact_trace( 

1427 resolved_artifact_id, 

1428 project_id=project_id, 

1429 user_id=user_id, 

1430 ) 

1431 return { 

1432 "patch": jsonable_encoder(patch) if patch is not None else None, 

1433 "artifact": jsonable_encoder(trace), 

1434 "checklist": [ 

1435 "Confirm the summary still matches the latest source interfaces.", 

1436 "Check examples and code snippets against source files in the trace.", 

1437 "Publish a preview before opening or merging a docs PR.", 

1438 ], 

1439 } 

1440 if tool_name == "publish_preview": 

1441 return jsonable_encoder( 

1442 service.publish_preview(arguments["project_id"], user_id=user_id) 

1443 ) 

1444 if tool_name == "explain_doc_trace": 

1445 return jsonable_encoder( 

1446 service.explain_trace( 

1447 arguments["artifact_id"], 

1448 project_id=arguments.get("project_id"), 

1449 user_id=user_id, 

1450 ) 

1451 ) 

1452 if tool_name == "get_project_activity": 

1453 return { 

1454 "activity": jsonable_encoder( 

1455 service.list_activity(arguments["project_id"], user_id=user_id) 

1456 ) 

1457 } 

1458 raise HTTPException(status_code=404, detail=f"Unknown MCP tool: {tool_name}") 

1459 

1460 

1461def _actor_user_id(actor: AuthContext | None) -> str | None: 

1462 if actor is None or actor.is_service: 

1463 return None 

1464 return actor.user_id 

1465 

1466 

1467def main() -> None: 

1468 import uvicorn 

1469 

1470 uvicorn.run( 

1471 "documint_mcp.server:create_app", 

1472 factory=True, 

1473 host=settings.host, 

1474 port=settings.port, 

1475 log_level=settings.log_level.lower(), 

1476 reload=settings.debug, 

1477 workers=1 if settings.debug else 2, 

1478 ) 

1479 

1480 

1481if __name__ == "__main__": 

1482 main()