diff --git a/cli/package.json b/cli/package.json index caf21a3e..ddeb3b93 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.7", + "version": "0.17.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/cli/src/index.ts b/cli/src/index.ts index 8e219e45..90c9f5e6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as fs from "fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Command } from "commander"; import { @@ -99,16 +100,27 @@ function createTransportOptions( } async function callMethod(args: Args): Promise { + // Read package.json to get name and version for client identity + const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli + const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector + let packageJson: { name: string; version: string }; + let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { + with: { type: "json" }, + }); + packageJson = packageJsonData.default; + const transportOptions = createTransportOptions( args.target, args.transport, args.headers, ); const transport = createTransport(transportOptions); - const client = new Client({ - name: "inspector-cli", - version: "0.5.1", - }); + + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + const clientIdentity = { name, version }; + + const client = new Client(clientIdentity); try { await connect(client, transport); diff --git a/client/package.json b/client/package.json index db4358fd..6881c1f7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.7", + "version": "0.17.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c35b0ec..b7fc607b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; +import { cleanParams } from "./utils/paramUtils"; +import type { JsonSchemaType } from "./utils/jsonUtils"; import React, { Suspense, useCallback, @@ -107,6 +109,14 @@ const App = () => { const [transportType, setTransportType] = useState< "stdio" | "sse" | "streamable-http" >(getInitialTransportType); + const [connectionType, setConnectionType] = useState<"direct" | "proxy">( + () => { + return ( + (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || + "proxy" + ); + }, + ); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -154,12 +164,12 @@ const App = () => { return migrateFromLegacyAuth(legacyToken, legacyHeaderName); } - // Default to Authorization: Bearer as the most common case + // Default to empty array return [ { name: "Authorization", value: "Bearer ", - enabled: true, + enabled: false, }, ]; }); @@ -254,6 +264,7 @@ const App = () => { oauthClientId, oauthScope, config, + connectionType, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -338,6 +349,10 @@ const App = () => { localStorage.setItem("lastTransportType", transportType); }, [transportType]); + useEffect(() => { + localStorage.setItem("lastConnectionType", connectionType); + }, [connectionType]); + useEffect(() => { if (bearerToken) { localStorage.setItem("lastBearerToken", bearerToken); @@ -764,12 +779,18 @@ const App = () => { lastToolCallOriginTabRef.current = currentTabRef.current; try { + // Find the tool schema to clean parameters properly + const tool = tools.find((t) => t.name === name); + const cleanedParams = tool?.inputSchema + ? cleanParams(params, tool.inputSchema as JsonSchemaType) + : params; + const response = await sendMCPRequest( { method: "tools/call" as const, params: { name, - arguments: params, + arguments: cleanedParams, _meta: { progressToken: progressTokenRef.current++, }, @@ -780,6 +801,8 @@ const App = () => { ); setToolResult(response); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); } catch (e) { const toolResult: CompatibilityCallToolResult = { content: [ @@ -791,6 +814,8 @@ const App = () => { isError: true, }; setToolResult(toolResult); + // Clear validation errors - tool execution errors are shown in ToolResults + setErrors((prev) => ({ ...prev, tools: null })); } }; @@ -882,6 +907,8 @@ const App = () => { logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} + connectionType={connectionType} + setConnectionType={setConnectionType} />
{ isValid: boolean; error: string | null }; +} + const isTypeSupported = ( type: JsonSchemaType["type"], supportedTypes: string[], @@ -63,456 +74,122 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { } }; -const DynamicJsonForm = ({ - schema, - value, - onChange, - maxDepth = 3, -}: DynamicJsonFormProps) => { - const isOnlyJSON = !isSimpleObject(schema); - const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); - const [jsonError, setJsonError] = useState(); - const [copiedJson, setCopiedJson] = useState(false); - const { toast } = useToast(); +const DynamicJsonForm = forwardRef( + ({ schema, value, onChange, maxDepth = 3 }, ref) => { + const isOnlyJSON = !isSimpleObject(schema); + const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); + const [jsonError, setJsonError] = useState(); + const [copiedJson, setCopiedJson] = useState(false); + const { toast } = useToast(); - // Store the raw JSON string to allow immediate feedback during typing - // while deferring parsing until the user stops typing - const [rawJsonValue, setRawJsonValue] = useState( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); + // Store the raw JSON string to allow immediate feedback during typing + // while deferring parsing until the user stops typing + const [rawJsonValue, setRawJsonValue] = useState( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); - // Use a ref to manage debouncing timeouts to avoid parsing JSON - // on every keystroke which would be inefficient and error-prone - const timeoutRef = useRef>(); + // Use a ref to manage debouncing timeouts to avoid parsing JSON + // on every keystroke which would be inefficient and error-prone + const timeoutRef = useRef>(); - // Debounce JSON parsing and parent updates to handle typing gracefully - const debouncedUpdateParent = useCallback( - (jsonString: string) => { - // Clear any existing timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - // Set a new timeout - timeoutRef.current = setTimeout(() => { - try { - const parsed = JSON.parse(jsonString); - onChange(parsed); - setJsonError(undefined); - } catch { - // Don't set error during normal typing + // Debounce JSON parsing and parent updates to handle typing gracefully + const debouncedUpdateParent = useCallback( + (jsonString: string) => { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } - }, 300); - }, - [onChange, setJsonError], - ); - // Update rawJsonValue when value prop changes - useEffect(() => { - if (!isJsonMode) { - setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); - } - }, [value, schema, isJsonMode]); + // Set a new timeout + timeoutRef.current = setTimeout(() => { + try { + const parsed = JSON.parse(jsonString); + onChange(parsed); + setJsonError(undefined); + } catch (err) { + // For invalid JSON, set error and reset to default if it's clearly malformed + const errorMessage = + err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); - const handleSwitchToFormMode = () => { - if (isJsonMode) { - // When switching to Form mode, ensure we have valid JSON + // Reset to default for clearly invalid JSON (not just incomplete typing) + const trimmed = jsonString.trim(); + if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) { + onChange(generateDefaultValue(schema)); + } + } + }, 300); + }, + [onChange, setJsonError, schema], + ); + + // Update rawJsonValue when value prop changes + useEffect(() => { + if (!isJsonMode) { + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + } + }, [value, schema, isJsonMode]); + + const handleSwitchToFormMode = () => { + if (isJsonMode) { + // When switching to Form mode, ensure we have valid JSON + try { + const parsed = JSON.parse(rawJsonValue); + // Update the parent component's state with the parsed value + onChange(parsed); + // Switch to form mode + setIsJsonMode(false); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + } else { + // Update raw JSON value when switching to JSON mode + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + setIsJsonMode(true); + } + }; + + const formatJson = () => { try { - const parsed = JSON.parse(rawJsonValue); - // Update the parent component's state with the parsed value - onChange(parsed); - // Switch to form mode - setIsJsonMode(false); + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) { + return; + } + const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); + setRawJsonValue(formatted); + debouncedUpdateParent(formatted); + setJsonError(undefined); } catch (err) { setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } - } else { - // Update raw JSON value when switching to JSON mode - setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); - setIsJsonMode(true); - } - }; + }; - const formatJson = () => { - try { - const jsonStr = rawJsonValue.trim(); - if (!jsonStr) { - return; + const validateJson = () => { + if (!isJsonMode) return { isValid: true, error: null }; + try { + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) return { isValid: true, error: null }; + const parsed = JSON.parse(jsonStr); + // Clear any pending debounced update and immediately update parent + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + onChange(parsed); + setJsonError(undefined); + return { isValid: true, error: null }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); + return { isValid: false, error: errorMessage }; } - const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); - setRawJsonValue(formatted); - debouncedUpdateParent(formatted); - setJsonError(undefined); - } catch (err) { - setJsonError(err instanceof Error ? err.message : "Invalid JSON"); - } - }; + }; - const renderFormFields = ( - propSchema: JsonSchemaType, - currentValue: JsonValue, - path: string[] = [], - depth: number = 0, - parentSchema?: JsonSchemaType, - propertyName?: string, - ) => { - 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} - /> - ); - } - - // Check if this property is required in the parent schema - const isRequired = - parentSchema?.required?.includes(propertyName || "") ?? false; - - let fieldType = propSchema.type; - if (Array.isArray(fieldType)) { - // Of the possible types, find the first non-null type to determine the control to render - fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; - } - - switch (fieldType) { - case "string": { - if ( - propSchema.oneOf && - propSchema.oneOf.every( - (option) => - typeof option.const === "string" && - typeof option.title === "string", - ) - ) { - return ( - - ); - } - - if (propSchema.enum) { - return ( - - ); - } - - let inputType = "text"; - switch (propSchema.format) { - case "email": - inputType = "email"; - break; - case "uri": - inputType = "url"; - break; - case "date": - inputType = "date"; - break; - case "date-time": - inputType = "datetime-local"; - break; - default: - inputType = "text"; - break; - } - - return ( - { - const val = e.target.value; - // Always allow setting string values, including empty strings - handleFieldChange(path, val); - }} - placeholder={propSchema.description} - required={isRequired} - minLength={propSchema.minLength} - maxLength={propSchema.maxLength} - pattern={propSchema.pattern} - /> - ); - } - - case "number": - return ( - { - const val = e.target.value; - if (!val && !isRequired) { - handleFieldChange(path, undefined); - } else { - const num = Number(val); - if (!isNaN(num)) { - handleFieldChange(path, num); - } - } - }} - placeholder={propSchema.description} - required={isRequired} - min={propSchema.minimum} - max={propSchema.maximum} - /> - ); - - case "integer": - return ( - { - const val = e.target.value; - if (!val && !isRequired) { - handleFieldChange(path, undefined); - } else { - const num = Number(val); - if (!isNaN(num) && Number.isInteger(num)) { - handleFieldChange(path, num); - } - } - }} - placeholder={propSchema.description} - required={isRequired} - min={propSchema.minimum} - max={propSchema.maximum} - /> - ); - - case "boolean": - return ( - handleFieldChange(path, e.target.checked)} - className="w-4 h-4" - required={isRequired} - /> - ); - case "null": - return null; - case "object": - if (!propSchema.properties) { - return ( - { - try { - const parsed = JSON.parse(newValue); - handleFieldChange(path, parsed); - setJsonError(undefined); - } catch (err) { - setJsonError( - err instanceof Error ? err.message : "Invalid JSON", - ); - } - }} - error={jsonError} - /> - ); - } - - return ( -
- {Object.entries(propSchema.properties).map(([key, subSchema]) => ( -
- - {renderFormFields( - subSchema as JsonSchemaType, - (currentValue as Record)?.[key], - [...path, key], - depth + 1, - propSchema, - key, - )} -
- ))} -
- ); - case "array": { - const arrayValue = Array.isArray(currentValue) ? currentValue : []; - if (!propSchema.items) return null; - - // If the array items are simple, render as form fields, otherwise use JSON editor - if (isSimpleObject(propSchema.items)) { - 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, - )} - -
- ))} - -
-
- ); - } - - // For complex arrays, fall back to JSON editor - return ( - { - try { - const parsed = JSON.parse(newValue); - handleFieldChange(path, parsed); - setJsonError(undefined); - } catch (err) { - setJsonError( - err instanceof Error ? err.message : "Invalid JSON", - ); - } - }} - error={jsonError} - /> - ); - } - default: - return null; - } - }; - - const handleFieldChange = (path: string[], fieldValue: JsonValue) => { - if (path.length === 0) { - onChange(fieldValue); - return; - } - - try { - const newValue = updateValueAtPath(value, path, fieldValue); - onChange(newValue); - } catch (error) { - console.error("Failed to update form value:", error); - onChange(value); - } - }; - - const shouldUseJsonMode = - schema.type === "object" && - (!schema.properties || Object.keys(schema.properties).length === 0); - - useEffect(() => { - if (shouldUseJsonMode && !isJsonMode) { - setIsJsonMode(true); - } - }, [shouldUseJsonMode, isJsonMode]); - - const handleCopyJson = useCallback(() => { - const copyToClipboard = async () => { + const handleCopyJson = useCallback(async () => { try { await navigator.clipboard.writeText( JSON.stringify(value, null, 2) ?? "[]", @@ -535,84 +212,450 @@ const DynamicJsonForm = ({ variant: "destructive", }); } + }, [toast, value]); + + useImperativeHandle(ref, () => ({ + validateJson, + })); + + const renderFormFields = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[] = [], + depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, + ) => { + 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} + /> + ); + } + + // Check if this property is required in the parent schema + const isRequired = + parentSchema?.required?.includes(propertyName || "") ?? false; + + let fieldType = propSchema.type; + if (Array.isArray(fieldType)) { + // Of the possible types, find the first non-null type to determine the control to render + fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; + } + + switch (fieldType) { + case "string": { + if ( + propSchema.oneOf && + propSchema.oneOf.every( + (option) => + typeof option.const === "string" && + typeof option.title === "string", + ) + ) { + return ( + + ); + } + + if (propSchema.enum) { + return ( + + ); + } + + let inputType = "text"; + switch (propSchema.format) { + case "email": + inputType = "email"; + break; + case "uri": + inputType = "url"; + break; + case "date": + inputType = "date"; + break; + case "date-time": + inputType = "datetime-local"; + break; + default: + inputType = "text"; + break; + } + + return ( + { + const val = e.target.value; + // Always allow setting string values, including empty strings + handleFieldChange(path, val); + }} + placeholder={propSchema.description} + required={isRequired} + minLength={propSchema.minLength} + maxLength={propSchema.maxLength} + pattern={propSchema.pattern} + /> + ); + } + + case "number": + return ( + { + const val = e.target.value; + if (!val && !isRequired) { + handleFieldChange(path, undefined); + } else { + const num = Number(val); + if (!isNaN(num)) { + handleFieldChange(path, num); + } + } + }} + placeholder={propSchema.description} + required={isRequired} + min={propSchema.minimum} + max={propSchema.maximum} + /> + ); + + case "integer": + return ( + { + const val = e.target.value; + if (!val && !isRequired) { + handleFieldChange(path, undefined); + } else { + const num = Number(val); + if (!isNaN(num) && Number.isInteger(num)) { + handleFieldChange(path, num); + } + } + }} + placeholder={propSchema.description} + required={isRequired} + min={propSchema.minimum} + max={propSchema.maximum} + /> + ); + + case "boolean": + return ( + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={isRequired} + /> + ); + case "null": + return null; + case "object": + if (!propSchema.properties) { + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; + + // If the array items are simple, render as form fields, otherwise use JSON editor + if (isSimpleObject(propSchema.items)) { + 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, + )} + +
+ ))} + +
+
+ ); + } + + // For complex arrays, fall back to JSON editor + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + default: + return null; + } }; - copyToClipboard(); - }, [toast, value]); + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } - return ( -
-
- {isJsonMode && ( - <> + try { + const newValue = updateValueAtPath(value, path, fieldValue); + onChange(newValue); + } catch (error) { + console.error("Failed to update form value:", error); + onChange(value); + } + }; + + const shouldUseJsonMode = + schema.type === "object" && + (!schema.properties || Object.keys(schema.properties).length === 0); + + useEffect(() => { + if (shouldUseJsonMode && !isJsonMode) { + setIsJsonMode(true); + } + }, [shouldUseJsonMode, isJsonMode]); + + return ( +
+
+ {isJsonMode && ( + <> + + + + )} + {!isOnlyJSON && ( - - - )} + )} +
- {!isOnlyJSON && ( - + {isJsonMode ? ( + { + // Always update local state + setRawJsonValue(newValue); + + // Use the debounced function to attempt parsing and updating parent + debouncedUpdateParent(newValue); + }} + error={jsonError} + /> + ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, + // render a simple representation of the JSON data + schema.type === "object" && + (typeof value !== "object" || + value === null || + Object.keys(value).length === 0) && + rawJsonValue && + rawJsonValue !== "{}" ? ( +
+

+ Form view not available for this JSON structure. Using simplified + view: +

+
+              {rawJsonValue}
+            
+

+ Use JSON mode for full editing capabilities. +

+
+ ) : ( + renderFormFields(schema, value) )}
- - {isJsonMode ? ( - { - // Always update local state - setRawJsonValue(newValue); - - // Use the debounced function to attempt parsing and updating parent - debouncedUpdateParent(newValue); - }} - error={jsonError} - /> - ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, - // render a simple representation of the JSON data - schema.type === "object" && - (typeof value !== "object" || - value === null || - Object.keys(value).length === 0) && - rawJsonValue && - rawJsonValue !== "{}" ? ( -
-

- Form view not available for this JSON structure. Using simplified - view: -

-
-            {rawJsonValue}
-          
-

- Use JSON mode for full editing capabilities. -

-
- ) : ( - renderFormFields(schema, value) - )} -
- ); -}; + ); + }, +); export default DynamicJsonForm; diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 81cc196d..8cc2c1fe 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -1,4 +1,7 @@ +import { Search } from "lucide-react"; import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { useState, useMemo, useRef } from "react"; type ListPaneProps = { items: T[]; @@ -20,41 +23,111 @@ const ListPane = ({ title, buttonText, isButtonDisabled, -}: ListPaneProps) => ( -
-
-

{title}

-
-
- - -
- {items.map((item, index) => ( -
setSelectedItem(item)} - > - {renderItem(item)} +}: ListPaneProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const searchInputRef = useRef(null); + + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return items; + + return items.filter((item) => { + const searchableText = [ + (item as { name?: string }).name || "", + (item as { description?: string }).description || "", + ] + .join(" ") + .toLowerCase(); + return searchableText.includes(searchQuery.toLowerCase()); + }); + }, [items, searchQuery]); + + const handleSearchClick = () => { + setIsSearchExpanded(true); + setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + }; + + const handleSearchBlur = () => { + if (!searchQuery.trim()) { + setIsSearchExpanded(false); + } + }; + + return ( +
+
+
+

+ {title} +

+
+ {!isSearchExpanded ? ( + + ) : ( +
+
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="pl-10 w-full transition-all duration-300 ease-in-out" + /> +
+
+ )}
- ))} +
+
+
+ + +
+ {filteredItems.map((item, index) => ( +
setSelectedItem(item)} + > + {renderItem(item)} +
+ ))} + {filteredItems.length === 0 && searchQuery && items.length > 0 && ( +
+ No items found matching "{searchQuery}" +
+ )} +
-
-); + ); +}; export default ListPane; diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 5f44a4f5..6e0fd695 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({

Resource metadata was added in the{" "} - - 2025-DRAFT-v2 specification update + + 2025-06-18 specification update
{authState.resourceMetadataError.message} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index fa590558..0df36bb8 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -63,9 +63,7 @@ const PromptsTab = ({ clearCompletions(); }, [clearCompletions, selectedPrompt]); - const handleInputChange = async (argName: string, value: string) => { - setPromptArgs((prev) => ({ ...prev, [argName]: value })); - + const triggerCompletions = (argName: string, value: string) => { if (selectedPrompt) { requestCompletions( { @@ -79,6 +77,16 @@ const PromptsTab = ({ } }; + const handleInputChange = async (argName: string, value: string) => { + setPromptArgs((prev) => ({ ...prev, [argName]: value })); + triggerCompletions(argName, value); + }; + + const handleFocus = async (argName: string) => { + const currentValue = promptArgs[argName] || ""; + triggerCompletions(argName, currentValue); + }; + const handleGetPrompt = () => { if (selectedPrompt) { getPrompt(selectedPrompt.name, promptArgs); @@ -143,6 +151,7 @@ const PromptsTab = ({ onInputChange={(value) => handleInputChange(arg.name, value) } + onFocus={() => handleFocus(arg.name)} options={completions[arg.name] || []} /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index c0c61fbe..889380d8 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -67,6 +67,8 @@ interface SidebarProps { loggingSupported: boolean; config: InspectorConfig; setConfig: (config: InspectorConfig) => void; + connectionType: "direct" | "proxy"; + setConnectionType: (type: "direct" | "proxy") => void; } const Sidebar = ({ @@ -94,6 +96,8 @@ const Sidebar = ({ loggingSupported, config, setConfig, + connectionType, + setConnectionType, }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); @@ -104,6 +108,8 @@ const Sidebar = ({ const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); + const connectionTypeTip = + "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; // Reusable error reporter for copy actions const reportError = useCallback( (error: unknown) => { @@ -313,6 +319,35 @@ const Sidebar = ({ /> )}

+ + {/* Connection Type switch - only visible for non-STDIO transport types */} + + +
+ + +
+
+ {connectionTypeTip} +
)} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 377521ae..0123296a 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -1,11 +1,11 @@ -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; 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 from "./DynamicJsonForm"; +import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; import { generateDefaultValue, @@ -22,10 +22,11 @@ import { Send, ChevronDown, ChevronUp, + AlertCircle, Copy, CheckCheck, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; @@ -45,6 +46,7 @@ const ToolsTab = ({ setSelectedTool, toolResult, nextCursor, + error, resourceContent, onReadResource, }: { @@ -64,9 +66,20 @@ const ToolsTab = ({ const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetaExpanded, setIsMetaExpanded] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); + const formRefs = useRef>({}); const { toast } = useToast(); const { copied, setCopied } = useCopy(); + // Function to check if any form has validation errors + const checkValidationErrors = () => { + const errors = Object.values(formRefs.current).some( + (ref) => ref && !ref.validateJson().isValid, + ); + setHasValidationErrors(errors); + return errors; + }; + useEffect(() => { const params = Object.entries( selectedTool?.inputSchema.properties ?? [], @@ -79,6 +92,12 @@ const ToolsTab = ({ ), ]); setParams(Object.fromEntries(params)); + + // Reset validation errors when switching tools + setHasValidationErrors(false); + + // Clear form refs for the previous tool + formRefs.current = {}; }, [selectedTool]); return ( @@ -114,6 +133,13 @@ const ToolsTab = ({
{selectedTool ? (
+ {error && ( + + + Error + {error} + + )}

{selectedTool.description}

@@ -159,21 +185,33 @@ const ToolsTab = ({ id={key} name={key} placeholder={prop.description} - value={(params[key] as string) ?? ""} - onChange={(e) => - setParams({ - ...params, - [key]: - e.target.value === "" - ? undefined - : e.target.value, - }) + value={ + params[key] === undefined + ? "" + : String(params[key]) } + onChange={(e) => { + const value = e.target.value; + if (value === "") { + // Field cleared - set to undefined + setParams({ + ...params, + [key]: undefined, + }); + } else { + // Field has value - keep as string + setParams({ + ...params, + [key]: value, + }); + } + }} className="mt-1" /> ) : prop.type === "object" || prop.type === "array" ? (
(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -189,6 +227,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />
@@ -199,19 +239,42 @@ const ToolsTab = ({ id={key} name={key} placeholder={prop.description} - value={(params[key] as string) ?? ""} + value={ + params[key] === undefined + ? "" + : String(params[key]) + } onChange={(e) => { const value = e.target.value; - setParams({ - ...params, - [key]: value === "" ? "" : Number(value), - }); + if (value === "") { + // Field cleared - set to undefined + setParams({ + ...params, + [key]: undefined, + }); + } else { + // Field has value - try to convert to number, but store input either way + const num = Number(value); + if (!isNaN(num)) { + setParams({ + ...params, + [key]: num, + }); + } else { + // Store invalid input as string - let server validate + setParams({ + ...params, + [key]: value, + }); + } + } }} className="mt-1" /> ) : (
(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -224,6 +287,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />
@@ -305,6 +370,9 @@ const ToolsTab = ({
+
+ ); + }; + + return render(); + }; + + describe("validateJson method", () => { + it("should return valid for form mode", () => { + const simpleSchema = { + type: "string" as const, + description: "Test string field", + }; + + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should return valid for valid JSON in JSON mode", () => { + renderFormWithRef(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should return invalid for malformed JSON in JSON mode", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } }); + + // Wait a bit for any debounced updates + await waitFor(() => { + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe( + "false", + ); + expect(validateButton.getAttribute("data-validation-error")).toContain( + "JSON", + ); + }); + }); + + it("should return valid for empty JSON in JSON mode", () => { + renderFormWithRef(); + + // Clear the textarea + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "" } }); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should set error state when validation fails", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { + target: { value: '{ "trailing": "comma", }' }, + }); + + // Trigger validation + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + // Check that validation result shows error + expect(validateButton.getAttribute("data-validation-valid")).toBe( + "false", + ); + expect(validateButton.getAttribute("data-validation-error")).toContain( + "JSON", + ); + }); + }); + + describe("forwardRef functionality", () => { + it("should expose validateJson method through ref", () => { + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const testButton = screen.getByTestId("ref-test-button"); + fireEvent.click(testButton); + + expect(testButton.getAttribute("data-has-validate-method")).toBe("true"); + }); + }); +}); diff --git a/client/src/components/__tests__/ListPane.test.tsx b/client/src/components/__tests__/ListPane.test.tsx new file mode 100644 index 00000000..26b5b9a4 --- /dev/null +++ b/client/src/components/__tests__/ListPane.test.tsx @@ -0,0 +1,203 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, beforeEach, jest } from "@jest/globals"; +import ListPane from "../ListPane"; + +describe("ListPane", () => { + const mockItems = [ + { id: 1, name: "Tool 1", description: "First tool" }, + { id: 2, name: "Tool 2", description: "Second tool" }, + { id: 3, name: "Another Tool", description: "Third tool" }, + ]; + + const defaultProps = { + items: mockItems, + listItems: jest.fn(), + clearItems: jest.fn(), + setSelectedItem: jest.fn(), + renderItem: (item: (typeof mockItems)[0]) =>
{item.name}
, + title: "List tools", + buttonText: "Load Tools", + }; + + const renderListPane = (props = {}) => { + return render(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("should render with title and button", () => { + renderListPane(); + + expect(screen.getByText("List tools")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Load Tools" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); + }); + + it("should render items when provided", () => { + renderListPane(); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("Tool 2")).toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + }); + + it("should render empty state when no items", () => { + renderListPane({ items: [] }); + + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + }); + + it("should render custom item content", () => { + const customRenderItem = (item: (typeof mockItems)[0]) => ( +
+ {item.name} + {item.description} +
+ ); + + renderListPane({ renderItem: customRenderItem }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("First tool")).toBeInTheDocument(); + }); + }); + + describe("Search Functionality", () => { + it("should show search icon initially", () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + expect(searchButton).toBeInTheDocument(); + expect(searchButton.querySelector("svg")).toBeInTheDocument(); + }); + + it("should expand search input when search icon is clicked", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + expect(searchInput).toBeInTheDocument(); + + // Wait for the setTimeout to complete and focus to be set + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(searchInput).toHaveFocus(); + }); + + it("should filter items based on search query", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "Tool" } }); + }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("Tool 2")).toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "Another" } }); + }); + + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + }); + + it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "NonExistent" } }); + }); + + expect( + screen.getByText('No items found matching "NonExistent"'), + ).toBeInTheDocument(); + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + }); + + it("should collapse search when input is empty and loses focus", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "test" } }); + fireEvent.change(searchInput, { target: { value: "" } }); + fireEvent.blur(searchInput); + }); + + const searchButtonAfterCollapse = screen.getByRole("button", { + name: "Search", + }); + expect(searchButtonAfterCollapse).toBeInTheDocument(); + expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0"); + }); + + it("should keep search expanded when input has content and loses focus", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "test" } }); + fireEvent.blur(searchInput); + }); + + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + }); + + it("should search through all item properties (description)", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "Search" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "First tool" } }); + }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 3a23daaf..4684eb63 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -59,6 +59,8 @@ describe("Sidebar", () => { loggingSupported: true, config: DEFAULT_INSPECTOR_CONFIG, setConfig: jest.fn(), + connectionType: "proxy" as const, + setConnectionType: jest.fn(), }; const renderSidebar = (props = {}) => { diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index bc75401d..a50f62df 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -637,4 +637,189 @@ describe("ToolsTab", () => { expect(screen.getByText(/version/i)).toBeInTheDocument(); }); }); + + describe("JSON Validation Integration", () => { + const toolWithJsonParams: Tool = { + name: "jsonTool", + description: "Tool with JSON parameters", + inputSchema: { + type: "object" as const, + required: ["config", "data"], // Make them required so they render as form fields + properties: { + config: { + type: "object" as const, + // No properties defined - this will force JSON mode + }, + data: { + type: "array" as const, + // No items defined - this will force JSON mode + }, + }, + }, + }; + + it("should prevent tool execution when JSON validation fails", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas (there should be at least 1 for JSON parameters) + const textareas = screen.getAllByRole("textbox"); + expect(textareas.length).toBeGreaterThanOrEqual(1); + + // Enter invalid JSON in the first textarea + const configTextarea = textareas[0]; + fireEvent.change(configTextarea, { + target: { value: '{ "invalid": json }' }, + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should allow tool execution when JSON validation passes", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas (should have one for each required field: config and data) + const textareas = screen.getAllByRole("textbox"); + expect(textareas.length).toBe(2); + + // Enter valid JSON in each textarea + fireEvent.change(textareas[0], { + target: { value: '{ "setting": "value" }' }, + }); + fireEvent.change(textareas[1], { + target: { value: '["item1", "item2"]' }, + }); + + // Wait for debounced updates + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully + expect(mockCallTool).toHaveBeenCalled(); + }); + + it("should handle mixed valid and invalid JSON parameters", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + + // Enter invalid JSON that contains both valid and invalid parts + fireEvent.change(textareas[0], { + target: { + value: + '{ "config": { "setting": "value" }, "data": ["unclosed array" }', + }, + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should work with tools that have no JSON parameters", async () => { + const mockCallTool = jest.fn(); + const simpleToolWithStringParam: Tool = { + name: "simpleTool", + description: "Tool with simple parameters", + inputSchema: { + type: "object" as const, + properties: { + message: { type: "string" as const }, + count: { type: "number" as const }, + }, + }, + }; + + renderToolsTab({ + tools: [simpleToolWithStringParam], + selectedTool: simpleToolWithStringParam, + callTool: mockCallTool, + }); + + // Fill in the simple parameters + const messageInput = screen.getByRole("textbox"); + const countInput = screen.getByRole("spinbutton"); + + fireEvent.change(messageInput, { target: { value: "test message" } }); + fireEvent.change(countInput, { target: { value: "5" } }); + + // Run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully (no JSON validation needed) + expect(mockCallTool).toHaveBeenCalledWith( + simpleToolWithStringParam.name, + { + message: "test message", + count: 5, + }, + ); + }); + + it("should handle empty JSON parameters correctly", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + expect(textareas.length).toBe(2); + + // Clear both textareas (empty JSON should be valid) + fireEvent.change(textareas[0], { target: { value: "{}" } }); + fireEvent.change(textareas[1], { target: { value: "[]" } }); + + // Wait for debounced updates + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called (empty JSON is considered valid) + expect(mockCallTool).toHaveBeenCalled(); + }); + }); }); diff --git a/client/src/components/ui/combobox.tsx b/client/src/components/ui/combobox.tsx index 02624086..9e6227b1 100644 --- a/client/src/components/ui/combobox.tsx +++ b/client/src/components/ui/combobox.tsx @@ -19,6 +19,7 @@ interface ComboboxProps { value: string; onChange: (value: string) => void; onInputChange: (value: string) => void; + onFocus?: () => void; options: string[]; placeholder?: string; emptyMessage?: string; @@ -29,6 +30,7 @@ export function Combobox({ value, onChange, onInputChange, + onFocus, options = [], placeholder = "Select...", emptyMessage = "No results found.", @@ -36,6 +38,16 @@ export function Combobox({ }: ComboboxProps) { const [open, setOpen] = React.useState(false); + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + setOpen(newOpen); + if (newOpen && onFocus) { + onFocus(); + } + }, + [onFocus], + ); + const handleSelect = React.useCallback( (option: string) => { onChange(option); @@ -52,7 +64,7 @@ export function Combobox({ ); return ( - +