diff --git a/src/App.jsx b/src/App.jsx index c0091fb..46042ec 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -26,6 +26,7 @@ const AnalyticsPage = lazy(() => const NutritionAnalyticsPage = lazy(() => import('./pages/NutritionAnalyticsPage').then((m) => ({ default: m.NutritionAnalyticsPage })) ); +const McpPage = lazy(() => import('./pages/McpPage').then((m) => ({ default: m.McpPage }))); function PageFallback({ label }) { return ( @@ -133,6 +134,16 @@ export default function App() { } /> + {/* MCP Server Route - RESTRICTED TO ADMIN ONLY */} + + + + } + /> + {/* Catch-all redirect */} } /> diff --git a/src/api/client.js b/src/api/client.js index 316b1f5..3249230 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -289,6 +289,18 @@ export const api = { }), uploadUserProductsFile: (file) => upload('/api/user/products/upload-file', file), + + // --- MCP (Model Context Protocol) --- + // The /mcp endpoint itself speaks streamable HTTP with sessions, which the + // browser can't talk to without a full MCP client. These two REST endpoints + // expose the same server for the admin page - see app/api/routers/mcp_info.py. + getMcpInfo: () => request('/api/mcp/info'), + + invokeMcpTool: (toolName, args = {}) => + request(`/api/mcp/tools/${encodeURIComponent(toolName)}`, { + method: 'POST', + body: JSON.stringify(args), + }), }; export { ApiError }; diff --git a/src/components/NavigationHeader.jsx b/src/components/NavigationHeader.jsx index 6ac6c37..7d157fa 100644 --- a/src/components/NavigationHeader.jsx +++ b/src/components/NavigationHeader.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react'; +import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid, Plug } from 'lucide-react'; import { useAuth } from '../context/useAuth'; export function NavigationHeader({ @@ -157,6 +157,18 @@ export function NavigationHeader({ Nutrition + {/* MCP tab ONLY visible to Admin role */} + {role === 'admin' && ( + + MCP + + )} + {/* Admin Panel tab ONLY visible to Admin role */} {role === 'admin' && ( { + try { + await navigator.clipboard.writeText(text); + } catch { + // clipboard is unavailable over plain http on some browsers; the snippet + // is on screen and selectable, so this is a missing convenience only + return; + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + ); +} + +function ToolRow({ tool }) { + const [open, setOpen] = useState(false); + const [args, setArgs] = useState({}); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const run = async () => { + setRunning(true); + setError(null); + setResult(null); + try { + // Blank inputs are dropped rather than sent as "": a tool's optional + // args default to null server-side, and an empty string is a value. + const payload = Object.fromEntries( + Object.entries(args).filter(([, v]) => v !== '' && v !== undefined) + ); + const res = await api.invokeMcpTool(tool.name, payload); + if (res.ok) setResult(res.result); + else setError(res.error); + } catch (e) { + setError(e.message); + } finally { + setRunning(false); + } + }; + + const summary = (tool.description || '').split('\n')[0]; + + return ( +
+ + + {open && ( +
+ {tool.description && ( +
+              {tool.description}
+            
+ )} + + {tool.parameters.length > 0 && ( +
+ {tool.parameters.map((p) => ( +
+ + setArgs((a) => ({ ...a, [p.name]: e.target.value }))} + placeholder={ + p.default !== null && p.default !== undefined + ? `default: ${p.default}` + : p.description.slice(0, 48) + } + className="w-full rounded-lg border border-ink-900/15 bg-white px-2.5 py-1.5 text-xs focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500" + /> +
+ ))} +
+ )} + + + + {error && ( +
+
{error}
+
+ )} + + {result !== null && ( +
+              {JSON.stringify(result, null, 2)}
+            
+ )} +
+ )} +
+ ); +} + +export function McpPage() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setInfo(await api.getMcpInfo()); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const snippet = info?.url + ? JSON.stringify( + { + mcpServers: { + 'nearle-catalogue': { + type: 'http', + url: info.url, + headers: { Authorization: 'Bearer ' }, + }, + }, + }, + null, + 2 + ) + : ''; + + return ( +
+ + +
+ {loading && ( +
+ Loading MCP server details... +
+ )} + + {error && ( +
+ +
{error}
+
+ )} + + {info && !info.enabled && ( +
+ +
+

The MCP server is not running.

+

{info.reason}

+
+
+ )} + + {info?.enabled && ( + <> + {/* Connection panel */} +
+
+
+ +

+ {info.name} + {info.version && ( + + v{info.version} + + )} +

+ + {info.tool_count} tools + +
+ +
+ +
+
+
+ Endpoint +
+
+ + {info.url} + + +
+
+
+
+ Transport / Auth +
+
+ {info.transport} · {info.auth} +
+
+
+ +
+
+ + Client configuration + + +
+
+                  {snippet}
+                
+

+ The token is the same one the app signs in with. Get one from{' '} + POST /api/auth/login; it expires, so a + long-lived client will need to refresh it. +

+
+
+ + {/* Tools */} +
+
+

Tools

+

+ All read-only. Expand a tool to see its arguments and run it against live data. +

+
+ {info.tools.map((t) => ( + + ))} +
+ + )} +
+
+ ); +}