added mcp ui as tool

This commit is contained in:
dswbx
2025-08-15 10:12:09 +02:00
parent aafd333d12
commit deb8aacca4
19 changed files with 445 additions and 221 deletions

View File

@@ -12,6 +12,7 @@ import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "ui/client";
import ToolsRoutes from "./tools";
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));
@@ -69,6 +70,11 @@ export function Routes({
<SettingsRoutes />
</Suspense>
</Route>
<Route path="/tools" nest>
<Suspense fallback={null}>
<ToolsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>

View File

@@ -1,10 +1,8 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton";
import { Icon } from "ui/components/display/Icon";
import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";

View File

@@ -26,11 +26,9 @@ import SchemaTest from "./tests/schema-test";
import SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test";
import Themes from "./tests/themes";
import MCPTest from "./tests/mcp/mcp-test";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
const tests = {
MCPTest,
DropdownTest,
Themes,
ModalTest,

View File

@@ -1,171 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient, getTemplate } from "./utils";
import { useMcpStore } from "./state";
import { AppShell } from "ui/layouts/AppShell";
import { 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 { Form } from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
export function Sidebar({ open, toggle }) {
const client = getClient();
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 handleRefresh = useCallback(async () => {
setLoading(true);
const res = await client.listTools();
if (res) setTools(res.tools);
setLoading(false);
}, []);
useEffect(() => {
handleRefresh();
}, []);
return (
<AppShell.SectionHeaderAccordionItem
title="Tools"
open={open}
toggle={toggle}
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center">
<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} />
</div>
)}
>
<div className="flex flex-col flex-grow p-3 gap-3">
<Formy.Input
type="text"
placeholder="Search tools"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<nav className="flex flex-col flex-1 gap-1">
{tools
.filter((tool) => tool.name.includes(query))
.map((tool) => (
<AppShell.SidebarLink
key={tool.name}
className={twMerge(
"flex flex-col items-start h-auto py-3 gap-px",
content?.name === tool.name ? "active" : "",
)}
onClick={() => setContent(tool)}
>
<span className="font-mono">{tool.name}</span>
<span className="text-sm text-primary/50">{tool.description}</span>
</AppShell.SidebarLink>
))}
</nav>
</div>
</AppShell.SectionHeaderAccordionItem>
);
}
export function Content() {
const content = useMcpStore((state) => state.content);
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
const [result, setResult] = useState<object | null>(null);
const client = getClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
useEffect(() => {
setPayload(getTemplate(content?.inputSchema));
setResult(null);
}, [content]);
const handleSubmit = useCallback(async () => {
if (!content?.name) return;
const res = await client.callTool({
name: content.name,
arguments: payload,
});
if (res) {
setResult(res);
jsonViewerTabsRef.current?.setSelected("Result");
}
}, [payload]);
if (!content) return null;
let readableResult = result;
try {
readableResult = result
? (result as any).content?.[0].text
? JSON.parse((result as any).content[0].text)
: result
: null;
} catch (e) {}
return (
<div className="flex flex-grow flex-col">
<AppShell.SectionHeader
right={
<Button
type="button"
disabled={!content?.name}
variant="primary"
onClick={handleSubmit}
>
Call Tool
</Button>
}
>
<AppShell.SectionHeaderTitle className="">
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
{content?.name}
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-grow flex-col py-4 px-5">
<div key={JSON.stringify(content)} className="flex flex-col gap-4">
<p className="text-primary/80">{content?.description}</p>
{hasInputSchema && (
<Form
schema={{
title: "InputSchema",
...content?.inputSchema,
}}
initialValues={payload}
hiddenSubmit={false}
onChange={(value) => {
setPayload(value);
}}
/>
)}
<JsonViewerTabs
ref={jsonViewerTabsRef}
expand={9}
showCopy
showSize
tabs={{
Arguments: { json: payload, title: "Payload", enabled: hasInputSchema },
Result: { json: readableResult, title: "Result" },
"Tool Configuration": {
json: content ?? null,
title: "Tool Configuration",
},
}}
/>
</div>
</div>
</AppShell.Scrollable>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Empty } from "ui/components/display/Empty";
import { Route } from "wouter";
import ToolsMcp from "./mcp/mcp";
export default function ToolsRoutes() {
return (
<>
<Route path="/" component={ToolsIndex} />
<Route path="/mcp" component={ToolsMcp} />
</>
);
}
function ToolsIndex() {
return <Empty title="Tools" description="Select a tool to continue." />;
}

View File

@@ -0,0 +1,15 @@
export const McpIcon = () => (
<svg
fill="currentColor"
fill-rule="evenodd"
height="1em"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<title>ModelContextProtocol</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);

View File

@@ -1,16 +1,37 @@
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useMcpStore } from "./state";
import * as Tools from "./tools";
import { TbWorld } from "react-icons/tb";
import { McpIcon } from "./components/mcp-icon";
import { useBknd } from "ui/client/bknd";
import { Empty } from "ui/components/display/Empty";
export default function MCPTest() {
export default function ToolsMcp() {
const { config, options } = useBknd();
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
if (!config.server.mcp.enabled) {
return (
<Empty
title="MCP not enabled"
description="Please enable MCP in the settings to continue."
/>
);
}
return (
<>
<div className="flex flex-col flex-grow">
<AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center">
<McpIcon />
<AppShell.SectionHeaderTitle>MCP UI</AppShell.SectionHeaderTitle>
<div className="flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<TbWorld />
<span className="text-sm font-mono leading-none">
{window.location.origin + "/mcp"}
</span>
</div>
</div>
</AppShell.SectionHeader>
<div className="flex h-full">
@@ -28,6 +49,6 @@ export default function MCPTest() {
</AppShell.Sidebar>
{feature === "tools" && <Tools.Content />}
</div>
</>
</div>
);
}

View File

@@ -12,11 +12,20 @@ export const useMcpStore = create(
tools: [] as ToolJson[],
feature: "tools" as Feature | null,
content: null as ToolJson | null,
history: [] as { type: "request" | "response"; data: any }[],
historyLimit: 50,
historyVisible: true,
},
(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)],
})),
setHistoryLimit: (limit: number) => set({ historyLimit: limit }),
setHistoryVisible: (visible: boolean) => set({ historyVisible: visible }),
}),
),
);

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient, getTemplate } from "./utils";
import { useMcpStore } from "./state";
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 {
Form,
} from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { JsonEditor } from "ui/components/code/JsonEditor";
export function Sidebar({ open, toggle }) {
const client = getClient();
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 handleRefresh = useCallback(async () => {
setLoading(true);
const res = await client.listTools();
if (res) setTools(res.tools);
setLoading(false);
}, []);
useEffect(() => {
handleRefresh();
}, []);
return (
<AppShell.SectionHeaderAccordionItem
title="Tools"
open={open}
toggle={toggle}
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center">
<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} />
</div>
)}
>
<div className="flex flex-col flex-grow p-3 gap-3">
<Formy.Input
type="text"
placeholder="Search tools"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<nav className="flex flex-col flex-1 gap-1">
{tools
.filter((tool) => tool.name.includes(query))
.map((tool) => {
return (
<AppShell.SidebarLink
key={tool.name}
className={twMerge(
"flex flex-col items-start h-auto py-3 gap-px",
content?.name === tool.name ? "active" : "",
)}
onClick={() => setContent(tool)}
>
<span className="font-mono">{tool.name}</span>
<span className="text-sm text-primary/50">{tool.description}</span>
</AppShell.SidebarLink>
);
})}
</nav>
</div>
</AppShell.SectionHeaderAccordionItem>
);
}
export function Content() {
const content = useMcpStore((state) => state.content);
const addHistory = useMcpStore((state) => state.addHistory);
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = getClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
useEffect(() => {
setPayload(getTemplate(content?.inputSchema));
setResult(null);
}, [content]);
const handleSubmit = useCallback(async () => {
if (!content?.name) return;
const request = {
name: content.name,
arguments: payload,
};
addHistory("request", request);
const res = await client.callTool(request);
if (res) {
setResult(res);
addHistory("response", res);
jsonViewerTabsRef.current?.setSelected("Result");
}
}, [payload]);
if (!content) return null;
let readableResult = result;
try {
readableResult = result
? (result as any).content?.[0].text
? JSON.parse((result as any).content[0].text)
: result
: null;
} catch (e) {}
return (
<div className="flex flex-grow flex-col">
<AppShell.SectionHeader
right={
<div className="flex flex-row gap-2">
<IconButton
Icon={historyVisible ? TbHistoryOff : TbHistory}
onClick={() => setHistoryVisible(!historyVisible)}
/>
<Button
type="button"
disabled={!content?.name}
variant="primary"
onClick={handleSubmit}
>
Call Tool
</Button>
</div>
}
>
<AppShell.SectionHeaderTitle className="">
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
{content?.name}
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-full">
<div className="flex flex-grow flex-col w-full">
<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>
{hasInputSchema && (
<Form
schema={{
title: "InputSchema",
...content?.inputSchema,
}}
initialValues={payload}
hiddenSubmit={false}
onChange={(value) => {
setPayload(value);
}}
/>
)}
<JsonViewerTabs
ref={jsonViewerTabsRef}
expand={9}
showCopy
showSize
tabs={{
Arguments: { json: payload, title: "Payload", enabled: hasInputSchema },
Result: { json: readableResult, title: "Result" },
"Tool Configuration": {
json: content ?? null,
title: "Tool Configuration",
},
}}
/>
</div>
</AppShell.Scrollable>
</div>
{historyVisible && (
<AppShell.Sidebar name="right" handle="left" maxWidth={window.innerWidth * 0.25}>
<History />
</AppShell.Sidebar>
)}
</div>
</div>
);
}
const History = () => {
const history = useMcpStore((state) => state.history.slice(0, 50));
return (
<>
<AppShell.SectionHeader>History</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1">
{history.map((item, i) => (
<JsonViewer
key={`${item.type}-${i}`}
json={item.data}
title={item.type}
expand={1}
/>
))}
</div>
</AppShell.Scrollable>
</>
);
};