Adds a FastMCP server mounted onto the existing FastAPI app, so it ships in the
same container and answers on the same host rather than needing a process of
its own.
Fifteen tools, all read-only: catalog search and browse, nutrition facts and
health scores, healthier alternatives, per-store inventory and pricing,
discounts, trending and sales analytics. Each wraps a service function the REST
API already reaches through a GET. None of the write or compute endpoints are
exposed, because a tool list is chosen from by a model rather than by a person,
and catalog generation or model retraining is not something to leave one tool
call away.
The tools are written by hand rather than generated from the OpenAPI schema.
Mirroring all 63 routes would work, but a model picks a tool by reading its
description, and 63 near-identical generated entries is a worse thing to choose
from than a dozen written to be told apart.
Authentication reuses the access token from POST /api/auth/login - no separate
MCP credential, the same Principal and expiry as the REST API. The check lives
in one middleware rather than at the top of each tool, so a tool added later
cannot be left unguarded by forgetting a line. Note that this requires passing
include={"authorization"} to get_http_headers(), which strips that header by
default to avoid forwarding it downstream; without it the header is invisible
and every request looks unauthenticated, valid ones included.
Mounting a sub-app does not run its lifespan - only the outermost app's is
executed - so the MCP app's lifespan is chained through the FastAPI one. Without
that the endpoint accepts a connection and then fails on the first message with
a session manager that was never started.
Adds GET /api/mcp/info and POST /api/mcp/tools/{name} for the admin UI. The MCP
endpoint speaks streamable HTTP with session handling, so rendering a tool list
in the browser would otherwise mean shipping a full MCP client in React.
fastmcp needs Python 3.10+. The image is 3.11; on anything older the import
fails and the REST API starts without the MCP endpoint instead of not starting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
9.6 KiB
Python
231 lines
9.6 KiB
Python
"""
|
|
FastAPI application entry point.
|
|
|
|
Run with:
|
|
uvicorn app.main:app --reload --port 8000
|
|
|
|
(see backend/README.md / the project documentation for full setup steps)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.infrastructure.persistence import restore_bundled_assets
|
|
from app.infrastructure.settings import API_CORS_ORIGINS
|
|
from app.api.routers import health, brands, search, chat, catalog, system
|
|
from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin
|
|
from app.api.routers import nutrition, nutrition_admin, upload
|
|
from app.api.routers import auth, user_products, admin_train, mcp_info
|
|
from app.services.store_db import ensure_store_intelligence_schema
|
|
from app.services.nutrition_db import ensure_nutrition_schema
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from app.mcp_server import MCP_PATH, build_http_app as build_mcp_app
|
|
|
|
_mcp_app = build_mcp_app()
|
|
except Exception as e: # pragma: no cover - depends on an optional dependency
|
|
# fastmcp needs Python >=3.10. The image is 3.11, but an older local
|
|
# interpreter should lose the MCP endpoint, not the whole API.
|
|
logging.getLogger(__name__).warning("MCP server unavailable: %s", e)
|
|
_mcp_app = None
|
|
MCP_PATH = "/mcp"
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
"""
|
|
Schema checks and auto-seeding, kicked off without blocking startup.
|
|
|
|
The work runs on a daemon thread rather than being awaited: it touches
|
|
Postgres, which may be slow or briefly unreachable on a cold boot, and the
|
|
server answering /api/health in under a second is what lets the container
|
|
healthcheck pass while that settles.
|
|
"""
|
|
|
|
# Runs before the thread below, and synchronously: the seed catalogs and
|
|
# model artifacts have to be in place before the first request can read
|
|
# them, and it is a handful of file copies on first boot, nothing on every
|
|
# boot after that.
|
|
try:
|
|
restore_bundled_assets()
|
|
except Exception as e:
|
|
logger.warning("Could not restore bundled assets: %s", e)
|
|
|
|
def _async_init():
|
|
try:
|
|
ensure_store_intelligence_schema()
|
|
ensure_nutrition_schema()
|
|
from app.api.routers.system import _run_background_auto_seed
|
|
_run_background_auto_seed()
|
|
except Exception as e:
|
|
logger.warning("Startup background init warning: %s", e)
|
|
|
|
threading.Thread(target=_async_init, daemon=True).start()
|
|
|
|
# The mounted MCP app carries its own lifespan, which starts the session
|
|
# manager its request handler depends on. Mounting a sub-app does NOT run
|
|
# that lifespan - only the outermost app's is executed - so without this
|
|
# the endpoint exists, accepts a connection, and then fails on the first
|
|
# message with a session manager that was never started.
|
|
if _mcp_app is not None:
|
|
async with _mcp_app.lifespan(_mcp_app):
|
|
yield
|
|
else:
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="Brand Product Search Engine - RAG API",
|
|
description=(
|
|
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. "
|
|
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
|
|
),
|
|
version="3.2.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
# When the app is served same-origin (the frontend's nginx proxies /api/* to
|
|
# this service), API_CORS_ORIGINS is empty and no cross-origin request is ever
|
|
# made. It is populated only when the API is also published on its own host -
|
|
# see api.{$DOMAIN} in the Caddyfile.
|
|
#
|
|
# A wildcard origin and credentialed requests are mutually exclusive under the
|
|
# CORS spec: browsers reject `Access-Control-Allow-Origin: *` on any request
|
|
# carrying credentials. Sending both is a silent misconfiguration - the server
|
|
# looks configured while every browser call fails - so a wildcard turns
|
|
# credentials off explicitly and says so in the log.
|
|
_allow_credentials = "*" not in API_CORS_ORIGINS
|
|
if not _allow_credentials:
|
|
logger.warning(
|
|
"API_CORS_ORIGINS contains '*': disabling allow_credentials, because "
|
|
"browsers reject credentialed cross-origin requests to a wildcard origin. "
|
|
"List the exact origins instead if you need cookies or Authorization headers."
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=API_CORS_ORIGINS,
|
|
allow_credentials=_allow_credentials,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# A wrong origin list fails only in the browser, as an opaque "blocked by CORS"
|
|
# with a perfectly healthy 200 in the server log - so state the effective list
|
|
# at startup, where it can actually be compared against the frontend's URL.
|
|
logger.info(
|
|
"CORS allowed origins: %s",
|
|
", ".join(API_CORS_ORIGINS) if API_CORS_ORIGINS else "(none - same-origin only)",
|
|
)
|
|
if API_CORS_ORIGINS and all(
|
|
o.startswith(("http://localhost", "http://127.0.0.1")) for o in API_CORS_ORIGINS
|
|
):
|
|
logger.warning(
|
|
"API_CORS_ORIGINS lists only localhost origins (%s). If this API is "
|
|
"published on a domain and the frontend is served from a different one, "
|
|
"every browser request will be blocked. Set API_CORS_ORIGINS to the "
|
|
"frontend's exact origin, e.g. https://catalogue.nearle.ai.in",
|
|
", ".join(API_CORS_ORIGINS),
|
|
)
|
|
|
|
app.include_router(health.router, prefix="/api")
|
|
app.include_router(auth.router, prefix="/api")
|
|
app.include_router(user_products.router, prefix="/api")
|
|
app.include_router(admin_train.router, prefix="/api")
|
|
app.include_router(system.router, prefix="/api")
|
|
app.include_router(brands.router, prefix="/api")
|
|
app.include_router(search.router, prefix="/api")
|
|
app.include_router(chat.router, prefix="/api")
|
|
app.include_router(catalog.router, prefix="/api")
|
|
app.include_router(stores.router, prefix="/api")
|
|
app.include_router(discounts.router, prefix="/api")
|
|
app.include_router(store_analytics.router, prefix="/api")
|
|
app.include_router(trending.router, prefix="/api")
|
|
app.include_router(recommendations.router, prefix="/api")
|
|
app.include_router(store_admin.router, prefix="/api")
|
|
app.include_router(nutrition.router, prefix="/api")
|
|
app.include_router(nutrition_admin.router, prefix="/api")
|
|
app.include_router(upload.router, prefix="/api")
|
|
app.include_router(mcp_info.router, prefix="/api")
|
|
|
|
# MCP lives outside /api on purpose: it is a protocol endpoint for AI clients,
|
|
# not part of the REST surface, and the catch-all SPA route below only skips
|
|
# paths it recognises. Mounted rather than routed because it is a whole ASGI
|
|
# app with its own request handling.
|
|
if _mcp_app is not None:
|
|
app.mount(MCP_PATH, _mcp_app)
|
|
logger.info("MCP server mounted at %s", MCP_PATH)
|
|
|
|
# Serve built frontend static files if dist exists (single-port unified
|
|
# deployment). Off in the normal setup: the React app is served by its own
|
|
# nginx on catalogue.nearle.ai.in and this API answers on
|
|
# mcp.catalogue.nearle.ai.in, so no dist/ is present here and the JSON root
|
|
# handler at the bottom of this file is what responds to /.
|
|
#
|
|
# The candidates cover both repo layouts - the sibling checkout is named
|
|
# `catalogue_frontend`, and only `frontend` was checked before, so this branch
|
|
# could never activate even when a build was sitting right next to it.
|
|
# FRONTEND_DIST_DIR overrides both when the build lands somewhere else.
|
|
_dist_override = os.getenv("FRONTEND_DIST_DIR", "").strip()
|
|
_repo_root = Path(__file__).resolve().parents[2]
|
|
_dist_candidates = (
|
|
[Path(_dist_override)]
|
|
if _dist_override
|
|
else [
|
|
_repo_root / "catalogue_frontend" / "dist",
|
|
_repo_root / "frontend" / "dist",
|
|
Path(__file__).resolve().parents[1] / "frontend" / "dist",
|
|
]
|
|
)
|
|
FRONTEND_DIST = next(
|
|
(p for p in _dist_candidates if (p / "assets").exists()),
|
|
_dist_candidates[0],
|
|
)
|
|
|
|
if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists():
|
|
logger.info("Serving built frontend from %s", FRONTEND_DIST)
|
|
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
|
|
|
|
@app.get("/{full_path:path}")
|
|
def serve_frontend(full_path: str):
|
|
# API and docs paths are served by the routers registered above, so
|
|
# this catch-all only sees them when the path genuinely doesn't exist.
|
|
# Returning None there would answer 200 with a `null` body - an unknown
|
|
# endpoint would look like a successful call to any client. 404 is the
|
|
# honest answer, and it matters now that the API is also reachable
|
|
# directly at api.{$DOMAIN} rather than only behind the frontend.
|
|
# "mcp" is in this list for the case where the MCP app failed to load:
|
|
# the mount would be absent, and without this the SPA fallback would
|
|
# answer an MCP client with index.html and a 200.
|
|
if full_path.startswith(("api", "docs", "redoc", "openapi.json", "mcp")):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
file_path = FRONTEND_DIST / full_path
|
|
if file_path.exists() and file_path.is_file():
|
|
return FileResponse(file_path)
|
|
return FileResponse(FRONTEND_DIST / "index.html")
|
|
else:
|
|
@app.get("/")
|
|
def root() -> dict:
|
|
return {
|
|
"service": "Brand Product Search Engine - RAG API",
|
|
"docs": "/docs",
|
|
"health": "/api/health",
|
|
"system_status": "/api/system/status",
|
|
}
|