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

@@ -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}`}