From 63254de13a6fbb8af73af72081c9e3f01bf2e624 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 14 Aug 2025 16:49:31 +0200 Subject: [PATCH] added a simple mcp ui in tests --- app/package.json | 2 +- app/src/core/utils/schema/secret.ts | 5 +- app/src/data/api/DataController.ts | 4 +- app/src/ui/components/code/JsonViewer.tsx | 130 +++++++++---- .../ui/components/display/ErrorBoundary.tsx | 4 +- .../form/json-schema-form/AnyOfField.tsx | 35 +++- .../components/form/json-schema-form/Form.tsx | 3 +- .../components/form/json-schema-form/utils.ts | 30 +-- app/src/ui/routes/test/index.tsx | 7 +- .../routes/test/tests/json-schema-form3.tsx | 27 ++- app/src/ui/routes/test/tests/mcp/mcp-test.tsx | 33 ++++ app/src/ui/routes/test/tests/mcp/state.ts | 22 +++ app/src/ui/routes/test/tests/mcp/tools.tsx | 171 ++++++++++++++++++ app/src/ui/routes/test/tests/mcp/utils.ts | 20 ++ app/vite.dev.ts | 2 +- bun.lock | 4 +- 16 files changed, 436 insertions(+), 63 deletions(-) create mode 100644 app/src/ui/routes/test/tests/mcp/mcp-test.tsx create mode 100644 app/src/ui/routes/test/tests/mcp/state.ts create mode 100644 app/src/ui/routes/test/tests/mcp/tools.tsx create mode 100644 app/src/ui/routes/test/tests/mcp/utils.ts diff --git a/app/package.json b/app/package.json index 798928a..bea51fd 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/core/utils/schema/secret.ts b/app/src/core/utils/schema/secret.ts index 0df68d3..6fcdf14 100644 --- a/app/src/core/utils/schema/secret.ts +++ b/app/src/core/utils/schema/secret.ts @@ -1,6 +1,7 @@ -import { s } from "bknd/utils"; +import type { s } from "bknd/utils"; +import { StringSchema } from "jsonv-ts"; -export class SecretSchema extends s.StringSchema {} +export class SecretSchema extends StringSchema {} export const secret = (o?: O): SecretSchema & O => new SecretSchema(o) as any; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index fd11281..d7cbc24 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -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 diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 923846b..ba2c63b 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -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 = ({
{showContext && (
- {(title || size) && ( + {(title || size !== undefined) && (
- {title && {title}} {size && ({size} Bytes)} + {title && {title}}{" "} + {size !== undefined && ({size} Bytes)}
)} {showCopy && ( @@ -43,30 +65,66 @@ export const JsonViewer = ({ )}
)} - 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 - } - /> + + level < expand} + style={style} + /> +
); }; + +export type JsonViewerTabsProps = Omit & { + selected?: string; + tabs: { + [key: string]: JsonViewerProps & { + enabled?: boolean; + }; + }; +}; + +export type JsonViewerTabsRef = { + setSelected: (selected: string) => void; +}; + +export const JsonViewerTabs = forwardRef( + ({ 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 ( +
+
+ {Object.keys(tabs).map((key) => ( + + ))} +
+ {/* @ts-ignore */} + +
+ ); + }, +); diff --git a/app/src/ui/components/display/ErrorBoundary.tsx b/app/src/ui/components/display/ErrorBoundary.tsx index ad9dd7d..db2f8d4 100644 --- a/app/src/ui/components/display/ErrorBoundary.tsx +++ b/app/src/ui/components/display/ErrorBoundary.tsx @@ -40,7 +40,7 @@ class ErrorBoundary extends Component { {this.props.fallback} ); } - return Error1; + return {this.state.error?.message ?? "Unknown error"}; } override render() { @@ -61,7 +61,7 @@ class ErrorBoundary extends Component { } const BaseError = ({ children }: { children: ReactNode }) => ( -
+
{children}
); diff --git a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx index 40d4598..ef93cce 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -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) => { const { selected, selectedSchema, path, errors } = useAnyOfContext(); if (selected === null) return null; + return (
0 && "bg-red-500/10")}> - + {/* another wrap is required for primitive schemas */} +
); }; +const AnotherField = (props: Partial) => { + const { value } = useFormValue(""); + + const inputProps = { + // @todo: check, potentially just provide value + value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined, + }; + return ; +}; + export const AnyOf = { Root, Select, diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 8357fc8..1a054f4 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -46,6 +46,7 @@ type FormState = { type FormOptions = { debug?: boolean; keepEmpty?: boolean; + anyOfNoneSelectedMode?: "none" | "first"; }; export type FormContext = { @@ -190,7 +191,7 @@ export function Form< root: "", path: "", }), - [schema, initialValues], + [schema, initialValues, options], ) as any; return ( diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 6f3e206..7a94cd9 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -62,20 +62,26 @@ export function getParentPointer(pointer: string) { } 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; } - - 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 = diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index d099a13..bdc44f2 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -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 }) {
- {children} + + {children} + ); } diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index be2bfb0..401ab1f 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -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 (
-
+ {/* */} + + {/* console.log("change", data)} diff --git a/app/src/ui/routes/test/tests/mcp/mcp-test.tsx b/app/src/ui/routes/test/tests/mcp/mcp-test.tsx new file mode 100644 index 0000000..c390c21 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/mcp-test.tsx @@ -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 ( + <> + +
+ MCP UI +
+
+
+ + setFeature("tools")} /> + setFeature("resources")} + > +
+ Resources +
+
+
+ {feature === "tools" && } +
+ + ); +} diff --git a/app/src/ui/routes/test/tests/mcp/state.ts b/app/src/ui/routes/test/tests/mcp/state.ts new file mode 100644 index 0000000..47558a5 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/state.ts @@ -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 }), + }), + ), +); diff --git a/app/src/ui/routes/test/tests/mcp/tools.tsx b/app/src/ui/routes/test/tests/mcp/tools.tsx new file mode 100644 index 0000000..67d528a --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/tools.tsx @@ -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(""); + + const handleRefresh = useCallback(async () => { + setLoading(true); + const res = await client.listTools(); + if (res) setTools(res.tools); + setLoading(false); + }, []); + + useEffect(() => { + handleRefresh(); + }, []); + + return ( + ( +
+ + {tools.length} + + +
+ )} + > +
+ setQuery(e.target.value)} + /> + +
+
+ ); +} + +export function Content() { + const content = useMcpStore((state) => state.content); + const [payload, setPayload] = useState(getTemplate(content?.inputSchema)); + const [result, setResult] = useState(null); + const client = getClient(); + const jsonViewerTabsRef = useRef(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 ( +
+ + Call Tool + + } + > + + + Tools / + {" "} + {content?.name} + + + +
+
+

{content?.description}

+ + {hasInputSchema && ( + { + setPayload(value); + }} + /> + )} + +
+
+
+
+ ); +} diff --git a/app/src/ui/routes/test/tests/mcp/utils.ts b/app/src/ui/routes/test/tests/mcp/utils.ts new file mode 100644 index 0000000..82d3df8 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/utils.ts @@ -0,0 +1,20 @@ +import { McpClient, type McpClientConfig } from "jsonv-ts/mcp"; +import { Draft2019 } from "json-schema-library"; + +const clients = new Map(); + +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); +} diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 66036d9..bee9219 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -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); diff --git a/bun.lock b/bun.lock index d94cc32..092e0c4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],