From 40b70e7a20c88b34eb2b140513015f75e6f7d059 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 5 Dec 2025 15:25:06 +0100 Subject: [PATCH] feat(admin): add mcp as main navigation item when enabled, and make it route-aware --- app/src/ui/layouts/AppShell/AppShell.tsx | 27 +++++-- app/src/ui/layouts/AppShell/Header.tsx | 21 +++--- app/src/ui/routes/index.tsx | 17 +++-- app/src/ui/routes/tools/index.tsx | 2 +- app/src/ui/routes/tools/mcp/mcp.tsx | 93 +++++++++++++----------- app/src/ui/routes/tools/mcp/state.ts | 7 -- app/src/ui/routes/tools/mcp/tools.tsx | 80 +++++++++++--------- 7 files changed, 136 insertions(+), 111 deletions(-) diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 8062d8f..1efa343 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = { ActiveIcon?: any; children?: React.ReactNode; renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; + scrollContainerRef?: React.RefObject; }; export const SectionHeaderAccordionItem = ({ @@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({ ActiveIcon = IconChevronUp, children, renderHeaderRight, + scrollContainerRef, }: SectionHeaderAccordionItemProps) => (
+ {/** biome-ignore lint/a11y/noStaticElementInteractions: . */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}

{title}

-
+
{renderHeaderRight?.({ open })}
{children}
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({ export const RouteAwareSectionHeaderAccordionItem = ({ routePattern, identifier, + renderHeaderRight, ...props -}: Omit & { +}: Omit & { + 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 ; + return ( + renderHeaderRight?.({ open: props.open, active })) + } + /> + ); }; export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => ( diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index ea0106c..14948e8 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -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), ); diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 6d959b4..2d9f793 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -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({ - - - - - + {TestRoutes && ( + + + + + + )} {children} diff --git a/app/src/ui/routes/tools/index.tsx b/app/src/ui/routes/tools/index.tsx index 3bfc6b0..558abe7 100644 --- a/app/src/ui/routes/tools/index.tsx +++ b/app/src/ui/routes/tools/index.tsx @@ -6,7 +6,7 @@ export default function ToolsRoutes() { return ( <> - + ); } diff --git a/app/src/ui/routes/tools/mcp/mcp.tsx b/app/src/ui/routes/tools/mcp/mcp.tsx index e8f9e54..771dd17 100644 --- a/app/src/ui/routes/tools/mcp/mcp.tsx +++ b/app/src/ui/routes/tools/mcp/mcp.tsx @@ -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 ( -
- -
- - - MCP UI - -
- -
- - {window.location.origin + mcpPath} - + +
+ +
+ + + MCP UI + +
+ +
+ + {window.location.origin + mcpPath} + +
-
- + -
- - setFeature("tools")} /> - setFeature("resources")} - > -
- Resources -
-
-
- {feature === "tools" && } - - {!content && ( - - - - )} +
+ Resources +
+ + + + + + + + + + + +
-
+ ); } diff --git a/app/src/ui/routes/tools/mcp/state.ts b/app/src/ui/routes/tools/mcp/state.ts index 877f324..cbbff4c 100644 --- a/app/src/ui/routes/tools/mcp/state.ts +++ b/app/src/ui/routes/tools/mcp/state.ts @@ -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)], diff --git a/app/src/ui/routes/tools/mcp/tools.tsx b/app/src/ui/routes/tools/mcp/tools.tsx index 6c475dd..2951b70 100644 --- a/app/src/ui/routes/tools/mcp/tools.tsx +++ b/app/src/ui/routes/tools/mcp/tools.tsx @@ -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(""); const [error, setError] = useState(null); + const scrollContainerRef = useRef(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 ( - ( + identifier="tools" + renderHeaderRight={({ active }) => (
{error && ( @@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) { {tools.length} - +
)} > @@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) { return ( { - setContent(tool); + //setContent(tool); closeSidebar(); }} > @@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) { })}
- + ); } 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(getTemplate(content?.inputSchema)); + const [payload, setPayload] = useState(getTemplate(tool?.inputSchema)); const [result, setResult] = useState(null); const historyVisible = useMcpStore((state) => state.historyVisible); const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible); const client = useMcpClient(); const jsonViewerTabsRef = useRef(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 (
setHistoryVisible(!historyVisible)} />