Merge remote-tracking branch 'origin2/main' into meta_properies_in_tools
This commit is contained in:
commit
caa1a2e1f5
30 changed files with 1559 additions and 776 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
|
||||
34
CLAUDE.md
34
CLAUDE.md
|
|
@ -1,33 +1 @@
|
|||
# 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
|
||||
@./AGENTS.md
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -236,13 +236,15 @@ ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start
|
|||
|
||||
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
|
||||
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true |
|
||||
| Setting | Description | Default |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `MCP_SERVER_REQUEST_TIMEOUT` | Client-side timeout (ms) - Inspector will cancel the request if no response is received within this time. Note: servers may have their own timeouts | 300000 |
|
||||
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true |
|
||||
|
||||
**Note on Timeouts:** The timeout settings above control when the Inspector (as an MCP client) will cancel requests. These are independent of any server-side timeouts. For example, if a server tool has a 10-minute timeout but the Inspector's timeout is set to 30 seconds, the Inspector will cancel the request after 30 seconds. Conversely, if the Inspector's timeout is 10 minutes but the server times out after 30 seconds, you'll receive the server's timeout error. For tools that require user interaction (like elicitation) or long-running operations, ensure the Inspector's timeout is set appropriately.
|
||||
|
||||
These settings can be adjusted in real-time through the UI and will persist across sessions.
|
||||
|
||||
|
|
@ -361,7 +363,7 @@ http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
|
|||
You can also set initial config settings via query params, for example:
|
||||
|
||||
```
|
||||
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
|
||||
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=60000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
|
||||
```
|
||||
|
||||
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
|
||||
|
|
@ -456,6 +458,17 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --me
|
|||
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
|
||||
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
|
||||
|
||||
## Tool Input Validation Guidelines
|
||||
|
||||
When implementing or modifying tool input parameter handling in the Inspector:
|
||||
|
||||
- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value
|
||||
- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects
|
||||
- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages
|
||||
- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema
|
||||
|
||||
These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.2",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -31,8 +32,6 @@ type JsonValue =
|
|||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
type Args = {
|
||||
target: string[];
|
||||
method?: string;
|
||||
|
|
@ -103,6 +102,15 @@ function createTransportOptions(
|
|||
}
|
||||
|
||||
async function callMethod(args: Args): Promise<void> {
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.2",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/serve-handler": "^6.1.4",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"co": "^4.6.0",
|
||||
"eslint": "^9.11.1",
|
||||
|
|
@ -77,6 +77,6 @@
|
|||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^7.1.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,10 @@ const App = () => {
|
|||
return localStorage.getItem("lastOauthScope") || "";
|
||||
});
|
||||
|
||||
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
|
||||
return localStorage.getItem("lastOauthClientSecret") || "";
|
||||
});
|
||||
|
||||
// Custom headers state with migration from legacy auth
|
||||
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
||||
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
||||
|
|
@ -282,6 +286,7 @@ const App = () => {
|
|||
env,
|
||||
customHeaders,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthScope,
|
||||
config,
|
||||
connectionType,
|
||||
|
|
@ -415,6 +420,10 @@ const App = () => {
|
|||
localStorage.setItem("lastOauthScope", oauthScope);
|
||||
}, [oauthScope]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
|
||||
}, [oauthClientSecret]);
|
||||
|
||||
useEffect(() => {
|
||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||
}, [config]);
|
||||
|
|
@ -931,6 +940,8 @@ const App = () => {
|
|||
setCustomHeaders={setCustomHeaders}
|
||||
oauthClientId={oauthClientId}
|
||||
setOauthClientId={setOauthClientId}
|
||||
oauthClientSecret={oauthClientSecret}
|
||||
setOauthClientSecret={setOauthClientSecret}
|
||||
oauthScope={oauthScope}
|
||||
setOauthScope={setOauthScope}
|
||||
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 clsx from "clsx";
|
||||
import { Copy, CheckCheck } from "lucide-react";
|
||||
|
|
@ -51,7 +52,7 @@ const JsonView = memo(
|
|||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [toast, normalizedData]);
|
||||
}, [toast, normalizedData, setCopied]);
|
||||
|
||||
return (
|
||||
<div className={clsx("p-4 border rounded relative", className)}>
|
||||
|
|
@ -101,6 +102,7 @@ const JsonNode = memo(
|
|||
initialExpandDepth,
|
||||
isError = false,
|
||||
}: JsonNodeProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||
const [typeStyleMap] = useState<Record<string, string>>({
|
||||
number: "text-blue-600",
|
||||
|
|
@ -113,6 +115,52 @@ const JsonNode = memo(
|
|||
});
|
||||
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 items = isArray
|
||||
? (data as JsonValue[])
|
||||
|
|
@ -206,7 +254,7 @@ const JsonNode = memo(
|
|||
|
||||
if (!isTooLong) {
|
||||
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 && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||
{name}:
|
||||
|
|
@ -220,12 +268,28 @@ const JsonNode = memo(
|
|||
>
|
||||
"{value}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{name}:
|
||||
|
|
@ -241,6 +305,22 @@ const JsonNode = memo(
|
|||
>
|
||||
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -253,7 +333,7 @@ const JsonNode = memo(
|
|||
return renderString(data as string);
|
||||
default:
|
||||
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 && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||
{name}:
|
||||
|
|
@ -262,6 +342,22 @@ const JsonNode = memo(
|
|||
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
||||
{data === null ? "null" : String(data)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({
|
|||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Resource metadata was added in the{" "}
|
||||
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
|
||||
2025-DRAFT-v2 specification update
|
||||
<a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location">
|
||||
2025-06-18 specification update
|
||||
</a>
|
||||
<br />
|
||||
{authState.resourceMetadataError.message}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -123,7 +131,9 @@ const PromptsTab = ({
|
|||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription className="break-all">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedPrompt ? (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -143,6 +153,7 @@ const PromptsTab = ({
|
|||
onInputChange={(value) =>
|
||||
handleInputChange(arg.name, value)
|
||||
}
|
||||
onFocus={() => handleFocus(arg.name)}
|
||||
options={completions[arg.name] || []}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,9 @@ const ResourcesTab = ({
|
|||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription className="break-all">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedResource ? (
|
||||
<JsonView
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const SamplingRequest = ({
|
|||
},
|
||||
role: {
|
||||
type: "string",
|
||||
default: "endTurn",
|
||||
default: "assistant",
|
||||
description: "Role of the model",
|
||||
},
|
||||
content: {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ interface SidebarProps {
|
|||
setCustomHeaders: (headers: CustomHeadersType) => void;
|
||||
oauthClientId: string;
|
||||
setOauthClientId: (id: string) => void;
|
||||
oauthClientSecret: string;
|
||||
setOauthClientSecret: (secret: string) => void;
|
||||
oauthScope: string;
|
||||
setOauthScope: (scope: string) => void;
|
||||
onConnect: () => void;
|
||||
|
|
@ -87,6 +89,8 @@ const Sidebar = ({
|
|||
setCustomHeaders,
|
||||
oauthClientId,
|
||||
setOauthClientId,
|
||||
oauthClientSecret,
|
||||
setOauthClientSecret,
|
||||
oauthScope,
|
||||
setOauthScope,
|
||||
onConnect,
|
||||
|
|
@ -104,6 +108,7 @@ const Sidebar = ({
|
|||
const [showAuthConfig, setShowAuthConfig] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||
const [showClientSecret, setShowClientSecret] = useState(false);
|
||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
|
@ -555,6 +560,38 @@ const Sidebar = ({
|
|||
data-testid="oauth-client-id-input"
|
||||
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">
|
||||
Redirect URL
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import {
|
||||
|
|
@ -121,7 +128,7 @@ const ToolsTab = ({
|
|||
renderItem={(tool) => (
|
||||
<div className="flex flex-col items-start">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -144,10 +151,12 @@ const ToolsTab = ({
|
|||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription className="break-all">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</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}
|
||||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
|
|
@ -158,148 +167,225 @@ const ToolsTab = ({
|
|||
const required = isPropertyRequired(key, inputSchema);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{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" ? (
|
||||
<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)) {
|
||||
<div className="flex justify-between">
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.nullable ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
name={key}
|
||||
checked={params[key] === null}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: num,
|
||||
[key]: checked
|
||||
? null
|
||||
: prop.default !== null
|
||||
? prop.default
|
||||
: prop.type === "boolean"
|
||||
? false
|
||||
: prop.type === "string"
|
||||
? ""
|
||||
: prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? undefined
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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 {
|
||||
// Store invalid input as string - let server validate
|
||||
setParams({
|
||||
...params,
|
||||
[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])
|
||||
}
|
||||
}}
|
||||
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);
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ describe("Sidebar", () => {
|
|||
setSseUrl: jest.fn(),
|
||||
oauthClientId: "",
|
||||
setOauthClientId: jest.fn(),
|
||||
oauthClientSecret: "",
|
||||
setOauthClientSecret: jest.fn(),
|
||||
oauthScope: "",
|
||||
setOauthScope: jest.fn(),
|
||||
env: {},
|
||||
|
|
@ -911,7 +913,8 @@ describe("Sidebar", () => {
|
|||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
description:
|
||||
"Client-side timeout (ms) - Inspector will cancel requests after this time",
|
||||
value: 5000,
|
||||
is_session_item: false,
|
||||
},
|
||||
|
|
@ -986,7 +989,8 @@ describe("Sidebar", () => {
|
|||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
description:
|
||||
"Client-side timeout (ms) - Inspector will cancel requests after this time",
|
||||
value: 0,
|
||||
is_session_item: false,
|
||||
},
|
||||
|
|
@ -1033,7 +1037,8 @@ describe("Sidebar", () => {
|
|||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
description:
|
||||
"Client-side timeout (ms) - Inspector will cancel requests after this time",
|
||||
value: 3000,
|
||||
is_session_item: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -143,6 +153,135 @@ 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 support tri-state nullable boolean (null -> false -> true -> null)", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
const toolWithNullableBoolean: Tool = {
|
||||
name: "testTool",
|
||||
description: "Tool with nullable boolean",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
optionalBoolean: {
|
||||
type: ["boolean", "null"] as const,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithNullableBoolean],
|
||||
selectedTool: toolWithNullableBoolean,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const nullCheckbox = screen.getByRole("checkbox", { name: /null/i });
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
|
||||
// State 1: Initial state should be null (input disabled)
|
||||
const wrapper = screen.getByRole("toolinputwrapper");
|
||||
expect(wrapper.classList).toContain("pointer-events-none");
|
||||
expect(wrapper.classList).toContain("opacity-50");
|
||||
|
||||
// Verify tool is called with null initially
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
|
||||
optionalBoolean: null,
|
||||
});
|
||||
|
||||
// State 2: Uncheck null checkbox -> should set value to false and enable input
|
||||
await act(async () => {
|
||||
fireEvent.click(nullCheckbox);
|
||||
});
|
||||
expect(wrapper.classList).not.toContain("pointer-events-none");
|
||||
|
||||
// Clear previous calls to make assertions clearer
|
||||
mockCallTool.mockClear();
|
||||
|
||||
// Verify tool can be called with false
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenLastCalledWith(
|
||||
toolWithNullableBoolean.name,
|
||||
{
|
||||
optionalBoolean: false,
|
||||
},
|
||||
);
|
||||
|
||||
// State 3: Check boolean checkbox -> should set value to true
|
||||
// Find the boolean checkbox within the input wrapper (to avoid ID conflict with null checkbox)
|
||||
const booleanCheckbox = within(wrapper).getByRole("checkbox");
|
||||
|
||||
mockCallTool.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(booleanCheckbox);
|
||||
});
|
||||
|
||||
// Verify tool can be called with true
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenLastCalledWith(
|
||||
toolWithNullableBoolean.name,
|
||||
{
|
||||
optionalBoolean: true,
|
||||
},
|
||||
);
|
||||
|
||||
// State 4: Check null checkbox again -> should set value back to null and disable input
|
||||
await act(async () => {
|
||||
fireEvent.click(nullCheckbox);
|
||||
});
|
||||
expect(wrapper.classList).toContain("pointer-events-none");
|
||||
|
||||
// Verify tool can be called with null again
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
expect(mockCallTool).toHaveBeenLastCalledWith(
|
||||
toolWithNullableBoolean.name,
|
||||
{
|
||||
optionalBoolean: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable button and change text while tool is running", async () => {
|
||||
// Create a promise that we can resolve later
|
||||
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||
|
|
@ -684,6 +823,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", () => {
|
||||
const toolWithJsonParams: Tool = {
|
||||
name: "jsonTool",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -121,10 +121,16 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
return window.location.origin + "/oauth/callback/debug";
|
||||
}
|
||||
|
||||
get redirect_uris() {
|
||||
// Normally register both redirect URIs to support both normal and debug flows
|
||||
// In debug subclass, redirectUrl may be the same as debugRedirectUrl, so remove duplicates
|
||||
// See: https://github.com/modelcontextprotocol/inspector/issues/825
|
||||
return [...new Set([this.redirectUrl, this.debugRedirectUrl])];
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
// Register both redirect URIs to support both normal and debug flows
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl, this.debugRedirectUrl],
|
||||
redirect_uris: this.redirect_uris,
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export type ConfigItem = {
|
|||
*/
|
||||
export type InspectorConfig = {
|
||||
/**
|
||||
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
||||
* Client-side timeout in milliseconds. The Inspector will cancel the request if no response
|
||||
* is received within this time. Note: This is independent of any server-side timeouts.
|
||||
*/
|
||||
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
|||
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 10000,
|
||||
description:
|
||||
"Client-side timeout (ms) - Inspector will cancel requests after this time",
|
||||
value: 300000, // 5 minutes - increased to support elicitation and other long-running tools
|
||||
is_session_item: false,
|
||||
},
|
||||
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue