Add the MCP server inspector page
Admin-only page at /mcp showing the server's connection details, a copy-pasteable client config, and every tool with its arguments - plus a console to run one against live data and see what an AI client would get back. It talks to /api/mcp/*, not to the /mcp endpoint directly. That endpoint speaks streamable HTTP with session handling, and implementing a client for it in the browser purely to render a tool list would be a lot of machinery for a read-only admin screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
src/App.jsx
11
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 */}
|
||||
<Route
|
||||
path="/mcp"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<McpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
|
||||
</Link>
|
||||
|
||||
{/* MCP tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/mcp"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/mcp' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Plug className="h-3.5 w-3.5" /> MCP
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Admin Panel tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
|
||||
325
src/pages/McpPage.jsx
Normal file
325
src/pages/McpPage.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import {
|
||||
Plug, Copy, Check, ChevronDown, ChevronRight, Play,
|
||||
Loader2, AlertTriangle, RefreshCw, Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
/*
|
||||
* Inspector for the MCP server the backend mounts at /mcp.
|
||||
*
|
||||
* It talks to /api/mcp/*, not to /mcp directly: the MCP endpoint speaks
|
||||
* streamable HTTP with session handling, and implementing a client for it in
|
||||
* the browser to render a tool list would be a lot of machinery for a read-only
|
||||
* admin screen. The backend exposes the same server over plain REST instead.
|
||||
*/
|
||||
|
||||
const TAG_STYLES = {
|
||||
catalog: 'bg-amber-500/15 text-amber-700 border-amber-500/30',
|
||||
nutrition: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/30',
|
||||
stores: 'bg-blue-500/15 text-blue-700 border-blue-500/30',
|
||||
analytics: 'bg-violet-500/15 text-violet-700 border-violet-500/30',
|
||||
};
|
||||
|
||||
function CopyButton({ text, label = 'Copy' }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = async () => {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-900/15 bg-white px-2.5 py-1.5 text-[11px] font-semibold text-slate-600 hover:bg-slate-50 transition active:scale-95"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-emerald-600" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? 'Copied' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border-b border-slate-100 last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-start gap-3 p-4 text-left hover:bg-slate-50/70 transition"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs font-bold text-ink-950">{tool.name}</code>
|
||||
{tool.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
||||
TAG_STYLES[t] || 'bg-slate-500/15 text-slate-700 border-slate-500/30'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-600">{summary}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-slate-100 bg-slate-50/50 p-4">
|
||||
{tool.description && (
|
||||
<pre className="whitespace-pre-wrap font-sans text-[11px] leading-relaxed text-slate-600">
|
||||
{tool.description}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{tool.parameters.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tool.parameters.map((p) => (
|
||||
<div key={p.name} className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<label className="sm:w-44 shrink-0 text-[11px] font-medium text-slate-700">
|
||||
<code className="font-mono">{p.name}</code>
|
||||
{p.required && <span className="ml-1 text-rose-500">*</span>}
|
||||
<span className="ml-1.5 text-[10px] font-normal text-slate-400">{p.type}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={args[p.name] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={running}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-ink-950 px-3 py-1.5 text-[11px] font-bold text-paper-100 hover:bg-ink-900 transition active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||
Run tool
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-2.5 text-[11px] text-rose-700">
|
||||
<pre className="whitespace-pre-wrap font-mono">{error}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== null && (
|
||||
<pre className="max-h-72 overflow-auto rounded-lg border border-ink-900/10 bg-ink-950 p-3 text-[11px] leading-relaxed text-emerald-300">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <token from POST /api/auth/login>' },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
|
||||
<NavigationHeader
|
||||
title="MCP Server"
|
||||
subtitle="Model Context Protocol endpoint for AI clients"
|
||||
icon={Plug}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6 px-6 py-8">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading MCP server details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 text-xs text-rose-700">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && !info.enabled && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-3.5 text-xs text-amber-800">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">The MCP server is not running.</p>
|
||||
<p className="mt-0.5">{info.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.enabled && (
|
||||
<>
|
||||
{/* Connection panel */}
|
||||
<section className="rounded-2xl border border-ink-900/10 bg-white p-5 shadow-xs">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<h2 className="text-sm font-bold">
|
||||
{info.name}
|
||||
{info.version && (
|
||||
<span className="ml-1.5 text-[11px] font-normal text-slate-400">
|
||||
v{info.version}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
|
||||
{info.tool_count} tools
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-900/15 bg-white px-2.5 py-1.5 text-[11px] font-semibold text-slate-600 hover:bg-slate-50 transition"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Endpoint
|
||||
</dt>
|
||||
<dd className="mt-0.5 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg bg-slate-50 px-2 py-1 font-mono text-[11px] text-ink-950">
|
||||
{info.url}
|
||||
</code>
|
||||
<CopyButton text={info.url} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Transport / Auth
|
||||
</dt>
|
||||
<dd className="mt-0.5 text-[11px] text-slate-600">
|
||||
<code className="font-mono">{info.transport}</code> · {info.auth}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
<Terminal className="h-3 w-3" /> Client configuration
|
||||
</span>
|
||||
<CopyButton text={snippet} label="Copy config" />
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-xl border border-ink-900/10 bg-ink-950 p-3.5 text-[11px] leading-relaxed text-slate-300">
|
||||
{snippet}
|
||||
</pre>
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
The token is the same one the app signs in with. Get one from{' '}
|
||||
<code className="font-mono">POST /api/auth/login</code>; it expires, so a
|
||||
long-lived client will need to refresh it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tools */}
|
||||
<section className="overflow-hidden rounded-2xl border border-ink-900/10 bg-white shadow-xs">
|
||||
<div className="border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 className="text-sm font-bold">Tools</h2>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||
All read-only. Expand a tool to see its arguments and run it against live data.
|
||||
</p>
|
||||
</div>
|
||||
{info.tools.map((t) => (
|
||||
<ToolRow key={t.name} tool={t} />
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user