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

@@ -62,7 +62,11 @@ export class DataController extends Controller {
hono.get( hono.get(
"/sync", "/sync",
permission(DataPermissions.databaseSync), permission(DataPermissions.databaseSync),
mcpTool("data_sync"), mcpTool("data_sync", {
annotations: {
destructiveHint: true,
},
}),
describeRoute({ describeRoute({
summary: "Sync database schema", summary: "Sync database schema",
tags: ["data"], tags: ["data"],

View File

@@ -53,6 +53,10 @@ export class ObjectToolSchema<
}) })
.optional(), .optional(),
}), }),
annotations: {
readOnlyHint: true,
destructiveHint: false,
},
}, },
async (params, ctx: AppToolHandlerCtx) => { async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(params.secrets); const configs = ctx.context.app.toJSON(params.secrets);

View File

@@ -69,6 +69,10 @@ export class RecordToolSchema<
}) })
.optional(), .optional(),
}), }),
annotations: {
readOnlyHint: true,
destructiveHint: false,
},
}, },
async (params, ctx: AppToolHandlerCtx) => { async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(params.secrets); const configs = ctx.context.app.toJSON(params.secrets);

View File

@@ -91,7 +91,7 @@ export class AdminController extends Controller {
logout: "/api/auth/logout", logout: "/api/auth/logout",
}; };
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"]; const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*", "/tools/*"];
if (isDebug()) { if (isDebug()) {
paths.push("/test/*"); paths.push("/test/*");
} }

View File

@@ -131,7 +131,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module", summary: "Get the config for a module",
tags: ["system"], tags: ["system"],
}), }),
mcpTool("system_config"), // @todo: ":module" gets not removed mcpTool("system_config", {
annotations: {
readOnlyHint: true,
},
}), // @todo: ":module" gets not removed
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })), jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })), jsc("query", s.object({ secrets: s.boolean().optional() })),
async (c) => { async (c) => {

View File

@@ -19,15 +19,9 @@ import { appShellStore } from "ui/store";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
export function Root({ children }: { children: React.ReactNode }) { export function Root({ children }: { children: React.ReactNode }) {
const sidebarWidth = appShellStore((store) => store.sidebarWidth);
return ( return (
<AppShellProvider> <AppShellProvider>
<div <div id="app-shell" data-shell="root" className="flex flex-1 flex-col select-none h-dvh">
id="app-shell"
data-shell="root"
className="flex flex-1 flex-col select-none h-dvh"
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
>
{children} {children}
</div> </div>
</AppShellProvider> </AppShellProvider>
@@ -97,10 +91,24 @@ export function Main({ children }) {
); );
} }
export function Sidebar({ children }) { export function Sidebar({
const open = appShellStore((store) => store.sidebarOpen); children,
const close = appShellStore((store) => store.closeSidebar); name = "default",
handle = "right",
minWidth,
maxWidth,
}: {
children: React.ReactNode;
name?: string;
handle?: "right" | "left";
minWidth?: number;
maxWidth?: number;
}) {
const open = appShellStore((store) => store.sidebars[name]?.open);
const close = appShellStore((store) => store.closeSidebar(name));
const width = appShellStore((store) => store.sidebars[name]?.width ?? 350);
const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]); const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]);
const sidebarRef = useRef<HTMLDivElement>(null!);
const [location] = useLocation(); const [location] = useLocation();
const closeHandler = () => { const closeHandler = () => {
@@ -115,16 +123,35 @@ export function Sidebar({ children }) {
return ( return (
<> <>
{handle === "left" && (
<SidebarResize
name={name}
handle={handle}
sidebarRef={sidebarRef}
minWidth={minWidth}
maxWidth={maxWidth}
/>
)}
<aside <aside
data-shell="sidebar" data-shell="sidebar"
className="hidden md:flex flex-col basis-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full bg-muted/10" ref={sidebarRef}
className="hidden md:flex flex-col flex-shrink-0 flex-grow-0 h-full bg-muted/10"
style={{ width }}
> >
{children} {children}
</aside> </aside>
<SidebarResize /> {handle === "right" && (
<SidebarResize
name={name}
handle={handle}
sidebarRef={sidebarRef}
minWidth={minWidth}
maxWidth={maxWidth}
/>
)}
<div <div
data-open={open} data-open={open}
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm" className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm max-w-[90%]"
> >
<aside <aside
ref={ref} ref={ref}
@@ -138,30 +165,36 @@ export function Sidebar({ children }) {
); );
} }
const SidebarResize = () => { const SidebarResize = ({
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth); name = "default",
handle = "right",
sidebarRef,
minWidth = 250,
maxWidth = window.innerWidth * 0.5,
}: {
name?: string;
handle?: "right" | "left";
sidebarRef: React.RefObject<HTMLDivElement>;
minWidth?: number;
maxWidth?: number;
}) => {
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth(name));
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [startX, setStartX] = useState(0); const [start, setStart] = useState(0);
const [startWidth, setStartWidth] = useState(0); const [startWidth, setStartWidth] = useState(sidebarRef.current?.offsetWidth ?? 0);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
setIsResizing(true); setIsResizing(true);
setStartX(e.clientX); setStart(e.clientX);
setStartWidth( setStartWidth(sidebarRef.current?.offsetWidth ?? 0);
Number.parseInt(
getComputedStyle(document.getElementById("app-shell")!)
.getPropertyValue("--sidebar-width")
.replace("px", ""),
),
);
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return; if (!isResizing) return;
const diff = e.clientX - startX; const diff = handle === "right" ? e.clientX - start : start - e.clientX;
const newWidth = clampNumber(startWidth + diff, 250, window.innerWidth * 0.5); const newWidth = clampNumber(startWidth + diff, minWidth, maxWidth);
setSidebarWidth(newWidth); setSidebarWidth(newWidth);
}; };
@@ -179,7 +212,7 @@ const SidebarResize = () => {
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isResizing, startX, startWidth]); }, [isResizing, start, startWidth, minWidth, maxWidth]);
return ( return (
<div <div

View File

@@ -25,6 +25,7 @@ import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils"; import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store"; import { appShellStore } from "ui/store";
import { getVersion } from "core/env"; import { getVersion } from "core/env";
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -105,9 +106,9 @@ export function HeaderNavigation() {
); );
} }
function SidebarToggler() { function SidebarToggler({ name = "default" }: { name?: string }) {
const toggle = appShellStore((store) => store.toggleSidebar); const toggle = appShellStore((store) => store.toggleSidebar(name));
const open = appShellStore((store) => store.sidebarOpen); const open = appShellStore((store) => store.sidebars[name]?.open);
return <IconButton id="toggle-sidebar" size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />; return <IconButton id="toggle-sidebar" size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
} }
@@ -132,7 +133,7 @@ export function Header({ hasSidebar = true }) {
<HeaderNavigation /> <HeaderNavigation />
<div className="flex flex-grow" /> <div className="flex flex-grow" />
<div className="flex md:hidden flex-row items-center pr-2 gap-2"> <div className="flex md:hidden flex-row items-center pr-2 gap-2">
<SidebarToggler /> <SidebarToggler name="default" />
<UserMenu /> <UserMenu />
</div> </div>
<div className="hidden md:flex flex-row items-center px-4 gap-2"> <div className="hidden md:flex flex-row items-center px-4 gap-2">
@@ -172,6 +173,14 @@ function UserMenu() {
}, },
]; ];
if (config.server.mcp.enabled) {
items.push({
label: "MCP",
onClick: () => navigate("/tools/mcp"),
icon: McpIcon,
});
}
if (config.auth.enabled) { if (config.auth.enabled) {
if (!auth.user) { if (!auth.user) {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); items.push({ label: "Login", onClick: handleLogin, icon: IconUser });

View File

@@ -18,7 +18,7 @@
--color-success-foreground: var(--color-green-800); --color-success-foreground: var(--color-green-800);
--color-info: var(--color-blue-100); --color-info: var(--color-blue-100);
--color-info-foreground: var(--color-blue-800); --color-info-foreground: var(--color-blue-800);
--color-resize: var(--color-blue-300); --color-resize: var(--color-blue-300);
@mixin light { @mixin light {
@@ -115,3 +115,10 @@ body,
@apply bg-primary/25; @apply bg-primary/25;
} }
} }
@utility debug {
@apply border-red-500 border;
}
@utility debug-blue {
@apply border-blue-500 border;
}

View File

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

View File

@@ -1,10 +1,8 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb"; import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Icon } from "ui/components/display/Icon"; import { Icon } from "ui/components/display/Icon";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; 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 SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test"; import { SqlAiTest } from "./tests/sql-ai-test";
import Themes from "./tests/themes"; import Themes from "./tests/themes";
import MCPTest from "./tests/mcp/mcp-test";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
const tests = { const tests = {
MCPTest,
DropdownTest, DropdownTest,
Themes, Themes,
ModalTest, 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 * as AppShell from "ui/layouts/AppShell/AppShell";
import { useMcpStore } from "./state"; import { useMcpStore } from "./state";
import * as Tools from "./tools"; 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 feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature); 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 ( return (
<> <div className="flex flex-col flex-grow">
<AppShell.SectionHeader> <AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center"> <div className="flex flex-row gap-4 items-center">
<McpIcon />
<AppShell.SectionHeaderTitle>MCP UI</AppShell.SectionHeaderTitle> <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> </div>
</AppShell.SectionHeader> </AppShell.SectionHeader>
<div className="flex h-full"> <div className="flex h-full">
@@ -28,6 +49,6 @@ export default function MCPTest() {
</AppShell.Sidebar> </AppShell.Sidebar>
{feature === "tools" && <Tools.Content />} {feature === "tools" && <Tools.Content />}
</div> </div>
</> </div>
); );
} }

View File

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

View File

@@ -1,23 +1,73 @@
import { create } from "zustand"; import { create } from "zustand";
import { combine, persist } from "zustand/middleware"; import { combine, persist } from "zustand/middleware";
type SidebarState = {
open: boolean;
width: number;
};
export const appShellStore = create( export const appShellStore = create(
persist( persist(
combine( combine(
{ {
sidebarOpen: false as boolean, sidebars: {
sidebarWidth: 350 as number, default: {
open: false,
width: 350,
},
} as Record<string, SidebarState>,
}, },
(set) => ({ (set) => ({
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), toggleSidebar: (name: string) => () =>
closeSidebar: () => set({ sidebarOpen: false }), set((state) => {
openSidebar: () => set({ sidebarOpen: true }), const sidebar = state.sidebars[name];
setSidebarWidth: (width: number) => set({ sidebarWidth: width }), if (!sidebar) return state;
resetSidebarWidth: () => set({ sidebarWidth: 350 }), return {
sidebars: {
...state.sidebars,
[name]: { ...sidebar, open: !sidebar.open },
},
};
}),
closeSidebar: (name: string) => () =>
set((state) => {
const sidebar = state.sidebars[name];
if (!sidebar) return state;
return {
sidebars: { ...state.sidebars, [name]: { ...sidebar, open: false } },
};
}),
setSidebarWidth: (name: string) => (width: number) =>
set((state) => {
const sidebar = state.sidebars[name];
if (!sidebar)
return { sidebars: { ...state.sidebars, [name]: { open: false, width } } };
return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width } } };
}),
resetSidebarWidth: (name: string) =>
set((state) => {
const sidebar = state.sidebars[name];
if (!sidebar) return state;
return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width: 350 } } };
}),
setSidebarState: (name: string, update: SidebarState) =>
set((state) => ({ sidebars: { ...state.sidebars, [name]: update } })),
}), }),
), ),
{ {
name: "appshell", name: "appshell",
version: 1,
migrate: () => {
return {
sidebars: {
default: {
open: false,
width: 350,
},
},
};
},
}, },
), ),
); );