This commit is contained in:
sriram
2026-08-13 16:11:24 +05:30
22 changed files with 1742 additions and 34 deletions

167
app/api/routers/mcp_info.py Normal file
View File

@@ -0,0 +1,167 @@
"""
REST inspector for the MCP server.
The MCP endpoint itself speaks streamable HTTP with session handling, which a
browser cannot usefully talk to without a full MCP client implementation. Rather
than ship one in React, these two endpoints expose what the admin page needs -
what tools exist, and what one returns - as ordinary JSON over the REST API the
frontend already authenticates against.
Admin-only. The tool inventory describes the whole catalog API surface, and the
invoke endpoint executes a tool; neither is something to leave open just because
the tools happen to be read-only.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from app.api.deps import require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mcp", tags=["mcp"])
def _server():
"""The FastMCP instance, or None when the optional dependency is absent."""
try:
from app.mcp_server import mcp
return mcp
except Exception: # pragma: no cover - depends on fastmcp being installed
return None
def _public_url(request: Request, path: str) -> str:
"""
The URL an external MCP client should connect to.
Built from the forwarded headers rather than a configured constant so it is
right on whichever host the request arrived on - the API's own domain, the
frontend's nginx, or localhost in development. uvicorn runs with
--proxy-headers, so request.url already reflects the external scheme.
"""
base = str(request.base_url).rstrip("/")
return f"{base}{path}"
@router.get("/info", dependencies=[Depends(require_admin)])
async def mcp_info(request: Request) -> Dict[str, Any]:
"""Describe the MCP server and list its tools, for the admin page."""
mcp = _server()
if mcp is None:
return {
"enabled": False,
"reason": (
"The fastmcp package is not installed, or failed to import. It "
"requires Python 3.10 or newer."
),
"tools": [],
}
from app.mcp_server import MCP_PATH
try:
# run_middleware=False: the auth middleware reads an Authorization header
# from an MCP request context that does not exist on this REST call. The
# caller is already established as an admin by the route dependency.
tools = await mcp.list_tools(run_middleware=False)
except Exception as exc: # noqa: BLE001
logger.exception("Could not list MCP tools")
raise HTTPException(status_code=500, detail=f"Could not list MCP tools: {exc}")
described: List[Dict[str, Any]] = []
for tool in sorted(tools, key=lambda t: t.name):
# Server-side FunctionTool exposes its JSON Schema as `parameters`.
# `inputSchema` is the wire-format name, present on the client-side Tool
# an MCP client receives - checked second so this keeps working if a
# future version converges on one name.
schema = getattr(tool, "parameters", None) or getattr(tool, "inputSchema", None) or {}
properties = schema.get("properties", {}) or {}
required = set(schema.get("required", []) or [])
described.append(
{
"name": tool.name,
"description": (tool.description or "").strip(),
"tags": sorted(getattr(tool, "tags", set()) or []),
"parameters": [
{
"name": pname,
"type": pinfo.get("type") or "any",
"description": (pinfo.get("description") or "").strip(),
"required": pname in required,
"default": pinfo.get("default"),
}
for pname, pinfo in properties.items()
],
}
)
return {
"enabled": True,
"name": mcp.name,
"version": getattr(mcp, "version", None),
"url": _public_url(request, MCP_PATH),
"transport": "http",
"auth": "Bearer token from POST /api/auth/login",
"tool_count": len(described),
"tools": described,
}
@router.post("/tools/{tool_name}", dependencies=[Depends(require_admin)])
async def invoke_tool(
tool_name: str,
arguments: Optional[Dict[str, Any]] = Body(default=None),
) -> Dict[str, Any]:
"""
Run one MCP tool and return its result - the admin page's "try it" console.
Exists so the server can be checked from the browser without installing an
MCP client. Every tool is read-only, so this executes the same call an AI
client would make, with the same code path.
"""
mcp = _server()
if mcp is None:
raise HTTPException(status_code=503, detail="The MCP server is not available.")
try:
# Same reasoning as above: this request authenticated through the REST
# guard, so the MCP auth middleware would have no headers to inspect.
tools = {t.name: t for t in await mcp.list_tools(run_middleware=False)}
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Could not list MCP tools: {exc}")
if tool_name not in tools:
raise HTTPException(
status_code=404,
detail=f"No MCP tool named '{tool_name}'. Known tools: {', '.join(sorted(tools))}.",
)
try:
result = await mcp.call_tool(tool_name, arguments or {}, run_middleware=False)
except Exception as exc: # noqa: BLE001
# A tool raising ToolError is a normal outcome worth showing verbatim -
# "no nutrition data for this product" is an answer, not a server fault.
return {"tool": tool_name, "ok": False, "error": str(exc)}
return {"tool": tool_name, "ok": True, "result": _jsonable(result)}
def _jsonable(result: Any) -> Any:
"""Reduce a ToolResult to something FastAPI can serialise."""
for attr in ("structured_content", "data"):
value = getattr(result, attr, None)
if value is not None:
return value
content = getattr(result, "content", None)
if content is None:
return result
out = []
for block in content:
text = getattr(block, "text", None)
out.append(text if text is not None else str(block))
return out

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import io
import json
import uuid
import logging
import pandas as pd

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from app.api.deps import require_permission
from app.infrastructure.settings import SEED_CATALOG_DIR
from app.services.vector_store import (
upsert_brand_products,