mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
added a simple mcp ui in tests
This commit is contained in:
@@ -65,7 +65,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.8.0",
|
"jsonv-ts": "link:jsonv-ts",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
|
|||||||
@@ -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 =>
|
export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
|
||||||
new SecretSchema(o) as any;
|
new SecretSchema(o) as any;
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ export class DataController extends Controller {
|
|||||||
|
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||||
// @todo: make dynamic based on entity
|
// @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
|
* Function endpoints
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { TbCopy } from "react-icons/tb";
|
|||||||
import { JsonView } from "react-json-view-lite";
|
import { JsonView } from "react-json-view-lite";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "../buttons/IconButton";
|
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 = ({
|
export const JsonViewer = ({
|
||||||
json,
|
json,
|
||||||
@@ -11,16 +40,8 @@ export const JsonViewer = ({
|
|||||||
showCopy = false,
|
showCopy = false,
|
||||||
copyIconProps = {},
|
copyIconProps = {},
|
||||||
className,
|
className,
|
||||||
}: {
|
}: JsonViewerProps) => {
|
||||||
json: object;
|
const size = showSize ? (json === null ? 0 : (JSON.stringify(json)?.length ?? 0)) : undefined;
|
||||||
title?: string;
|
|
||||||
expand?: number;
|
|
||||||
showSize?: boolean;
|
|
||||||
showCopy?: boolean;
|
|
||||||
copyIconProps?: any;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
|
||||||
const showContext = size || title || showCopy;
|
const showContext = size || title || showCopy;
|
||||||
|
|
||||||
function onCopy() {
|
function onCopy() {
|
||||||
@@ -31,9 +52,10 @@ export const JsonViewer = ({
|
|||||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||||
{showContext && (
|
{showContext && (
|
||||||
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
<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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showCopy && (
|
{showCopy && (
|
||||||
@@ -43,30 +65,66 @@ export const JsonViewer = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<JsonView
|
<ErrorBoundary>
|
||||||
data={json}
|
<JsonView
|
||||||
shouldExpandNode={(level) => level < expand}
|
data={json as any}
|
||||||
style={
|
shouldExpandNode={(level) => level < expand}
|
||||||
{
|
style={style}
|
||||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
/>
|
||||||
container: "ml-[-10px]",
|
</ErrorBoundary>
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|||||||
<BaseError>{this.props.fallback}</BaseError>
|
<BaseError>{this.props.fallback}</BaseError>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <BaseError>Error1</BaseError>;
|
return <BaseError>{this.state.error?.message ?? "Unknown error"}</BaseError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -61,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BaseError = ({ children }: { children: ReactNode }) => (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
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 { getLabel, getMultiSchemaMatched } from "./utils";
|
||||||
|
import { FieldWrapper } from "ui/components/form/json-schema-form/FieldWrapper";
|
||||||
|
|
||||||
export type AnyOfFieldRootProps = {
|
export type AnyOfFieldRootProps = {
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -47,7 +54,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
|
|||||||
const errors = useFormError(path, { strict: true });
|
const errors = useFormError(path, { strict: true });
|
||||||
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
||||||
const [_selected, setSelected] = useAtom(selectedAtom);
|
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) => {
|
const select = useEvent((index: number | null) => {
|
||||||
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||||
@@ -117,15 +134,27 @@ const Select = () => {
|
|||||||
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||||
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||||
if (selected === null) return null;
|
if (selected === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContextOverride prefix={path} schema={selectedSchema}>
|
<FormContextOverride prefix={path} schema={selectedSchema}>
|
||||||
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
|
<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>
|
</div>
|
||||||
</FormContextOverride>
|
</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 = {
|
export const AnyOf = {
|
||||||
Root,
|
Root,
|
||||||
Select,
|
Select,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type FormState<Data = any> = {
|
|||||||
type FormOptions = {
|
type FormOptions = {
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
keepEmpty?: boolean;
|
keepEmpty?: boolean;
|
||||||
|
anyOfNoneSelectedMode?: "none" | "first";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormContext<Data> = {
|
export type FormContext<Data> = {
|
||||||
@@ -190,7 +191,7 @@ export function Form<
|
|||||||
root: "",
|
root: "",
|
||||||
path: "",
|
path: "",
|
||||||
}),
|
}),
|
||||||
[schema, initialValues],
|
[schema, initialValues, options],
|
||||||
) as any;
|
) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -62,20 +62,26 @@ export function getParentPointer(pointer: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
|
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
|
||||||
if (pointer === "#/" || !schema) {
|
try {
|
||||||
|
if (pointer === "#/" || !schema) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||||
|
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
|
||||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPointer = getParentPointer(pointer);
|
|
||||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
|
||||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
|
||||||
|
|
||||||
return !!required;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IsTypeType =
|
export type IsTypeType =
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ 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";
|
||||||
|
|
||||||
const tests = {
|
const tests = {
|
||||||
|
MCPTest,
|
||||||
DropdownTest,
|
DropdownTest,
|
||||||
Themes,
|
Themes,
|
||||||
ModalTest,
|
ModalTest,
|
||||||
@@ -88,7 +91,9 @@ function TestRoot({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</AppShell.Sidebar>
|
</AppShell.Sidebar>
|
||||||
<AppShell.Main>{children}</AppShell.Main>
|
<AppShell.Main key={window.location.href}>
|
||||||
|
<ErrorBoundary key={window.location.href}>{children}</ErrorBoundary>
|
||||||
|
</AppShell.Main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JSONSchema } from "json-schema-to-ts";
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { s } from "bknd/utils";
|
||||||
import {
|
import {
|
||||||
AnyOf,
|
AnyOf,
|
||||||
AnyOfField,
|
AnyOfField,
|
||||||
@@ -73,7 +74,31 @@ export default function JsonSchemaForm3() {
|
|||||||
return (
|
return (
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<div className="flex flex-col p-3">
|
<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
|
{/*<Form
|
||||||
onChange={(data) => console.log("change", data)}
|
onChange={(data) => console.log("change", data)}
|
||||||
|
|||||||
33
app/src/ui/routes/test/tests/mcp/mcp-test.tsx
Normal file
33
app/src/ui/routes/test/tests/mcp/mcp-test.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/src/ui/routes/test/tests/mcp/state.ts
Normal file
22
app/src/ui/routes/test/tests/mcp/state.ts
Normal 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 }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
171
app/src/ui/routes/test/tests/mcp/tools.tsx
Normal file
171
app/src/ui/routes/test/tests/mcp/tools.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/src/ui/routes/test/tests/mcp/utils.ts
Normal file
20
app/src/ui/routes/test/tests/mcp/utils.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import type { Connection } from "./src/data/connection/Connection";
|
|||||||
import { __bknd } from "modules/ModuleManager";
|
import { __bknd } from "modules/ModuleManager";
|
||||||
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
||||||
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
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";
|
import { createClient } from "@libsql/client";
|
||||||
|
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -35,7 +35,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.8.0",
|
"jsonv-ts": "link:jsonv-ts",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -2516,7 +2516,7 @@
|
|||||||
|
|
||||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user