diff --git a/client/package.json b/client/package.json index e3445da..c32dfa1 100644 --- a/client/package.json +++ b/client/package.json @@ -27,12 +27,15 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", + "@types/prismjs": "^1.26.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", + "prismjs": "^1.29.0", "pkce-challenge": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-simple-code-editor": "^0.14.1", "react-toastify": "^10.0.6", "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx new file mode 100644 index 0000000..ff26118 --- /dev/null +++ b/client/src/components/DynamicJsonForm.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import JsonEditor from "./JsonEditor"; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonSchemaType = { + type: "string" | "number" | "integer" | "boolean" | "array" | "object"; + description?: string; + properties?: Record; + items?: JsonSchemaType; +}; + +type JsonObject = { [key: string]: JsonValue }; + +interface DynamicJsonFormProps { + schema: JsonSchemaType; + value: JsonValue; + onChange: (value: JsonValue) => void; + maxDepth?: number; +} + +const formatFieldLabel = (key: string): string => { + return key + .replace(/([A-Z])/g, " $1") // Insert space before capital letters + .replace(/_/g, " ") // Replace underscores with spaces + .replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter +}; + +const DynamicJsonForm = ({ + schema, + value, + onChange, + maxDepth = 3, +}: DynamicJsonFormProps) => { + const [isJsonMode, setIsJsonMode] = useState(false); + const [jsonError, setJsonError] = useState(); + + const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => { + switch (propSchema.type) { + case "string": + return ""; + case "number": + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + case "object": { + const obj: JsonObject = {}; + if (propSchema.properties) { + Object.entries(propSchema.properties).forEach(([key, prop]) => { + obj[key] = generateDefaultValue(prop); + }); + } + return obj; + } + default: + return null; + } + }; + + const renderFormFields = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[] = [], + depth: number = 0, + ) => { + if ( + depth >= maxDepth && + (propSchema.type === "object" || propSchema.type === "array") + ) { + // Render as JSON editor when max depth is reached + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + }} + error={jsonError} + /> + ); + } + + switch (propSchema.type) { + case "string": + case "number": + case "integer": + return ( + + handleFieldChange( + path, + propSchema.type === "string" + ? e.target.value + : Number(e.target.value), + ) + } + placeholder={propSchema.description} + /> + ); + case "boolean": + return ( + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + /> + ); + case "object": + if (!propSchema.properties) return null; + return ( +
+ {Object.entries(propSchema.properties).map(([key, prop]) => ( +
+ + {renderFormFields( + prop, + (currentValue as JsonObject)?.[key], + [...path, key], + depth + 1, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; + return ( +
+ {propSchema.description && ( +

{propSchema.description}

+ )} + + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + default: + return null; + } + }; + + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } + + const newValue = { + ...(typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : {}), + } as JsonObject; + let current: JsonObject = newValue; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!(key in current)) { + current[key] = {}; + } + current = current[key] as JsonObject; + } + + current[path[path.length - 1]] = fieldValue; + onChange(newValue); + }; + + return ( +
+
+ +
+ + {isJsonMode ? ( + { + try { + onChange(JSON.parse(newValue)); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + }} + error={jsonError} + /> + ) : ( + renderFormFields(schema, value) + )} +
+ ); +}; + +export default DynamicJsonForm; diff --git a/client/src/components/JsonEditor.tsx b/client/src/components/JsonEditor.tsx new file mode 100644 index 0000000..2fb7e26 --- /dev/null +++ b/client/src/components/JsonEditor.tsx @@ -0,0 +1,59 @@ +import Editor from "react-simple-code-editor"; +import Prism from "prismjs"; +import "prismjs/components/prism-json"; +import "prismjs/themes/prism.css"; +import { Button } from "@/components/ui/button"; + +interface JsonEditorProps { + value: string; + onChange: (value: string) => void; + error?: string; +} + +const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { + const formatJson = (json: string): string => { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } + }; + + return ( +
+
+ +
+
+ + Prism.highlight(code, Prism.languages.json, "json") + } + padding={10} + style={{ + fontFamily: '"Fira code", "Fira Mono", monospace', + fontSize: 14, + backgroundColor: "transparent", + minHeight: "100px", + }} + className="w-full" + /> +
+ {error &&

{error}

} +
+ ); +}; + +export default JsonEditor; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index a3a7ff2..41b2ef7 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -4,6 +4,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import { ListToolsResult, Tool, @@ -15,6 +16,12 @@ import ListPane from "./ListPane"; import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"; +type SchemaProperty = { + type: string; + description?: string; + properties?: Record; +}; + const ToolsTab = ({ tools, listTools, @@ -159,22 +166,21 @@ const ToolsTab = ({ {selectedTool.description}

{Object.entries(selectedTool.inputSchema.properties ?? []).map( - ([key, value]) => ( -
- - { - /* @ts-expect-error value type is currently unknown */ - value.type === "string" ? ( + ([key, value]) => { + const prop = value as SchemaProperty; + return ( +
+ + {prop.type === "string" ? (