feat(admin): add mcp as main navigation item when enabled, and make it route-aware

This commit is contained in:
dswbx
2025-12-05 15:25:06 +01:00
parent 9958dd7308
commit 40b70e7a20
7 changed files with 136 additions and 111 deletions

View File

@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
ActiveIcon?: any; ActiveIcon?: any;
children?: React.ReactNode; children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
scrollContainerRef?: React.RefObject<HTMLDivElement>;
}; };
export const SectionHeaderAccordionItem = ({ export const SectionHeaderAccordionItem = ({
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
ActiveIcon = IconChevronUp, ActiveIcon = IconChevronUp,
children, children,
renderHeaderRight, renderHeaderRight,
scrollContainerRef,
}: SectionHeaderAccordionItemProps) => ( }: SectionHeaderAccordionItemProps) => (
<div <div
style={{ minHeight: 49 }} style={{ minHeight: 49 }}
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
: "flex-initial cursor-pointer hover:bg-primary/5", : "flex-initial cursor-pointer hover:bg-primary/5",
)} )}
> >
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
<div <div
className={twMerge( className={twMerge(
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2", "flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
@@ -501,14 +505,12 @@ export const SectionHeaderAccordionItem = ({
> >
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} /> <IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2> <h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
<div className="flex flex-grow" /> <div className="flex grow" />
{renderHeaderRight?.({ open })} {renderHeaderRight?.({ open })}
</div> </div>
<div <div
className={twMerge( ref={scrollContainerRef}
"overflow-y-scroll transition-all", className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
open ? " flex-grow" : "h-0 opacity-0",
)}
> >
{children} {children}
</div> </div>
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
export const RouteAwareSectionHeaderAccordionItem = ({ export const RouteAwareSectionHeaderAccordionItem = ({
routePattern, routePattern,
identifier, identifier,
renderHeaderRight,
...props ...props
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & { }: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
// it's optional because it could be provided using the context // it's optional because it could be provided using the context
routePattern?: string; routePattern?: string;
identifier: string; identifier: string;
}) => { }) => {
const { active, toggle } = useRoutePathState(routePattern, identifier); const { active, toggle } = useRoutePathState(routePattern, identifier);
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />; return (
<SectionHeaderAccordionItem
{...props}
open={active}
toggle={toggle}
renderHeaderRight={
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
}
/>
);
}; };
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => ( export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (

View File

@@ -30,28 +30,29 @@ import { useAppShellAdminOptions } from "ui/options";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const { config } = useBknd();
const items: { const items: {
label: string; label: string;
href: string; href: string;
Icon: any; Icon?: any;
exact?: boolean; exact?: boolean;
tooltip?: string; tooltip?: string;
disabled?: boolean; disabled?: boolean;
}[] = [ }[] = [
/*{
label: "Base",
href: "#",
exact: true,
Icon: TbLayoutDashboard,
disabled: true,
tooltip: "Coming soon"
},*/
{ label: "Data", href: "/data", Icon: TbDatabase }, { label: "Data", href: "/data", Icon: TbDatabase },
{ label: "Auth", href: "/auth", Icon: TbFingerprint }, { label: "Auth", href: "/auth", Icon: TbFingerprint },
{ label: "Media", href: "/media", Icon: TbPhoto }, { label: "Media", href: "/media", Icon: TbPhoto },
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
]; ];
if (import.meta.env.DEV || Object.keys(config.flows?.flows ?? {}).length > 0) {
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
}
if (config.server.mcp.enabled) {
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
}
const activeItem = items.find((item) => const activeItem = items.find((item) =>
item.exact ? location === item.href : location.startsWith(item.href), item.exact ? location === item.href : location.startsWith(item.href),
); );

View File

@@ -15,7 +15,10 @@ import { useBkndWindowContext } from "bknd/client";
import ToolsRoutes from "./tools"; import ToolsRoutes from "./tools";
// @ts-ignore // @ts-ignore
const TestRoutes = lazy(() => import("./test")); let TestRoutes: any;
if (import.meta.env.DEV) {
TestRoutes = lazy(() => import("./test"));
}
export function Routes({ export function Routes({
BkndWrapper, BkndWrapper,
@@ -43,11 +46,13 @@ export function Routes({
<Route path="/" nest> <Route path="/" nest>
<Root> <Root>
<Switch> <Switch>
<Route path="/test*" nest> {TestRoutes && (
<Suspense fallback={null}> <Route path="/test*" nest>
<TestRoutes /> <Suspense fallback={null}>
</Suspense> <TestRoutes />
</Route> </Suspense>
</Route>
)}
{children} {children}

View File

@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
return ( return (
<> <>
<Route path="/" component={ToolsIndex} /> <Route path="/" component={ToolsIndex} />
<Route path="/mcp" component={ToolsMcp} /> <Route path="/mcp*" component={ToolsMcp} nest />
</> </>
); );
} }

View File

@@ -8,14 +8,13 @@ import { Empty } from "ui/components/display/Empty";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { appShellStore } from "ui/store"; import { appShellStore } from "ui/store";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
import { Route, Switch } from "wouter";
export default function ToolsMcp() { export default function ToolsMcp() {
useBrowserTitle(["MCP UI"]); useBrowserTitle(["MCP UI"]);
const { config } = useBknd(); const { config } = useBknd();
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
const content = useMcpStore((state) => state.content);
const openSidebar = appShellStore((store) => store.toggleSidebar("default")); const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
const mcpPath = config.server.mcp.path; const mcpPath = config.server.mcp.path;
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
} }
return ( return (
<div className="flex flex-col flex-grow max-w-screen"> <RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
<AppShell.SectionHeader> <div className="flex flex-col flex-grow max-w-screen">
<div className="flex flex-row gap-4 items-center"> <AppShell.SectionHeader>
<McpIcon /> <div className="flex flex-row gap-4 items-center">
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate"> <McpIcon />
MCP UI <AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
</AppShell.SectionHeaderTitle> MCP UI
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2"> </AppShell.SectionHeaderTitle>
<TbWorld /> <div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<div className="min-w-0 flex-1"> <TbWorld />
<span className="block truncate text-sm font-mono leading-none select-text"> <div className="min-w-0 flex-1">
{window.location.origin + mcpPath} <span className="block truncate text-sm font-mono leading-none select-text">
</span> {window.location.origin + mcpPath}
</span>
</div>
</div> </div>
</div> </div>
</div> </AppShell.SectionHeader>
</AppShell.SectionHeader>
<div className="flex h-full"> <div className="flex grow h-full">
<AppShell.Sidebar> <AppShell.Sidebar>
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} /> <Tools.Sidebar />
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
title="Resources" title="Resources"
open={feature === "resources"} identifier="resources"
toggle={() => setFeature("resources")}
>
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
<i>Resources</i>
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.Sidebar>
{feature === "tools" && <Tools.Content />}
{!content && (
<Empty title="No tool selected" description="Please select a tool to continue.">
<Button
variant="primary"
onClick={() => openSidebar()}
className="block md:hidden"
> >
Open Tools <div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
</Button> <i>Resources</i>
</Empty> </div>
)} </AppShell.RouteAwareSectionHeaderAccordionItem>
</AppShell.Sidebar>
<Switch>
<Route path="/tools/:toolName?" component={Tools.Content} />
<Route path="*">
<Empty
title="No tool selected"
description="Please select a tool to continue."
>
<Button
variant="primary"
onClick={() => openSidebar()}
className="block md:hidden"
>
Open Tools
</Button>
</Empty>
</Route>
</Switch>
</div>
</div> </div>
</div> </RoutePathStateProvider>
); );
} }

View File

@@ -3,23 +3,16 @@ import { combine } from "zustand/middleware";
import type { ToolJson } from "jsonv-ts/mcp"; import type { ToolJson } from "jsonv-ts/mcp";
const FEATURES = ["tools", "resources"] as const;
export type Feature = (typeof FEATURES)[number];
export const useMcpStore = create( export const useMcpStore = create(
combine( combine(
{ {
tools: [] as ToolJson[], tools: [] as ToolJson[],
feature: "tools" as Feature | null,
content: null as ToolJson | null,
history: [] as { type: "request" | "response"; data: any }[], history: [] as { type: "request" | "response"; data: any }[],
historyLimit: 50, historyLimit: 50,
historyVisible: false, historyVisible: false,
}, },
(set) => ({ (set) => ({
setTools: (tools: ToolJson[]) => set({ tools }), setTools: (tools: ToolJson[]) => set({ tools }),
setFeature: (feature: Feature) => set({ feature }),
setContent: (content: ToolJson | null) => set({ content }),
addHistory: (type: "request" | "response", data: any) => addHistory: (type: "request" | "response", data: any) =>
set((state) => ({ set((state) => ({
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)], history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],

View File

@@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell";
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb"; import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer"; import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
import { twMerge } from "ui/elements/mocks/tailwind-merge";
import { Field, Form } from "ui/components/form/json-schema-form"; import { Field, Form } from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
@@ -13,17 +12,18 @@ import { appShellStore } from "ui/store";
import { Icon } from "ui/components/display/Icon"; import { Icon } from "ui/components/display/Icon";
import { useMcpClient } from "./hooks/use-mcp-client"; import { useMcpClient } from "./hooks/use-mcp-client";
import { Tooltip } from "@mantine/core"; import { Tooltip } from "@mantine/core";
import { Link } from "ui/components/wouter/Link";
import { useParams } from "wouter";
export function Sidebar({ open, toggle }) { export function Sidebar() {
const client = useMcpClient(); const client = useMcpClient();
const closeSidebar = appShellStore((store) => store.closeSidebar("default")); const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
const tools = useMcpStore((state) => state.tools); const tools = useMcpStore((state) => state.tools);
const setTools = useMcpStore((state) => state.setTools); const setTools = useMcpStore((state) => state.setTools);
const setContent = useMcpStore((state) => state.setContent);
const content = useMcpStore((state) => state.content);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(undefined!);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
}, []); }, []);
useEffect(() => { useEffect(() => {
handleRefresh(); handleRefresh().then(() => {
if (scrollContainerRef.current) {
const selectedTool = scrollContainerRef.current.querySelector(".active");
if (selectedTool) {
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
});
}, []); }, []);
return ( return (
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
scrollContainerRef={scrollContainerRef}
title="Tools" title="Tools"
open={open} identifier="tools"
toggle={toggle} renderHeaderRight={({ active }) => (
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
{error && ( {error && (
<Tooltip label={error}> <Tooltip label={error}>
@@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) {
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none"> <span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length} {tools.length}
</span> </span>
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} /> <IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
</div> </div>
)} )}
> >
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
return ( return (
<AppShell.SidebarLink <AppShell.SidebarLink
key={tool.name} key={tool.name}
className={twMerge( className="flex flex-col items-start h-auto py-3 gap-px"
"flex flex-col items-start h-auto py-3 gap-px", as={Link}
content?.name === tool.name ? "active" : "", href={`/tools/${tool.name}`}
)}
onClick={() => { onClick={() => {
setContent(tool); //setContent(tool);
closeSidebar(); closeSidebar();
}} }}
> >
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
})} })}
</nav> </nav>
</div> </div>
</AppShell.SectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
); );
} }
export function Content() { export function Content() {
const content = useMcpStore((state) => state.content); const { toolName } = useParams();
const tools = useMcpStore((state) => state.tools);
const tool = tools.find((tool) => tool.name === toolName);
const addHistory = useMcpStore((state) => state.addHistory); const addHistory = useMcpStore((state) => state.addHistory);
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema)); const [payload, setPayload] = useState<object>(getTemplate(tool?.inputSchema));
const [result, setResult] = useState<object | null>(null); const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible); const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible); const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = useMcpClient(); const client = useMcpClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null); const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema = const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0; tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
setPayload(getTemplate(content?.inputSchema)); setPayload(getTemplate(tool?.inputSchema));
setResult(null); setResult(null);
}, [content]); }, [toolName]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!content?.name) return; if (!tool?.name) return;
const request = { const request = {
name: content.name, name: tool.name,
arguments: payload, arguments: payload,
}; };
startTransition(async () => { startTransition(async () => {
@@ -131,7 +139,7 @@ export function Content() {
}); });
}, [payload]); }, [payload]);
if (!content) return null; if (!tool) return null;
let readableResult = result; let readableResult = result;
try { try {
@@ -144,11 +152,11 @@ export function Content() {
return ( return (
<Form <Form
className="flex flex-grow flex-col min-w-0 max-w-screen" className="flex grow flex-col min-w-0 max-w-screen"
key={content.name} key={tool.name}
schema={{ schema={{
title: "InputSchema", title: "InputSchema",
...content?.inputSchema, ...tool?.inputSchema,
}} }}
validateOn="submit" validateOn="submit"
initialValues={payload} initialValues={payload}
@@ -163,12 +171,12 @@ export function Content() {
right={ right={
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<IconButton <IconButton
Icon={historyVisible ? TbHistory : TbHistoryOff} Icon={historyVisible ? TbHistoryOff : TbHistory}
onClick={() => setHistoryVisible(!historyVisible)} onClick={() => setHistoryVisible(!historyVisible)}
/> />
<Button <Button
type="submit" type="submit"
disabled={!content?.name || isPending} disabled={!tool?.name || isPending}
variant="primary" variant="primary"
className="whitespace-nowrap" className="whitespace-nowrap"
> >
@@ -181,19 +189,19 @@ export function Content() {
<span className="opacity-50"> <span className="opacity-50">
Tools <span className="opacity-70">/</span> Tools <span className="opacity-70">/</span>
</span>{" "} </span>{" "}
<span className="truncate">{content?.name}</span> <span className="truncate">{tool?.name}</span>
</AppShell.SectionHeaderTitle> </AppShell.SectionHeaderTitle>
</AppShell.SectionHeader> </AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-vw"> <div className="flex grow flex-row w-vw">
<div <div
className="flex flex-grow flex-col max-w-full" className="flex grow flex-col max-w-full"
style={{ style={{
width: "calc(100% - var(--sidebar-width-right) - 1px)", width: "calc(100% - var(--sidebar-width-right) - 1px)",
}} }}
> >
<AppShell.Scrollable> <AppShell.Scrollable>
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4"> <div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{content?.description}</p> <p className="text-primary/80">{tool?.description}</p>
{hasInputSchema && <Field name="" />} {hasInputSchema && <Field name="" />}
<JsonViewerTabs <JsonViewerTabs
@@ -209,7 +217,7 @@ export function Content() {
}, },
Result: { json: readableResult, title: "Result" }, Result: { json: readableResult, title: "Result" },
Configuration: { Configuration: {
json: content ?? null, json: tool ?? null,
title: "Configuration", title: "Configuration",
}, },
}} }}
@@ -234,7 +242,7 @@ const History = () => {
<> <>
<AppShell.SectionHeader>History</AppShell.SectionHeader> <AppShell.SectionHeader>History</AppShell.SectionHeader>
<AppShell.Scrollable> <AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1"> <div className="flex grow flex-col p-3 gap-1">
{history.map((item, i) => ( {history.map((item, i) => (
<JsonViewer <JsonViewer
key={`${item.type}-${i}`} key={`${item.type}-${i}`}