added a simple mcp ui in tests

This commit is contained in:
dswbx
2025-08-14 16:49:31 +02:00
parent 9ac5fa03c6
commit 63254de13a
16 changed files with 436 additions and 63 deletions

View File

@@ -26,8 +26,11 @@ 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,
@@ -88,7 +91,9 @@ function TestRoot({ children }) {
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
<AppShell.Main key={window.location.href}>
<ErrorBoundary key={window.location.href}>{children}</ErrorBoundary>
</AppShell.Main>
</>
);
}

View File

@@ -1,6 +1,7 @@
import type { JSONSchema } from "json-schema-to-ts";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { s } from "bknd/utils";
import {
AnyOf,
AnyOfField,
@@ -73,7 +74,31 @@ export default function JsonSchemaForm3() {
return (
<Scrollable>
<div className="flex flex-col p-3">
<Form schema={_schema.auth.toJSON()} options={formOptions} />
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
<Form
options={{
anyOfNoneSelectedMode: "first",
debug: true,
}}
initialValues={{ isd: "1", nested2: { name: "hello" } }}
schema={s
.object({
isd: s
.anyOf([s.string({ title: "String" }), s.number({ title: "Number" })])
.optional(),
email: s.string({ format: "email" }).optional(),
nested: s
.object({
name: s.string(),
})
.optional(),
nested2: s
.anyOf([s.object({ name: s.string() }), s.object({ age: s.number() })])
.optional(),
})
.toJSON()}
/>
{/*<Form
onChange={(data) => console.log("change", data)}

View File

@@ -0,0 +1,33 @@
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useMcpStore } from "./state";
import * as Tools from "./tools";
export default function MCPTest() {
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
return (
<>
<AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center">
<AppShell.SectionHeaderTitle>MCP UI</AppShell.SectionHeaderTitle>
</div>
</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 />}
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { create } from "zustand";
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,
},
(set) => ({
setTools: (tools: ToolJson[]) => set({ tools }),
setFeature: (feature: Feature) => set({ feature }),
setContent: (content: ToolJson | null) => set({ content }),
}),
),
);

View File

@@ -0,0 +1,171 @@
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,20 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { Draft2019 } from "json-schema-library";
const clients = new Map<string, McpClient>();
export function getClient(
{ url, ...opts }: McpClientConfig = { url: window.location.origin + "/mcp" },
) {
if (!clients.has(String(url))) {
clients.set(String(url), new McpClient({ url, ...opts }));
}
return clients.get(String(url))!;
}
export function getTemplate(schema: object) {
if (!schema || schema === undefined || schema === null) return undefined;
const lib = new Draft2019(schema);
return lib.getTemplate(undefined, schema);
}