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

@@ -65,7 +65,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.8.0",
"jsonv-ts": "link:jsonv-ts",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",

View File

@@ -1,6 +1,7 @@
import { s } from "bknd/utils";
import type { s } from "bknd/utils";
import { StringSchema } from "jsonv-ts";
export class SecretSchema<O extends s.IStringOptions> extends s.StringSchema<O> {}
export class SecretSchema<O extends s.IStringOptions> extends StringSchema<O> {}
export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
new SecretSchema(o) as any;

View File

@@ -201,7 +201,9 @@ export class DataController extends Controller {
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], {
coerce: (v) => v as number | string,
});
/**
* Function endpoints

View File

@@ -2,6 +2,35 @@ import { TbCopy } from "react-icons/tb";
import { JsonView } from "react-json-view-lite";
import { twMerge } from "tailwind-merge";
import { IconButton } from "../buttons/IconButton";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { forwardRef, useImperativeHandle, useState } from "react";
export type JsonViewerProps = {
json: object | null;
title?: string;
expand?: number;
showSize?: boolean;
showCopy?: boolean;
copyIconProps?: any;
className?: string;
};
const style = {
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
container: "ml-[-10px]",
label: "text-primary/90 font-bold font-mono mr-2",
stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text",
numberValue: "text-sky-500 dark:text-sky-400 font-mono",
nullValue: "text-zinc-400 font-mono",
undefinedValue: "text-zinc-400 font-mono",
otherValue: "text-zinc-400 font-mono",
booleanValue: "text-orange-500 dark:text-orange-400 font-mono",
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
collapseIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
expandIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
noQuotesForStringValues: false,
} as any;
export const JsonViewer = ({
json,
@@ -11,16 +40,8 @@ export const JsonViewer = ({
showCopy = false,
copyIconProps = {},
className,
}: {
json: object;
title?: string;
expand?: number;
showSize?: boolean;
showCopy?: boolean;
copyIconProps?: any;
className?: string;
}) => {
const size = showSize ? JSON.stringify(json).length : undefined;
}: JsonViewerProps) => {
const size = showSize ? (json === null ? 0 : (JSON.stringify(json)?.length ?? 0)) : undefined;
const showContext = size || title || showCopy;
function onCopy() {
@@ -31,9 +52,10 @@ export const JsonViewer = ({
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
{showContext && (
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
{(title || size) && (
{(title || size !== undefined) && (
<div className="flex flex-row">
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
{title && <span>{title}</span>}{" "}
{size !== undefined && <span>({size} Bytes)</span>}
</div>
)}
{showCopy && (
@@ -43,30 +65,66 @@ export const JsonViewer = ({
)}
</div>
)}
<ErrorBoundary>
<JsonView
data={json}
data={json as any}
shouldExpandNode={(level) => level < expand}
style={
{
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
container: "ml-[-10px]",
label: "text-primary/90 font-bold font-mono mr-2",
stringValue: "text-emerald-500 font-mono select-text",
numberValue: "text-sky-400 font-mono",
nullValue: "text-zinc-400 font-mono",
undefinedValue: "text-zinc-400 font-mono",
otherValue: "text-zinc-400 font-mono",
booleanValue: "text-orange-400 font-mono",
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
collapseIcon:
"text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
expandIcon:
"text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
noQuotesForStringValues: false,
} as any
}
style={style}
/>
</ErrorBoundary>
</div>
);
};
export type JsonViewerTabsProps = Omit<JsonViewerProps, "json"> & {
selected?: string;
tabs: {
[key: string]: JsonViewerProps & {
enabled?: boolean;
};
};
};
export type JsonViewerTabsRef = {
setSelected: (selected: string) => void;
};
export const JsonViewerTabs = forwardRef<JsonViewerTabsRef, JsonViewerTabsProps>(
({ tabs: _tabs, ...defaultProps }, ref) => {
const tabs = Object.fromEntries(
Object.entries(_tabs).filter(([_, v]) => v.enabled !== false),
);
const [selected, setSelected] = useState(defaultProps.selected ?? Object.keys(tabs)[0]);
useImperativeHandle(ref, () => ({
setSelected,
}));
return (
<div className="flex flex-col bg-primary/5 rounded-md">
<div className="flex flex-row gap-4 border-b px-3 border-primary/10">
{Object.keys(tabs).map((key) => (
<button
key={key}
type="button"
className={twMerge(
"flex flex-row text-sm cursor-pointer py-3 pt-3.5 px-1 border-b border-transparent -mb-px transition-opacity",
selected === key ? "border-primary" : "opacity-50 hover:opacity-70",
)}
onClick={() => setSelected(key)}
>
<span className="font-mono leading-none">{key}</span>
</button>
))}
</div>
{/* @ts-ignore */}
<JsonViewer
className="bg-transparent"
{...defaultProps}
{...tabs[selected as any]}
title={undefined}
/>
</div>
);
},
);

View File

@@ -40,7 +40,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
<BaseError>{this.props.fallback}</BaseError>
);
}
return <BaseError>Error1</BaseError>;
return <BaseError>{this.state.error?.message ?? "Unknown error"}</BaseError>;
}
override render() {
@@ -61,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
const BaseError = ({ children }: { children: ReactNode }) => (
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-none font-mono">
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-tight font-mono">
{children}
</div>
);

View File

@@ -5,8 +5,15 @@ import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
import {
FormContextOverride,
useDerivedFieldContext,
useFormContext,
useFormError,
useFormValue,
} from "./Form";
import { getLabel, getMultiSchemaMatched } from "./utils";
import { FieldWrapper } from "ui/components/form/json-schema-form/FieldWrapper";
export type AnyOfFieldRootProps = {
path?: string;
@@ -47,7 +54,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
const errors = useFormError(path, { strict: true });
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [_selected, setSelected] = useAtom(selectedAtom);
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
const {
options: { anyOfNoneSelectedMode },
} = useFormContext();
const selected =
_selected !== null
? _selected
: matchedIndex > -1
? matchedIndex
: anyOfNoneSelectedMode === "first"
? 0
: null;
const select = useEvent((index: number | null) => {
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
@@ -117,15 +134,27 @@ const Select = () => {
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null;
return (
<FormContextOverride prefix={path} schema={selectedSchema}>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
{/* another wrap is required for primitive schemas */}
<AnotherField key={`${path}_${selected}`} label={false} {...props} />
</div>
</FormContextOverride>
);
};
const AnotherField = (props: Partial<FormFieldProps>) => {
const { value } = useFormValue("");
const inputProps = {
// @todo: check, potentially just provide value
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
};
return <FormField name={""} label={false} {...props} inputProps={inputProps} />;
};
export const AnyOf = {
Root,
Select,

View File

@@ -46,6 +46,7 @@ type FormState<Data = any> = {
type FormOptions = {
debug?: boolean;
keepEmpty?: boolean;
anyOfNoneSelectedMode?: "none" | "first";
};
export type FormContext<Data> = {
@@ -190,7 +191,7 @@ export function Form<
root: "",
path: "",
}),
[schema, initialValues],
[schema, initialValues, options],
) as any;
return (

View File

@@ -62,6 +62,7 @@ export function getParentPointer(pointer: string) {
}
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
try {
if (pointer === "#/" || !schema) {
return false;
}
@@ -72,10 +73,15 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data
}
const parentPointer = getParentPointer(pointer);
if (parentPointer === "" || parentPointer === "#") return false;
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
return !!required;
} catch (e) {
console.error("isRequired", { pointer, schema, data, e });
return false;
}
}
export type IsTypeType =

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);
}

View File

@@ -7,7 +7,7 @@ import type { Connection } from "./src/data/connection/Connection";
import { __bknd } from "modules/ModuleManager";
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
import { $console } from "bknd/utils";
import { $console } from "core/utils/console";
import { createClient } from "@libsql/client";
registries.media.register("local", StorageLocalAdapter);

View File

@@ -35,7 +35,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.8.0",
"jsonv-ts": "link:jsonv-ts",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
@@ -2516,7 +2516,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.8.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-OS0QnkpmyqoFbK+qh7Rk+XAc+TCpWnOW1j9hJWJ1e0Lz1yGOExpa7ghokI4gUjKOwUXNq1eN7vUs+WUTzX2+gA=="],
"jsonv-ts": ["jsonv-ts@link:jsonv-ts", {}],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],