Expose the catalog to AI clients over MCP at /mcp
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>
This commit is contained in:
167
app/api/routers/mcp_info.py
Normal file
167
app/api/routers/mcp_info.py
Normal 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
|
||||
Reference in New Issue
Block a user