diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..a81d0faef 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -124,9 +124,9 @@ const cloneToolParams = ( }; const filterReservedMetadata = ( - metadata: Record, -): Record => { - return Object.entries(metadata).reduce>( + metadata: Record, +): Record => { + return Object.entries(metadata).reduce>( (acc, [key, value]) => { if ( !isReservedMetaKey(key) && @@ -265,7 +265,7 @@ const App = () => { useState(EMPTY_DEBUGGER_STATE); // Metadata state - persisted in localStorage - const [metadata, setMetadata] = useState>(() => { + const [metadata, setMetadata] = useState>(() => { const savedMetadata = localStorage.getItem("lastMetadata"); if (savedMetadata) { try { @@ -284,7 +284,7 @@ const App = () => { setAuthState((prev) => ({ ...prev, ...updates })); }; - const handleMetadataChange = (newMetadata: Record) => { + const handleMetadataChange = (newMetadata: Record) => { const sanitizedMetadata = filterReservedMetadata(newMetadata); setMetadata(sanitizedMetadata); localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata)); diff --git a/client/src/components/MetadataTab.tsx b/client/src/components/MetadataTab.tsx index beb26ac7a..82d1822d3 100644 --- a/client/src/components/MetadataTab.tsx +++ b/client/src/components/MetadataTab.tsx @@ -1,167 +1,157 @@ -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { TabsContent } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Trash2, Plus } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - META_NAME_RULES_MESSAGE, - META_PREFIX_RULES_MESSAGE, - RESERVED_NAMESPACE_MESSAGE, - hasValidMetaName, - hasValidMetaPrefix, - isReservedMetaKey, -} from "@/utils/metaUtils"; - -interface MetadataEntry { - key: string; - value: string; -} +import JsonEditor from "@/components/JsonEditor"; interface MetadataTabProps { - metadata: Record; - onMetadataChange: (metadata: Record) => void; + metadata: Record; + onMetadataChange: (metadata: Record) => void; } const MetadataTab: React.FC = ({ metadata, onMetadataChange, }) => { - const [entries, setEntries] = useState(() => { - return Object.entries(metadata).map(([key, value]) => ({ key, value })); - }); + const stringifyCompact = ( + value: Record | null | undefined, + ) => { + if (!value || Object.keys(value).length === 0) { + return ""; + } - const addEntry = () => { - setEntries([...entries, { key: "", value: "" }]); + try { + return JSON.stringify(value); + } catch { + return ""; + } }; - const removeEntry = (index: number) => { - const newEntries = entries.filter((_, i) => i !== index); - setEntries(newEntries); - updateMetadata(newEntries); + const stringifyPretty = (value: Record) => { + try { + return JSON.stringify(value, null, 2); + } catch { + return ""; + } }; - const updateEntry = ( - index: number, - field: "key" | "value", - value: string, - ) => { - const newEntries = [...entries]; - newEntries[index][field] = value; - setEntries(newEntries); - updateMetadata(newEntries); + const initialCompact = stringifyCompact(metadata); + const [jsonValue, setJsonValue] = useState(() => { + if (!initialCompact) { + return ""; + } + + return stringifyPretty(metadata); + }); + + const [jsonError, setJsonError] = useState(null); + const lastMetadataStringRef = useRef(initialCompact); + + useEffect(() => { + const compact = stringifyCompact(metadata); + + if (compact === lastMetadataStringRef.current) { + return; + } + + lastMetadataStringRef.current = compact; + + if (!compact) { + setJsonValue(""); + setJsonError(null); + return; + } + + if (metadata) { + const pretty = stringifyPretty(metadata); + setJsonValue(pretty); + setJsonError(null); + } + }, [metadata]); + + const handleJsonChange = (value: string) => { + setJsonValue(value); + + if (!value.trim()) { + onMetadataChange({}); + lastMetadataStringRef.current = ""; + setJsonError(null); + return; + } + + try { + const parsed = JSON.parse(value); + + if ( + parsed === null || + Array.isArray(parsed) || + typeof parsed !== "object" + ) { + setJsonError("Meta data must be a JSON object"); + return; + } + + onMetadataChange(parsed); + lastMetadataStringRef.current = JSON.stringify(parsed); + setJsonError(null); + } catch { + setJsonError("Invalid JSON format"); + } }; - const updateMetadata = (newEntries: MetadataEntry[]) => { - const metadataObject: Record = {}; - newEntries.forEach(({ key, value }) => { - const trimmedKey = key.trim(); + const handlePrettyClick = () => { + if (!jsonValue.trim()) { + return; + } + + try { + const parsed = JSON.parse(jsonValue); + if ( - trimmedKey && - value.trim() && - hasValidMetaPrefix(trimmedKey) && - !isReservedMetaKey(trimmedKey) && - hasValidMetaName(trimmedKey) + parsed === null || + Array.isArray(parsed) || + typeof parsed !== "object" ) { - metadataObject[trimmedKey] = value.trim(); + setJsonError("Meta data must be a JSON object"); + return; } - }); - onMetadataChange(metadataObject); + + const pretty = stringifyPretty(parsed); + setJsonValue(pretty); + onMetadataChange(parsed); + lastMetadataStringRef.current = JSON.stringify(parsed); + setJsonError(null); + } catch { + setJsonError("Invalid JSON format"); + } }; return (
-
+
-

Metadata

+

Meta Data

- Key-value pairs that will be included in all MCP requests + Provide an object containing key-value pairs that will be included + in all MCP requests.

-
-
- {entries.map((entry, index) => { - const trimmedKey = entry.key.trim(); - const hasInvalidPrefix = - trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey); - const isReservedKey = - trimmedKey !== "" && isReservedMetaKey(trimmedKey); - const hasInvalidName = - trimmedKey !== "" && !hasValidMetaName(trimmedKey); - const validationMessage = hasInvalidPrefix - ? META_PREFIX_RULES_MESSAGE - : isReservedKey - ? RESERVED_NAMESPACE_MESSAGE - : hasInvalidName - ? META_NAME_RULES_MESSAGE - : null; - return ( -
-
-
- - - updateEntry(index, "key", e.target.value) - } - aria-invalid={Boolean(validationMessage)} - className={cn( - validationMessage && - "border-red-500 focus-visible:ring-red-500 focus-visible:ring-1", - )} - /> -
-
- - - updateEntry(index, "value", e.target.value) - } - disabled={Boolean(validationMessage)} - /> -
- -
- {validationMessage && ( -

- {validationMessage} -

- )} -
- ); - })} -
- - {entries.length === 0 && ( -
-

- No metadata entries. Click "Add Entry" to add key-value pairs. -

-
- )} +
); diff --git a/client/src/components/__tests__/MetadataTab.test.tsx b/client/src/components/__tests__/MetadataTab.test.tsx index f22e102ff..1d4a9342b 100644 --- a/client/src/components/__tests__/MetadataTab.test.tsx +++ b/client/src/components/__tests__/MetadataTab.test.tsx @@ -2,11 +2,32 @@ import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import MetadataTab from "../MetadataTab"; import { Tabs } from "@/components/ui/tabs"; -import { - META_NAME_RULES_MESSAGE, - META_PREFIX_RULES_MESSAGE, - RESERVED_NAMESPACE_MESSAGE, -} from "@/utils/metaUtils"; + +jest.mock("react-simple-code-editor", () => { + return function MockCodeEditor({ + value, + onValueChange: onValueChange, + }: { + value: string; + onValueChange: (value: string) => void; + }) { + return ( +