Merge remote-tracking branch 'upstream/main' into issue-846
This commit is contained in:
commit
8367ef242b
17 changed files with 530 additions and 194 deletions
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# MCP Inspector Development Guide
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
- Build all: `npm run build`
|
||||||
|
- Build client: `npm run build-client`
|
||||||
|
- Build server: `npm run build-server`
|
||||||
|
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
|
||||||
|
- Format code: `npm run prettier-fix`
|
||||||
|
- Client lint: `cd client && npm run lint`
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript with proper type annotations
|
||||||
|
- Follow React functional component patterns with hooks
|
||||||
|
- Use ES modules (import/export) not CommonJS
|
||||||
|
- Use Prettier for formatting (auto-formatted on commit)
|
||||||
|
- Follow existing naming conventions:
|
||||||
|
- camelCase for variables and functions
|
||||||
|
- PascalCase for component names and types
|
||||||
|
- kebab-case for file names
|
||||||
|
- Use async/await for asynchronous operations
|
||||||
|
- Implement proper error handling with try/catch blocks
|
||||||
|
- Use Tailwind CSS for styling in the client
|
||||||
|
- Keep components small and focused on a single responsibility
|
||||||
|
|
||||||
|
## Project Organization
|
||||||
|
|
||||||
|
The project is organized as a monorepo with workspaces:
|
||||||
|
|
||||||
|
- `client/`: React frontend with Vite, TypeScript and Tailwind
|
||||||
|
- `server/`: Express backend with TypeScript
|
||||||
|
- `cli/`: Command-line interface for testing and invoking MCP server methods directly
|
||||||
45
CLAUDE.md
45
CLAUDE.md
|
|
@ -1,44 +1 @@
|
||||||
# MCP Inspector Development Guide
|
@./AGENTS.md
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
|
|
||||||
- Build all: `npm run build`
|
|
||||||
- Build client: `npm run build-client`
|
|
||||||
- Build server: `npm run build-server`
|
|
||||||
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
|
|
||||||
- Format code: `npm run prettier-fix`
|
|
||||||
- Client lint: `cd client && npm run lint`
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
- Use TypeScript with proper type annotations
|
|
||||||
- Follow React functional component patterns with hooks
|
|
||||||
- Use ES modules (import/export) not CommonJS
|
|
||||||
- Use Prettier for formatting (auto-formatted on commit)
|
|
||||||
- Follow existing naming conventions:
|
|
||||||
- camelCase for variables and functions
|
|
||||||
- PascalCase for component names and types
|
|
||||||
- kebab-case for file names
|
|
||||||
- Use async/await for asynchronous operations
|
|
||||||
- Implement proper error handling with try/catch blocks
|
|
||||||
- Use Tailwind CSS for styling in the client
|
|
||||||
- Keep components small and focused on a single responsibility
|
|
||||||
|
|
||||||
## Project Organization
|
|
||||||
|
|
||||||
The project is organized as a monorepo with workspaces:
|
|
||||||
|
|
||||||
- `client/`: React frontend with Vite, TypeScript and Tailwind
|
|
||||||
- `server/`: Express backend with TypeScript
|
|
||||||
- `cli/`: Command-line interface for testing and invoking MCP server methods directly
|
|
||||||
|
|
||||||
## Tool Input Validation Guidelines
|
|
||||||
|
|
||||||
When handling tool input parameters and form fields:
|
|
||||||
|
|
||||||
- **Optional fields with empty values should be omitted entirely** - Do not send empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value
|
|
||||||
- **Fields with explicit defaults should preserve their default values** - If a field has an explicit default in its schema (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects
|
|
||||||
- **Required fields should preserve their values even when empty** - This allows the server to properly validate and return appropriate error messages
|
|
||||||
- **Deeper validation should be handled by the server** - Inspector should focus on basic field presence, while the MCP server handles parameter validation according to its schema
|
|
||||||
|
|
||||||
These guidelines ensure clean parameter passing and proper separation of concerns between the Inspector client and MCP servers.
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,10 @@ const App = () => {
|
||||||
return localStorage.getItem("lastOauthScope") || "";
|
return localStorage.getItem("lastOauthScope") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastOauthClientSecret") || "";
|
||||||
|
});
|
||||||
|
|
||||||
// Custom headers state with migration from legacy auth
|
// Custom headers state with migration from legacy auth
|
||||||
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
||||||
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
||||||
|
|
@ -262,6 +266,7 @@ const App = () => {
|
||||||
env,
|
env,
|
||||||
customHeaders,
|
customHeaders,
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
|
oauthClientSecret,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
connectionType,
|
connectionType,
|
||||||
|
|
@ -394,6 +399,10 @@ const App = () => {
|
||||||
localStorage.setItem("lastOauthScope", oauthScope);
|
localStorage.setItem("lastOauthScope", oauthScope);
|
||||||
}, [oauthScope]);
|
}, [oauthScope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
|
||||||
|
}, [oauthClientSecret]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
@ -900,6 +909,8 @@ const App = () => {
|
||||||
setCustomHeaders={setCustomHeaders}
|
setCustomHeaders={setCustomHeaders}
|
||||||
oauthClientId={oauthClientId}
|
oauthClientId={oauthClientId}
|
||||||
setOauthClientId={setOauthClientId}
|
setOauthClientId={setOauthClientId}
|
||||||
|
oauthClientSecret={oauthClientSecret}
|
||||||
|
setOauthClientSecret={setOauthClientSecret}
|
||||||
oauthScope={oauthScope}
|
oauthScope={oauthScope}
|
||||||
setOauthScope={setOauthScope}
|
setOauthScope={setOauthScope}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, memo, useMemo, useCallback } from "react";
|
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import type React from "react";
|
||||||
import type { JsonValue } from "@/utils/jsonUtils";
|
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";
|
||||||
|
|
@ -51,7 +52,7 @@ const JsonView = memo(
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [toast, normalizedData]);
|
}, [toast, normalizedData, setCopied]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("p-4 border rounded relative", className)}>
|
<div className={clsx("p-4 border rounded relative", className)}>
|
||||||
|
|
@ -101,6 +102,7 @@ const JsonNode = memo(
|
||||||
initialExpandDepth,
|
initialExpandDepth,
|
||||||
isError = false,
|
isError = false,
|
||||||
}: JsonNodeProps) => {
|
}: JsonNodeProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||||
const [typeStyleMap] = useState<Record<string, string>>({
|
const [typeStyleMap] = useState<Record<string, string>>({
|
||||||
number: "text-blue-600",
|
number: "text-blue-600",
|
||||||
|
|
@ -113,6 +115,52 @@ const JsonNode = memo(
|
||||||
});
|
});
|
||||||
const dataType = getDataType(data);
|
const dataType = getDataType(data);
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
if (copied) {
|
||||||
|
timeoutId = setTimeout(() => setCopied(false), 500);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const handleCopyValue = useCallback(
|
||||||
|
(value: JsonValue) => {
|
||||||
|
try {
|
||||||
|
let text: string;
|
||||||
|
const valueType = getDataType(value);
|
||||||
|
switch (valueType) {
|
||||||
|
case "string":
|
||||||
|
text = value as unknown as string;
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
case "boolean":
|
||||||
|
text = String(value);
|
||||||
|
break;
|
||||||
|
case "null":
|
||||||
|
text = "null";
|
||||||
|
break;
|
||||||
|
case "undefined":
|
||||||
|
text = "undefined";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
text = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
const renderCollapsible = (isArray: boolean) => {
|
const renderCollapsible = (isArray: boolean) => {
|
||||||
const items = isArray
|
const items = isArray
|
||||||
? (data as JsonValue[])
|
? (data as JsonValue[])
|
||||||
|
|
@ -206,7 +254,7 @@ const JsonNode = memo(
|
||||||
|
|
||||||
if (!isTooLong) {
|
if (!isTooLong) {
|
||||||
return (
|
return (
|
||||||
<div className="flex mr-1 rounded hover:bg-gray-800/20">
|
<div className="flex mr-1 rounded hover:bg-gray-800/20 group items-start">
|
||||||
{name && (
|
{name && (
|
||||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
{name}:
|
{name}:
|
||||||
|
|
@ -220,12 +268,28 @@ const JsonNode = memo(
|
||||||
>
|
>
|
||||||
"{value}"
|
"{value}"
|
||||||
</pre>
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyValue(value as unknown as JsonValue);
|
||||||
|
}}
|
||||||
|
aria-label={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
title={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
|
<div className="flex mr-1 rounded group hover:bg-gray-800/20 items-start">
|
||||||
{name && (
|
{name && (
|
||||||
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
{name}:
|
{name}:
|
||||||
|
|
@ -241,6 +305,22 @@ const JsonNode = memo(
|
||||||
>
|
>
|
||||||
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
||||||
</pre>
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyValue(value as unknown as JsonValue);
|
||||||
|
}}
|
||||||
|
aria-label={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
title={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -253,7 +333,7 @@ const JsonNode = memo(
|
||||||
return renderString(data as string);
|
return renderString(data as string);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
|
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20 group">
|
||||||
{name && (
|
{name && (
|
||||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
{name}:
|
{name}:
|
||||||
|
|
@ -262,6 +342,22 @@ const JsonNode = memo(
|
||||||
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
||||||
{data === null ? "null" : String(data)}
|
{data === null ? "null" : String(data)}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyValue(data as JsonValue);
|
||||||
|
}}
|
||||||
|
aria-label={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
title={name ? `Copy value of ${name}` : "Copy value"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ const PromptsTab = ({
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription className="break-all">
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : selectedPrompt ? (
|
) : selectedPrompt ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,9 @@ const ResourcesTab = ({
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription className="break-all">
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : selectedResource ? (
|
) : selectedResource ? (
|
||||||
<JsonView
|
<JsonView
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ const SamplingRequest = ({
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "endTurn",
|
default: "assistant",
|
||||||
description: "Role of the model",
|
description: "Role of the model",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ interface SidebarProps {
|
||||||
setCustomHeaders: (headers: CustomHeadersType) => void;
|
setCustomHeaders: (headers: CustomHeadersType) => void;
|
||||||
oauthClientId: string;
|
oauthClientId: string;
|
||||||
setOauthClientId: (id: string) => void;
|
setOauthClientId: (id: string) => void;
|
||||||
|
oauthClientSecret: string;
|
||||||
|
setOauthClientSecret: (secret: string) => void;
|
||||||
oauthScope: string;
|
oauthScope: string;
|
||||||
setOauthScope: (scope: string) => void;
|
setOauthScope: (scope: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
|
|
@ -87,6 +89,8 @@ const Sidebar = ({
|
||||||
setCustomHeaders,
|
setCustomHeaders,
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
setOauthClientId,
|
setOauthClientId,
|
||||||
|
oauthClientSecret,
|
||||||
|
setOauthClientSecret,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
setOauthScope,
|
setOauthScope,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
|
@ -104,6 +108,7 @@ const Sidebar = ({
|
||||||
const [showAuthConfig, setShowAuthConfig] = useState(false);
|
const [showAuthConfig, setShowAuthConfig] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||||
|
const [showClientSecret, setShowClientSecret] = useState(false);
|
||||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||||
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -555,6 +560,38 @@ const Sidebar = ({
|
||||||
data-testid="oauth-client-id-input"
|
data-testid="oauth-client-id-input"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Client Secret
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type={showClientSecret ? "text" : "password"}
|
||||||
|
placeholder="Client Secret (optional)"
|
||||||
|
onChange={(e) => setOauthClientSecret(e.target.value)}
|
||||||
|
value={oauthClientSecret}
|
||||||
|
data-testid="oauth-client-secret-input"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
|
onClick={() => setShowClientSecret(!showClientSecret)}
|
||||||
|
aria-label={
|
||||||
|
showClientSecret ? "Hide secret" : "Show secret"
|
||||||
|
}
|
||||||
|
aria-pressed={showClientSecret}
|
||||||
|
title={
|
||||||
|
showClientSecret ? "Hide secret" : "Show secret"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showClientSecret ? (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
Redirect URL
|
Redirect URL
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -114,7 +121,7 @@ const ToolsTab = ({
|
||||||
renderItem={(tool) => (
|
renderItem={(tool) => (
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start">
|
||||||
<span className="flex-1">{tool.name}</span>
|
<span className="flex-1">{tool.name}</span>
|
||||||
<span className="text-sm text-gray-500 text-left">
|
<span className="text-sm text-gray-500 text-left line-clamp-3">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,10 +144,12 @@ const ToolsTab = ({
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription className="break-all">
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||||
{selectedTool.description}
|
{selectedTool.description}
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
|
|
@ -151,148 +160,214 @@ const ToolsTab = ({
|
||||||
const required = isPropertyRequired(key, inputSchema);
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<Label
|
<div className="flex justify-between">
|
||||||
htmlFor={key}
|
<Label
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
htmlFor={key}
|
||||||
>
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
{key}
|
>
|
||||||
{required && (
|
{key}
|
||||||
<span className="text-red-500 ml-1">*</span>
|
{required && (
|
||||||
)}
|
<span className="text-red-500 ml-1">*</span>
|
||||||
</Label>
|
)}
|
||||||
{prop.type === "boolean" ? (
|
</Label>
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
{prop.nullable ? (
|
||||||
<Checkbox
|
<div className="flex items-center space-x-2">
|
||||||
id={key}
|
<Checkbox
|
||||||
name={key}
|
id={key}
|
||||||
checked={!!params[key]}
|
name={key}
|
||||||
onCheckedChange={(checked: boolean) =>
|
checked={params[key] === null}
|
||||||
setParams({
|
onCheckedChange={(checked: boolean) =>
|
||||||
...params,
|
|
||||||
[key]: checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={key}
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{prop.description || "Toggle this option"}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : prop.type === "string" ? (
|
|
||||||
<Textarea
|
|
||||||
id={key}
|
|
||||||
name={key}
|
|
||||||
placeholder={prop.description}
|
|
||||||
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" ? (
|
|
||||||
<div className="mt-1">
|
|
||||||
<DynamicJsonForm
|
|
||||||
ref={(ref) => (formRefs.current[key] = ref)}
|
|
||||||
schema={{
|
|
||||||
type: prop.type,
|
|
||||||
properties: prop.properties,
|
|
||||||
description: prop.description,
|
|
||||||
items: prop.items,
|
|
||||||
}}
|
|
||||||
value={
|
|
||||||
(params[key] as JsonValue) ??
|
|
||||||
generateDefaultValue(prop)
|
|
||||||
}
|
|
||||||
onChange={(newValue: JsonValue) => {
|
|
||||||
setParams({
|
|
||||||
...params,
|
|
||||||
[key]: newValue,
|
|
||||||
});
|
|
||||||
// Check validation after a short delay to allow form to update
|
|
||||||
setTimeout(checkValidationErrors, 100);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : prop.type === "number" ||
|
|
||||||
prop.type === "integer" ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id={key}
|
|
||||||
name={key}
|
|
||||||
placeholder={prop.description}
|
|
||||||
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 - try to convert to number, but store input either way
|
|
||||||
const num = Number(value);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: num,
|
[key]: checked ? null : prop.default,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={key}
|
||||||
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
null
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="toolinputwrapper"
|
||||||
|
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{prop.type === "boolean" ? (
|
||||||
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
|
<Checkbox
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
checked={!!params[key]}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={key}
|
||||||
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{prop.description || "Toggle this option"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : prop.type === "string" && prop.enum ? (
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
params[key] === undefined
|
||||||
|
? ""
|
||||||
|
: String(params[key])
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "") {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Store invalid input as string - let server validate
|
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={key} className="mt-1">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
prop.description || "Select an option"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{prop.enum.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : prop.type === "string" ? (
|
||||||
|
<Textarea
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
placeholder={prop.description}
|
||||||
|
value={
|
||||||
|
params[key] === undefined
|
||||||
|
? ""
|
||||||
|
: String(params[key])
|
||||||
}
|
}
|
||||||
}}
|
onChange={(e) => {
|
||||||
className="mt-1"
|
const value = e.target.value;
|
||||||
/>
|
if (value === "") {
|
||||||
) : (
|
// Field cleared - set to undefined
|
||||||
<div className="mt-1">
|
setParams({
|
||||||
<DynamicJsonForm
|
...params,
|
||||||
ref={(ref) => (formRefs.current[key] = ref)}
|
[key]: undefined,
|
||||||
schema={{
|
});
|
||||||
type: prop.type,
|
} else {
|
||||||
properties: prop.properties,
|
// Field has value - keep as string
|
||||||
description: prop.description,
|
setParams({
|
||||||
items: prop.items,
|
...params,
|
||||||
}}
|
[key]: value,
|
||||||
value={params[key] as JsonValue}
|
});
|
||||||
onChange={(newValue: JsonValue) => {
|
}
|
||||||
setParams({
|
|
||||||
...params,
|
|
||||||
[key]: newValue,
|
|
||||||
});
|
|
||||||
// Check validation after a short delay to allow form to update
|
|
||||||
setTimeout(checkValidationErrors, 100);
|
|
||||||
}}
|
}}
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
) : prop.type === "object" ||
|
||||||
)}
|
prop.type === "array" ? (
|
||||||
|
<div className="mt-1">
|
||||||
|
<DynamicJsonForm
|
||||||
|
ref={(ref) => (formRefs.current[key] = ref)}
|
||||||
|
schema={{
|
||||||
|
type: prop.type,
|
||||||
|
properties: prop.properties,
|
||||||
|
description: prop.description,
|
||||||
|
items: prop.items,
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
(params[key] as JsonValue) ??
|
||||||
|
generateDefaultValue(prop)
|
||||||
|
}
|
||||||
|
onChange={(newValue: JsonValue) => {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: newValue,
|
||||||
|
});
|
||||||
|
// Check validation after a short delay to allow form to update
|
||||||
|
setTimeout(checkValidationErrors, 100);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : prop.type === "number" ||
|
||||||
|
prop.type === "integer" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
placeholder={prop.description}
|
||||||
|
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 - 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1">
|
||||||
|
<DynamicJsonForm
|
||||||
|
ref={(ref) => (formRefs.current[key] = ref)}
|
||||||
|
schema={{
|
||||||
|
type: prop.type,
|
||||||
|
properties: prop.properties,
|
||||||
|
description: prop.description,
|
||||||
|
items: prop.items,
|
||||||
|
}}
|
||||||
|
value={params[key] as JsonValue}
|
||||||
|
onChange={(newValue: JsonValue) => {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: newValue,
|
||||||
|
});
|
||||||
|
// Check validation after a short delay to allow form to update
|
||||||
|
setTimeout(checkValidationErrors, 100);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ describe("Sidebar", () => {
|
||||||
setSseUrl: jest.fn(),
|
setSseUrl: jest.fn(),
|
||||||
oauthClientId: "",
|
oauthClientId: "",
|
||||||
setOauthClientId: jest.fn(),
|
setOauthClientId: jest.fn(),
|
||||||
|
oauthClientSecret: "",
|
||||||
|
setOauthClientSecret: jest.fn(),
|
||||||
oauthScope: "",
|
oauthScope: "",
|
||||||
setOauthScope: jest.fn(),
|
setOauthScope: jest.fn(),
|
||||||
env: {},
|
env: {},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ describe("ToolsTab", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "tool4",
|
||||||
|
description: "Tool with nullable field",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
num: { type: ["number", "null"] as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
|
@ -135,6 +145,38 @@ describe("ToolsTab", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow specifying null value", async () => {
|
||||||
|
const mockCallTool = jest.fn();
|
||||||
|
const toolWithNullableField = mockTools[3];
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
tools: [toolWithNullableField],
|
||||||
|
selectedTool: toolWithNullableField,
|
||||||
|
callTool: mockCallTool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nullToggleButton = screen.getByRole("checkbox", { name: /null/i });
|
||||||
|
expect(nullToggleButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(nullToggleButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("toolinputwrapper").classList).toContain(
|
||||||
|
"pointer-events-none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(runButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool should have been called with null value
|
||||||
|
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
|
||||||
|
num: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should disable button and change text while tool is running", async () => {
|
it("should disable button and change text while tool is running", async () => {
|
||||||
// Create a promise that we can resolve later
|
// Create a promise that we can resolve later
|
||||||
let resolvePromise: ((value: unknown) => void) | undefined;
|
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||||
|
|
@ -638,6 +680,64 @@ describe("ToolsTab", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Enum Parameters", () => {
|
||||||
|
const toolWithEnumParam: Tool = {
|
||||||
|
name: "enumTool",
|
||||||
|
description: "Tool with enum parameter",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
format: {
|
||||||
|
type: "string" as const,
|
||||||
|
enum: ["json", "xml", "csv", "yaml"],
|
||||||
|
description: "Output format",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock scrollIntoView for Radix UI Select
|
||||||
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render enum parameter as dropdown", () => {
|
||||||
|
renderToolsTab({
|
||||||
|
tools: [toolWithEnumParam],
|
||||||
|
selectedTool: toolWithEnumParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should render a select button instead of textarea
|
||||||
|
const selectTrigger = screen.getByRole("combobox", { name: /format/i });
|
||||||
|
expect(selectTrigger).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render non-enum string parameter as textarea", () => {
|
||||||
|
const toolWithStringParam: Tool = {
|
||||||
|
name: "stringTool",
|
||||||
|
description: "Tool with regular string parameter",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
text: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Some text input",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
tools: [toolWithStringParam],
|
||||||
|
selectedTool: toolWithStringParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should render textarea, not select
|
||||||
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("JSON Validation Integration", () => {
|
describe("JSON Validation Integration", () => {
|
||||||
const toolWithJsonParams: Tool = {
|
const toolWithJsonParams: Tool = {
|
||||||
name: "jsonTool",
|
name: "jsonTool",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ interface UseConnectionOptions {
|
||||||
// Custom headers support
|
// Custom headers support
|
||||||
customHeaders?: CustomHeaders;
|
customHeaders?: CustomHeaders;
|
||||||
oauthClientId?: string;
|
oauthClientId?: string;
|
||||||
|
oauthClientSecret?: string;
|
||||||
oauthScope?: string;
|
oauthScope?: string;
|
||||||
config: InspectorConfig;
|
config: InspectorConfig;
|
||||||
connectionType?: "direct" | "proxy";
|
connectionType?: "direct" | "proxy";
|
||||||
|
|
@ -89,6 +90,7 @@ export function useConnection({
|
||||||
env,
|
env,
|
||||||
customHeaders,
|
customHeaders,
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
|
oauthClientSecret,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
connectionType = "proxy",
|
connectionType = "proxy",
|
||||||
|
|
@ -125,12 +127,20 @@ export function useConnection({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientInformation: { client_id: string; client_secret?: string } = {
|
||||||
|
client_id: oauthClientId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (oauthClientSecret) {
|
||||||
|
clientInformation.client_secret = oauthClientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
saveClientInformationToSessionStorage({
|
saveClientInformationToSessionStorage({
|
||||||
serverUrl: sseUrl,
|
serverUrl: sseUrl,
|
||||||
clientInformation: { client_id: oauthClientId },
|
clientInformation,
|
||||||
isPreregistered: true,
|
isPreregistered: true,
|
||||||
});
|
});
|
||||||
}, [oauthClientId, sseUrl]);
|
}, [oauthClientId, oauthClientSecret, sseUrl]);
|
||||||
|
|
||||||
const pushHistory = (request: object, response?: object) => {
|
const pushHistory = (request: object, response?: object) => {
|
||||||
setRequestHistory((prev) => [
|
setRequestHistory((prev) => [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function useCopy({ timeout = 500 }: UseCopyProps = {}) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [copied]);
|
}, [copied, timeout]);
|
||||||
|
|
||||||
return { copied, setCopied };
|
return { copied, setCopied };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,7 @@ describe("normalizeUnionType", () => {
|
||||||
expect(normalized.type).toBe("boolean");
|
expect(normalized.type).toBe("boolean");
|
||||||
expect(normalized.anyOf).toBeUndefined();
|
expect(normalized.anyOf).toBeUndefined();
|
||||||
expect(normalized.description).toBe("Optional boolean parameter");
|
expect(normalized.description).toBe("Optional boolean parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes array type with string and null to string type", () => {
|
test("normalizes array type with string and null to string type", () => {
|
||||||
|
|
@ -200,6 +201,7 @@ describe("normalizeUnionType", () => {
|
||||||
|
|
||||||
expect(normalized.type).toBe("string");
|
expect(normalized.type).toBe("string");
|
||||||
expect(normalized.description).toBe("Optional string parameter");
|
expect(normalized.description).toBe("Optional string parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes array type with boolean and null to boolean type", () => {
|
test("normalizes array type with boolean and null to boolean type", () => {
|
||||||
|
|
@ -212,6 +214,7 @@ describe("normalizeUnionType", () => {
|
||||||
|
|
||||||
expect(normalized.type).toBe("boolean");
|
expect(normalized.type).toBe("boolean");
|
||||||
expect(normalized.description).toBe("Optional boolean parameter");
|
expect(normalized.description).toBe("Optional boolean parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes anyOf with number and null to number type", () => {
|
test("normalizes anyOf with number and null to number type", () => {
|
||||||
|
|
@ -225,6 +228,7 @@ describe("normalizeUnionType", () => {
|
||||||
expect(normalized.type).toBe("number");
|
expect(normalized.type).toBe("number");
|
||||||
expect(normalized.anyOf).toBeUndefined();
|
expect(normalized.anyOf).toBeUndefined();
|
||||||
expect(normalized.description).toBe("Optional number parameter");
|
expect(normalized.description).toBe("Optional number parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes anyOf with integer and null to integer type", () => {
|
test("normalizes anyOf with integer and null to integer type", () => {
|
||||||
|
|
@ -238,6 +242,7 @@ describe("normalizeUnionType", () => {
|
||||||
expect(normalized.type).toBe("integer");
|
expect(normalized.type).toBe("integer");
|
||||||
expect(normalized.anyOf).toBeUndefined();
|
expect(normalized.anyOf).toBeUndefined();
|
||||||
expect(normalized.description).toBe("Optional integer parameter");
|
expect(normalized.description).toBe("Optional integer parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes array type with number and null to number type", () => {
|
test("normalizes array type with number and null to number type", () => {
|
||||||
|
|
@ -250,6 +255,7 @@ describe("normalizeUnionType", () => {
|
||||||
|
|
||||||
expect(normalized.type).toBe("number");
|
expect(normalized.type).toBe("number");
|
||||||
expect(normalized.description).toBe("Optional number parameter");
|
expect(normalized.description).toBe("Optional number parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes array type with integer and null to integer type", () => {
|
test("normalizes array type with integer and null to integer type", () => {
|
||||||
|
|
@ -262,6 +268,7 @@ describe("normalizeUnionType", () => {
|
||||||
|
|
||||||
expect(normalized.type).toBe("integer");
|
expect(normalized.type).toBe("integer");
|
||||||
expect(normalized.description).toBe("Optional integer parameter");
|
expect(normalized.description).toBe("Optional integer parameter");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles anyOf with reversed order (null first)", () => {
|
test("handles anyOf with reversed order (null first)", () => {
|
||||||
|
|
@ -273,6 +280,7 @@ describe("normalizeUnionType", () => {
|
||||||
|
|
||||||
expect(normalized.type).toBe("string");
|
expect(normalized.type).toBe("string");
|
||||||
expect(normalized.anyOf).toBeUndefined();
|
expect(normalized.anyOf).toBeUndefined();
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("leaves non-union schemas unchanged", () => {
|
test("leaves non-union schemas unchanged", () => {
|
||||||
|
|
@ -294,6 +302,7 @@ describe("normalizeUnionType", () => {
|
||||||
const normalized = normalizeUnionType(schema);
|
const normalized = normalizeUnionType(schema);
|
||||||
|
|
||||||
expect(normalized).toEqual(schema);
|
expect(normalized).toEqual(schema);
|
||||||
|
expect(normalized.nullable).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("leaves anyOf with more than two types unchanged", () => {
|
test("leaves anyOf with more than two types unchanged", () => {
|
||||||
|
|
@ -343,6 +352,7 @@ describe("normalizeUnionType", () => {
|
||||||
expect(normalized.minLength).toBe(1);
|
expect(normalized.minLength).toBe(1);
|
||||||
expect(normalized.maxLength).toBe(100);
|
expect(normalized.maxLength).toBe(100);
|
||||||
expect(normalized.pattern).toBe("^[a-z]+$");
|
expect(normalized.pattern).toBe("^[a-z]+$");
|
||||||
|
expect(normalized.nullable).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export type JsonSchemaType = {
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
nullable?: boolean;
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
enum?: string[];
|
enum?: string[];
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "string", anyOf: undefined };
|
return { ...schema, type: "string", anyOf: undefined, nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle anyOf with exactly boolean and null (FastMCP pattern)
|
// Handle anyOf with exactly boolean and null (FastMCP pattern)
|
||||||
|
|
@ -168,7 +168,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "boolean", anyOf: undefined };
|
return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle anyOf with exactly number and null (FastMCP pattern)
|
// Handle anyOf with exactly number and null (FastMCP pattern)
|
||||||
|
|
@ -178,7 +178,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "number", anyOf: undefined };
|
return { ...schema, type: "number", anyOf: undefined, nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle anyOf with exactly integer and null (FastMCP pattern)
|
// Handle anyOf with exactly integer and null (FastMCP pattern)
|
||||||
|
|
@ -188,7 +188,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
|
||||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "integer", anyOf: undefined };
|
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle array type with exactly string and null
|
// Handle array type with exactly string and null
|
||||||
|
|
@ -198,7 +198,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.type.includes("string") &&
|
schema.type.includes("string") &&
|
||||||
schema.type.includes("null")
|
schema.type.includes("null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "string" };
|
return { ...schema, type: "string", nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle array type with exactly boolean and null
|
// Handle array type with exactly boolean and null
|
||||||
|
|
@ -208,7 +208,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.type.includes("boolean") &&
|
schema.type.includes("boolean") &&
|
||||||
schema.type.includes("null")
|
schema.type.includes("null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "boolean" };
|
return { ...schema, type: "boolean", nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle array type with exactly number and null
|
// Handle array type with exactly number and null
|
||||||
|
|
@ -218,7 +218,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.type.includes("number") &&
|
schema.type.includes("number") &&
|
||||||
schema.type.includes("null")
|
schema.type.includes("null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "number" };
|
return { ...schema, type: "number", nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle array type with exactly integer and null
|
// Handle array type with exactly integer and null
|
||||||
|
|
@ -228,7 +228,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||||
schema.type.includes("integer") &&
|
schema.type.includes("integer") &&
|
||||||
schema.type.includes("null")
|
schema.type.includes("null")
|
||||||
) {
|
) {
|
||||||
return { ...schema, type: "integer" };
|
return { ...schema, type: "integer", nullable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return schema;
|
return schema;
|
||||||
|
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -11462,9 +11462,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue