Merge branch 'main' into main

This commit is contained in:
Cliff Hall
2025-04-14 17:00:34 -04:00
committed by GitHub
22 changed files with 362 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.8.2", "version": "0.9.0",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",

View File

@@ -3,33 +3,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils"; import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;

View File

@@ -1,9 +1,10 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react"; import { useState, memo, useMemo, useCallback, useEffect } from "react";
import { JsonValue } from "./DynamicJsonForm"; import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx"; import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react"; import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps { interface JsonViewProps {
data: unknown; data: unknown;
@@ -11,21 +12,7 @@ interface JsonViewProps {
initialExpandDepth?: number; initialExpandDepth?: number;
className?: string; className?: string;
withCopyButton?: boolean; withCopyButton?: boolean;
} isError?: boolean;
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
} }
const JsonView = memo( const JsonView = memo(
@@ -35,6 +22,7 @@ const JsonView = memo(
initialExpandDepth = 3, initialExpandDepth = 3,
className, className,
withCopyButton = true, withCopyButton = true,
isError = false,
}: JsonViewProps) => { }: JsonViewProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -100,6 +88,7 @@ const JsonView = memo(
name={name} name={name}
depth={0} depth={0}
initialExpandDepth={initialExpandDepth} initialExpandDepth={initialExpandDepth}
isError={isError}
/> />
</div> </div>
</div> </div>
@@ -114,28 +103,28 @@ interface JsonNodeProps {
name?: string; name?: string;
depth: number; depth: number;
initialExpandDepth: number; initialExpandDepth: number;
isError?: boolean;
} }
const JsonNode = memo( const JsonNode = memo(
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => { ({
data,
name,
depth = 0,
initialExpandDepth,
isError = false,
}: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState<Record<string, string>>({
const getDataType = (value: JsonValue): string => {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
};
const dataType = getDataType(data);
const typeStyleMap: Record<string, string> = {
number: "text-blue-600", number: "text-blue-600",
boolean: "text-amber-600", boolean: "text-amber-600",
null: "text-purple-600", null: "text-purple-600",
undefined: "text-gray-600", undefined: "text-gray-600",
string: "text-green-600 break-all whitespace-pre-wrap", string: "text-green-600 group-hover:text-green-500",
error: "text-red-600 group-hover:text-red-500",
default: "text-gray-700", default: "text-gray-700",
}; });
const dataType = getDataType(data);
const renderCollapsible = (isArray: boolean) => { const renderCollapsible = (isArray: boolean) => {
const items = isArray const items = isArray
@@ -236,7 +225,14 @@ const JsonNode = memo(
{name}: {name}:
</span> </span>
)} )}
<pre className={typeStyleMap.string}>"{value}"</pre> <pre
className={clsx(
typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>
"{value}"
</pre>
</div> </div>
); );
} }
@@ -250,8 +246,8 @@ const JsonNode = memo(
)} )}
<pre <pre
className={clsx( className={clsx(
typeStyleMap.string, isError ? typeStyleMap.error : typeStyleMap.string,
"cursor-pointer group-hover:text-green-500", "cursor-pointer break-all whitespace-pre-wrap",
)} )}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"} title={isExpanded ? "Click to collapse" : "Click to expand"}

View File

@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
isButtonDisabled, isButtonDisabled,
}: ListPaneProps<T>) => ( }: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700"> <div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold dark:text-white">{title}</h3> <h3 className="font-semibold dark:text-white">{title}</h3>
</div> </div>
<div className="p-4"> <div className="p-4">

View File

@@ -108,7 +108,7 @@ const PromptsTab = ({
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"} {selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3> </h3>

View File

@@ -162,7 +162,7 @@ const ResourcesTab = ({
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 flex justify-between items-center"> <div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
<h3 <h3
className="font-semibold truncate" className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name} title={selectedResource?.name || selectedTemplate?.name}

View File

@@ -94,7 +94,7 @@ const Sidebar = ({
return ( return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full"> <div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200"> <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center"> <div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold"> <h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version} MCP Inspector v{version}
@@ -105,14 +105,19 @@ const Sidebar = ({
<div className="p-4 flex-1 overflow-auto"> <div className="p-4 flex-1 overflow-auto">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Transport Type</label> <label
className="text-sm font-medium"
htmlFor="transport-type-select"
>
Transport Type
</label>
<Select <Select
value={transportType} value={transportType}
onValueChange={(value: "stdio" | "sse") => onValueChange={(value: "stdio" | "sse") =>
setTransportType(value) setTransportType(value)
} }
> >
<SelectTrigger> <SelectTrigger id="transport-type-select">
<SelectValue placeholder="Select transport type" /> <SelectValue placeholder="Select transport type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -125,8 +130,11 @@ const Sidebar = ({
{transportType === "stdio" ? ( {transportType === "stdio" ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Command</label> <label className="text-sm font-medium" htmlFor="command-input">
Command
</label>
<Input <Input
id="command-input"
placeholder="Command" placeholder="Command"
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
@@ -134,8 +142,14 @@ const Sidebar = ({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Arguments</label> <label
className="text-sm font-medium"
htmlFor="arguments-input"
>
Arguments
</label>
<Input <Input
id="arguments-input"
placeholder="Arguments (space-separated)" placeholder="Arguments (space-separated)"
value={args} value={args}
onChange={(e) => setArgs(e.target.value)} onChange={(e) => setArgs(e.target.value)}
@@ -146,8 +160,11 @@ const Sidebar = ({
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">URL</label> <label className="text-sm font-medium" htmlFor="sse-url-input">
URL
</label>
<Input <Input
id="sse-url-input"
placeholder="URL" placeholder="URL"
value={sseUrl} value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)} onChange={(e) => setSseUrl(e.target.value)}
@@ -159,6 +176,7 @@ const Sidebar = ({
variant="outline" variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)} onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full" className="flex items-center w-full"
aria-expanded={showBearerToken}
> >
{showBearerToken ? ( {showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -169,8 +187,14 @@ const Sidebar = ({
</Button> </Button>
{showBearerToken && ( {showBearerToken && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label> <label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input <Input
id="bearer-token-input"
placeholder="Bearer Token" placeholder="Bearer Token"
value={bearerToken} value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)} onChange={(e) => setBearerToken(e.target.value)}
@@ -189,6 +213,7 @@ const Sidebar = ({
onClick={() => setShowEnvVars(!showEnvVars)} onClick={() => setShowEnvVars(!showEnvVars)}
className="flex items-center w-full" className="flex items-center w-full"
data-testid="env-vars-button" data-testid="env-vars-button"
aria-expanded={showEnvVars}
> >
{showEnvVars ? ( {showEnvVars ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -203,6 +228,7 @@ const Sidebar = ({
<div key={idx} className="space-y-2 pb-4"> <div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label={`Environment variable key ${idx + 1}`}
placeholder="Key" placeholder="Key"
value={key} value={key}
onChange={(e) => { onChange={(e) => {
@@ -245,6 +271,7 @@ const Sidebar = ({
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label={`Environment variable value ${idx + 1}`}
type={shownEnvVars.has(key) ? "text" : "password"} type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value" placeholder="Value"
value={value} value={value}
@@ -311,6 +338,7 @@ const Sidebar = ({
onClick={() => setShowConfig(!showConfig)} onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full" className="flex items-center w-full"
data-testid="config-button" data-testid="config-button"
aria-expanded={showConfig}
> >
{showConfig ? ( {showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -327,7 +355,10 @@ const Sidebar = ({
return ( return (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<label className="text-sm font-medium text-green-600 break-all"> <label
className="text-sm font-medium text-green-600 break-all"
htmlFor={`${configKey}-input`}
>
{configItem.label} {configItem.label}
</label> </label>
<Tooltip> <Tooltip>
@@ -341,6 +372,7 @@ const Sidebar = ({
</div> </div>
{typeof configItem.value === "number" ? ( {typeof configItem.value === "number" ? (
<Input <Input
id={`${configKey}-input`}
type="number" type="number"
data-testid={`${configKey}-input`} data-testid={`${configKey}-input`}
value={configItem.value} value={configItem.value}
@@ -367,7 +399,7 @@ const Sidebar = ({
setConfig(newConfig); setConfig(newConfig);
}} }}
> >
<SelectTrigger> <SelectTrigger id={`${configKey}-input`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -377,6 +409,7 @@ const Sidebar = ({
</Select> </Select>
) : ( ) : (
<Input <Input
id={`${configKey}-input`}
data-testid={`${configKey}-input`} data-testid={`${configKey}-input`}
value={configItem.value} value={configItem.value}
onChange={(e) => { onChange={(e) => {
@@ -400,7 +433,13 @@ const Sidebar = ({
<div className="space-y-2"> <div className="space-y-2">
{connectionStatus === "connected" && ( {connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Button data-testid="connect-button" onClick={onConnect}> <Button
data-testid="connect-button"
onClick={() => {
onDisconnect();
onConnect();
}}
>
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"} {transportType === "stdio" ? "Restart" : "Reconnect"}
</Button> </Button>
@@ -450,14 +489,19 @@ const Sidebar = ({
{loggingSupported && connectionStatus === "connected" && ( {loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label> <label
className="text-sm font-medium"
htmlFor="logging-level-select"
>
Logging Level
</label>
<Select <Select
value={logLevel} value={logLevel}
onValueChange={(value: LoggingLevel) => onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value) sendLogLevelRequest(value)
} }
> >
<SelectTrigger> <SelectTrigger id="logging-level-select">
<SelectValue placeholder="Select logging level" /> <SelectValue placeholder="Select logging level" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils"; import { generateDefaultValue } from "@/utils/schemaUtils";
import { import {
CallToolResultSchema, CallToolResultSchema,
@@ -68,11 +69,18 @@ const ToolsTab = ({
return ( return (
<> <>
<h4 className="font-semibold mb-2"> <h4 className="font-semibold mb-2">
Tool Result: {isError ? "Error" : "Success"} Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
</h4> </h4>
{structuredResult.content.map((item, index) => ( {structuredResult.content.map((item, index) => (
<div key={index} className="mb-2"> <div key={index} className="mb-2">
{item.type === "text" && <JsonView data={item.text} />} {item.type === "text" && (
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && ( {item.type === "image" && (
<img <img
src={`data:${item.mimeType};base64,${item.data}`} src={`data:${item.mimeType};base64,${item.data}`}
@@ -132,7 +140,7 @@ const ToolsTab = ({
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"} {selectedTool ? selectedTool.name : "Select a tool"}
</h3> </h3>

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals"; import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm"; import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "../DynamicJsonForm"; import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => { describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => { const renderForm = (props = {}) => {

View File

@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = "Button"; Button.displayName = "Button";
export { Button, buttonVariants }; export { Button };

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface InputProps export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface TextareaProps export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -15,13 +15,6 @@ type ToasterToast = ToastProps & {
action?: ToastActionElement; action?: ToastActionElement;
}; };
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0; let count = 0;
function genId() { function genId() {
@@ -29,23 +22,28 @@ function genId() {
return count.toString(); return count.toString();
} }
type ActionType = typeof actionTypes; const enum ActionType {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"]; type: ActionType.ADD_TOAST;
toast: ToasterToast; toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"]; type: ActionType.UPDATE_TOAST;
toast: Partial<ToasterToast>; toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"]; type: ActionType.DISMISS_TOAST;
toastId?: ToasterToast["id"]; toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"]; type: ActionType.REMOVE_TOAST;
toastId?: ToasterToast["id"]; toastId?: ToasterToast["id"];
}; };
@@ -63,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId); toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: ActionType.REMOVE_TOAST,
toastId: toastId, toastId: toastId,
}); });
}, TOAST_REMOVE_DELAY); }, TOAST_REMOVE_DELAY);
@@ -73,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case ActionType.ADD_TOAST:
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}; };
case "UPDATE_TOAST": case ActionType.UPDATE_TOAST:
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
@@ -87,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
), ),
}; };
case "DISMISS_TOAST": { case ActionType.DISMISS_TOAST: {
const { toastId } = action; const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -112,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
), ),
}; };
} }
case "REMOVE_TOAST": case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
@@ -144,13 +142,14 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: ActionType.UPDATE_TOAST,
toast: { ...props, id }, toast: { ...props, id },
}); });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); const dismiss = () =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: ActionType.ADD_TOAST,
toast: { toast: {
...props, ...props,
id, id,
@@ -184,7 +183,8 @@ function useToast() {
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
}; };
} }

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { import {
ResourceReference, ResourceReference,
PromptReference, PromptReference,
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
wait: number, wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>; let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) { return (...args: Parameters<T>) => {
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait); timeout = setTimeout(() => {
void func(...args);
}, wait);
}; };
} }
@@ -58,8 +60,8 @@ export function useCompletionState(
}); });
}, [cleanup]); }, [cleanup]);
const requestCompletions = useCallback( const requestCompletions = useMemo(() => {
debounce( return debounce(
async ( async (
ref: ResourceReference | PromptReference, ref: ResourceReference | PromptReference,
argName: string, argName: string,
@@ -94,7 +96,7 @@ export function useCompletionState(
loading: { ...prev.loading, [argName]: false }, loading: { ...prev.loading, [argName]: false },
})); }));
} }
} catch (err) { } catch {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -108,9 +110,8 @@ export function useCompletionState(
} }
}, },
debounceMs, debounceMs,
), );
[handleCompletion, completionsSupported, cleanup, debounceMs], }, [handleCompletion, completionsSupported, cleanup, debounceMs]);
);
// Clear completions when support status changes // Clear completions when support status changes
useEffect(() => { useEffect(() => {

View File

@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
document.documentElement.classList.toggle("dark", newTheme === "dark"); document.documentElement.classList.toggle("dark", newTheme === "dark");
} }
}, []); }, []);
return useMemo(() => [theme, setThemeWithSideEffect], [theme]); return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
}; };
export default useTheme; export default useTheme;

View File

@@ -1,5 +1,146 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils"; import {
import { JsonValue } from "../../components/DynamicJsonForm"; getDataType,
tryParseJson,
updateValueAtPath,
getValueAtPath,
} from "../jsonUtils";
import type { JsonValue } from "../jsonUtils";
describe("getDataType", () => {
test("should return 'string' for string values", () => {
expect(getDataType("hello")).toBe("string");
expect(getDataType("")).toBe("string");
});
test("should return 'number' for number values", () => {
expect(getDataType(123)).toBe("number");
expect(getDataType(0)).toBe("number");
expect(getDataType(-10)).toBe("number");
expect(getDataType(1.5)).toBe("number");
expect(getDataType(NaN)).toBe("number");
expect(getDataType(Infinity)).toBe("number");
});
test("should return 'boolean' for boolean values", () => {
expect(getDataType(true)).toBe("boolean");
expect(getDataType(false)).toBe("boolean");
});
test("should return 'undefined' for undefined value", () => {
expect(getDataType(undefined)).toBe("undefined");
});
test("should return 'object' for object values", () => {
expect(getDataType({})).toBe("object");
expect(getDataType({ key: "value" })).toBe("object");
});
test("should return 'array' for array values", () => {
expect(getDataType([])).toBe("array");
expect(getDataType([1, 2, 3])).toBe("array");
expect(getDataType(["a", "b", "c"])).toBe("array");
expect(getDataType([{}, { nested: true }])).toBe("array");
});
test("should return 'null' for null value", () => {
expect(getDataType(null)).toBe("null");
});
});
describe("tryParseJson", () => {
test("should correctly parse valid JSON object", () => {
const jsonString = '{"name":"test","value":123}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test", value: 123 });
});
test("should correctly parse valid JSON array", () => {
const jsonString = '[1,2,3,"test"]';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual([1, 2, 3, "test"]);
});
test("should correctly parse JSON with whitespace", () => {
const jsonString = ' { "name" : "test" } ';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
});
test("should correctly parse nested JSON structures", () => {
const jsonString =
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({
user: {
name: "test",
details: {
age: 30,
},
},
items: [1, 2, 3],
});
});
test("should correctly parse empty objects and arrays", () => {
expect(tryParseJson("{}").success).toBe(true);
expect(tryParseJson("{}").data).toEqual({});
expect(tryParseJson("[]").success).toBe(true);
expect(tryParseJson("[]").data).toEqual([]);
});
test("should return failure for non-JSON strings", () => {
const nonJsonString = "this is not json";
const result = tryParseJson(nonJsonString);
expect(result.success).toBe(false);
expect(result.data).toBe(nonJsonString);
});
test("should return failure for malformed JSON", () => {
const malformedJson = '{"name":"test",}';
const result = tryParseJson(malformedJson);
expect(result.success).toBe(false);
expect(result.data).toBe(malformedJson);
});
test("should return failure for strings with correct delimiters but invalid JSON", () => {
const invalidJson = "{name:test}";
const result = tryParseJson(invalidJson);
expect(result.success).toBe(false);
expect(result.data).toBe(invalidJson);
});
test("should handle edge cases", () => {
expect(tryParseJson("").success).toBe(false);
expect(tryParseJson("").data).toBe("");
expect(tryParseJson(" ").success).toBe(false);
expect(tryParseJson(" ").data).toBe(" ");
expect(tryParseJson("null").success).toBe(false);
expect(tryParseJson("null").data).toBe("null");
expect(tryParseJson('"string"').success).toBe(false);
expect(tryParseJson('"string"').data).toBe('"string"');
expect(tryParseJson("123").success).toBe(false);
expect(tryParseJson("123").data).toBe("123");
expect(tryParseJson("true").success).toBe(false);
expect(tryParseJson("true").data).toBe("true");
});
});
describe("updateValueAtPath", () => { describe("updateValueAtPath", () => {
// Basic functionality tests // Basic functionality tests
@@ -8,17 +149,17 @@ describe("updateValueAtPath", () => {
}); });
test("initializes an empty object when input is null/undefined and path starts with a string", () => { test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({ expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
foo: "bar", foo: "bar",
}); });
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({ expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
foo: "bar", foo: "bar",
}); });
}); });
test("initializes an empty array when input is null/undefined and path starts with a number", () => { test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]); expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]); expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
}); });
// Object update tests // Object update tests
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
}); });
test("returns default value when input is null/undefined", () => { test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default"); expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe( expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
"default",
);
}); });
test("handles array indices correctly", () => { test("handles array indices correctly", () => {

View File

@@ -1,5 +1,5 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils"; import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm"; import type { JsonSchemaType } from "../jsonUtils";
describe("generateDefaultValue", () => { describe("generateDefaultValue", () => {
test("generates default string", () => { test("generates default string", () => {

View File

@@ -1,7 +1,66 @@
import { JsonValue } from "../components/DynamicJsonForm"; export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export type JsonObject = { [key: string]: JsonValue }; export type JsonObject = { [key: string]: JsonValue };
export type DataType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function"
| "array"
| "null";
export function getDataType(value: JsonValue): DataType {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
export function tryParseJson(str: string): {
success: boolean;
data: JsonValue;
} {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
/** /**
* Updates a value at a specific path in a nested JSON structure * Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value * @param obj The original JSON value

View File

@@ -1,5 +1,4 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm"; import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import { JsonObject } from "./jsonPathUtils";
/** /**
* Generates a default value based on a JSON schema type * Generates a default value based on a JSON schema type

12
package-lock.json generated
View File

@@ -1,20 +1,20 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.8.2", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.8.2", "version": "0.9.0",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"client", "client",
"server" "server"
], ],
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-client": "^0.8.2", "@modelcontextprotocol/inspector-client": "^0.9.0",
"@modelcontextprotocol/inspector-server": "^0.8.2", "@modelcontextprotocol/inspector-server": "^0.9.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",
@@ -32,7 +32,7 @@
}, },
"client": { "client": {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.8.2", "version": "0.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.9.0",
@@ -9500,7 +9500,7 @@
}, },
"server": { "server": {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.8.2", "version": "0.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.9.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.8.2", "version": "0.9.0",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -36,8 +36,8 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-client": "^0.8.2", "@modelcontextprotocol/inspector-client": "^0.9.0",
"@modelcontextprotocol/inspector-server": "^0.8.2", "@modelcontextprotocol/inspector-server": "^0.9.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.8.2", "version": "0.9.0",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",