mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #321 from bknd-io/feat/admin/mcp-in-nav
feat(admin): add mcp as main navigation item when enabled, and make it route-aware
This commit is contained in:
@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight,
|
||||
scrollContainerRef,
|
||||
}: SectionHeaderAccordionItemProps) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
|
||||
: "flex-initial cursor-pointer hover:bg-primary/5",
|
||||
)}
|
||||
>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
|
||||
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"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} />
|
||||
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex grow" />
|
||||
{renderHeaderRight?.({ open })}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-y-scroll transition-all",
|
||||
open ? " flex-grow" : "h-0 opacity-0",
|
||||
)}
|
||||
ref={scrollContainerRef}
|
||||
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
|
||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||
routePattern,
|
||||
identifier,
|
||||
renderHeaderRight,
|
||||
...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
|
||||
routePattern?: string;
|
||||
identifier: string;
|
||||
}) => {
|
||||
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">) => (
|
||||
|
||||
@@ -30,28 +30,29 @@ import { useAppShellAdminOptions } from "ui/options";
|
||||
|
||||
export function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
const { config } = useBknd();
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
Icon?: any;
|
||||
exact?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
/*{
|
||||
label: "Base",
|
||||
href: "#",
|
||||
exact: true,
|
||||
Icon: TbLayoutDashboard,
|
||||
disabled: true,
|
||||
tooltip: "Coming soon"
|
||||
},*/
|
||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||
{ 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) =>
|
||||
item.exact ? location === item.href : location.startsWith(item.href),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useBkndWindowContext } from "bknd/client";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
let TestRoutes: any;
|
||||
if (import.meta.env.DEV) {
|
||||
TestRoutes = lazy(() => import("./test"));
|
||||
}
|
||||
|
||||
export function Routes({
|
||||
BkndWrapper,
|
||||
@@ -43,11 +46,13 @@ export function Routes({
|
||||
<Route path="/" nest>
|
||||
<Root>
|
||||
<Switch>
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
{TestRoutes && (
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" component={ToolsIndex} />
|
||||
<Route path="/mcp" component={ToolsMcp} />
|
||||
<Route path="/mcp*" component={ToolsMcp} nest />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,13 @@ import { Empty } from "ui/components/display/Empty";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { appShellStore } from "ui/store";
|
||||
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() {
|
||||
useBrowserTitle(["MCP UI"]);
|
||||
|
||||
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 mcpPath = config.server.mcp.path;
|
||||
|
||||
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow max-w-screen">
|
||||
<AppShell.SectionHeader>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<McpIcon />
|
||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||
MCP UI
|
||||
</AppShell.SectionHeaderTitle>
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
|
||||
<div className="flex flex-col flex-grow max-w-screen">
|
||||
<AppShell.SectionHeader>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<McpIcon />
|
||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||
MCP UI
|
||||
</AppShell.SectionHeaderTitle>
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
</AppShell.SectionHeader>
|
||||
|
||||
<div className="flex h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
open={feature === "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"
|
||||
<div className="flex grow h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
identifier="resources"
|
||||
>
|
||||
Open Tools
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||
<i>Resources</i>
|
||||
</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>
|
||||
</RoutePathStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@ import { combine } from "zustand/middleware";
|
||||
|
||||
import type { ToolJson } from "jsonv-ts/mcp";
|
||||
|
||||
const FEATURES = ["tools", "resources"] as const;
|
||||
export type Feature = (typeof FEATURES)[number];
|
||||
|
||||
export const useMcpStore = create(
|
||||
combine(
|
||||
{
|
||||
tools: [] as ToolJson[],
|
||||
feature: "tools" as Feature | null,
|
||||
content: null as ToolJson | null,
|
||||
history: [] as { type: "request" | "response"; data: any }[],
|
||||
historyLimit: 50,
|
||||
historyVisible: false,
|
||||
},
|
||||
(set) => ({
|
||||
setTools: (tools: ToolJson[]) => set({ tools }),
|
||||
setFeature: (feature: Feature) => set({ feature }),
|
||||
setContent: (content: ToolJson | null) => set({ content }),
|
||||
addHistory: (type: "request" | "response", data: any) =>
|
||||
set((state) => ({
|
||||
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell";
|
||||
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
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 { Button } from "ui/components/buttons/Button";
|
||||
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 { useMcpClient } from "./hooks/use-mcp-client";
|
||||
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 closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
||||
const tools = useMcpStore((state) => state.tools);
|
||||
const setTools = useMcpStore((state) => state.setTools);
|
||||
const setContent = useMcpStore((state) => state.setContent);
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(undefined!);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
handleRefresh().then(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const selectedTool = scrollContainerRef.current.querySelector(".active");
|
||||
if (selectedTool) {
|
||||
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
title="Tools"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
identifier="tools"
|
||||
renderHeaderRight={({ active }) => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{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">
|
||||
{tools.length}
|
||||
</span>
|
||||
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
|
||||
<IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
|
||||
return (
|
||||
<AppShell.SidebarLink
|
||||
key={tool.name}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start h-auto py-3 gap-px",
|
||||
content?.name === tool.name ? "active" : "",
|
||||
)}
|
||||
className="flex flex-col items-start h-auto py-3 gap-px"
|
||||
as={Link}
|
||||
href={`/tools/${tool.name}`}
|
||||
onClick={() => {
|
||||
setContent(tool);
|
||||
//setContent(tool);
|
||||
closeSidebar();
|
||||
}}
|
||||
>
|
||||
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
|
||||
const [payload, setPayload] = useState<object>(getTemplate(tool?.inputSchema));
|
||||
const [result, setResult] = useState<object | null>(null);
|
||||
const historyVisible = useMcpStore((state) => state.historyVisible);
|
||||
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
||||
const client = useMcpClient();
|
||||
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
||||
const hasInputSchema =
|
||||
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
||||
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setPayload(getTemplate(content?.inputSchema));
|
||||
setPayload(getTemplate(tool?.inputSchema));
|
||||
setResult(null);
|
||||
}, [content]);
|
||||
}, [toolName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!content?.name) return;
|
||||
if (!tool?.name) return;
|
||||
const request = {
|
||||
name: content.name,
|
||||
name: tool.name,
|
||||
arguments: payload,
|
||||
};
|
||||
startTransition(async () => {
|
||||
@@ -131,7 +139,7 @@ export function Content() {
|
||||
});
|
||||
}, [payload]);
|
||||
|
||||
if (!content) return null;
|
||||
if (!tool) return null;
|
||||
|
||||
let readableResult = result;
|
||||
try {
|
||||
@@ -144,11 +152,11 @@ export function Content() {
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||
key={content.name}
|
||||
className="flex grow flex-col min-w-0 max-w-screen"
|
||||
key={tool.name}
|
||||
schema={{
|
||||
title: "InputSchema",
|
||||
...content?.inputSchema,
|
||||
...tool?.inputSchema,
|
||||
}}
|
||||
validateOn="submit"
|
||||
initialValues={payload}
|
||||
@@ -163,12 +171,12 @@ export function Content() {
|
||||
right={
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconButton
|
||||
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
||||
Icon={historyVisible ? TbHistoryOff : TbHistory}
|
||||
onClick={() => setHistoryVisible(!historyVisible)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content?.name || isPending}
|
||||
disabled={!tool?.name || isPending}
|
||||
variant="primary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
@@ -181,19 +189,19 @@ export function Content() {
|
||||
<span className="opacity-50">
|
||||
Tools <span className="opacity-70">/</span>
|
||||
</span>{" "}
|
||||
<span className="truncate">{content?.name}</span>
|
||||
<span className="truncate">{tool?.name}</span>
|
||||
</AppShell.SectionHeaderTitle>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-grow flex-row w-vw">
|
||||
<div className="flex grow flex-row w-vw">
|
||||
<div
|
||||
className="flex flex-grow flex-col max-w-full"
|
||||
className="flex grow flex-col max-w-full"
|
||||
style={{
|
||||
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
||||
}}
|
||||
>
|
||||
<AppShell.Scrollable>
|
||||
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{content?.description}</p>
|
||||
<div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{tool?.description}</p>
|
||||
|
||||
{hasInputSchema && <Field name="" />}
|
||||
<JsonViewerTabs
|
||||
@@ -209,7 +217,7 @@ export function Content() {
|
||||
},
|
||||
Result: { json: readableResult, title: "Result" },
|
||||
Configuration: {
|
||||
json: content ?? null,
|
||||
json: tool ?? null,
|
||||
title: "Configuration",
|
||||
},
|
||||
}}
|
||||
@@ -234,7 +242,7 @@ const History = () => {
|
||||
<>
|
||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||
<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) => (
|
||||
<JsonViewer
|
||||
key={`${item.type}-${i}`}
|
||||
|
||||
Reference in New Issue
Block a user