Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-73ea615029
This commit is contained in:
commit
95f16e3a3a
37 changed files with 3451 additions and 914 deletions
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: ''
|
labels: ""
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Inspector Version**
|
**Inspector Version**
|
||||||
|
|
||||||
- [e.g. 0.16.5)
|
- [e.g. 0.16.5)
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
|
|
@ -15,6 +15,7 @@ A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
|
|
@ -27,8 +28,9 @@ A clear and concise description of what you expected to happen.
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
|
||||||
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
|
@./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
|
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Streamable HTTP transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:3000/mcp",
|
||||||
|
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
||||||
|
|
||||||
**STDIO transport example:**
|
**STDIO transport example:**
|
||||||
|
|
@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Streamable HTTP transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"default-server": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:3000/mcp",
|
||||||
|
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
||||||
|
|
||||||
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
|
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients.
|
||||||
|
|
||||||
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.6",
|
"version": "0.17.0",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"spawn-rx": "^5.1.2"
|
"spawn-rx": "^5.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import {
|
import {
|
||||||
|
|
@ -99,16 +100,27 @@ function createTransportOptions(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callMethod(args: Args): Promise<void> {
|
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(
|
const transportOptions = createTransportOptions(
|
||||||
args.target,
|
args.target,
|
||||||
args.transport,
|
args.transport,
|
||||||
args.headers,
|
args.headers,
|
||||||
);
|
);
|
||||||
const transport = createTransport(transportOptions);
|
const transport = createTransport(transportOptions);
|
||||||
const client = new Client({
|
|
||||||
name: "inspector-cli",
|
const [, name = packageJson.name] = packageJson.name.split("/");
|
||||||
version: "0.5.1",
|
const version = packageJson.version;
|
||||||
});
|
const clientIdentity = { name, version };
|
||||||
|
|
||||||
|
const client = new Client(clientIdentity);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connect(client, transport);
|
await connect(client, transport);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.6",
|
"version": "0.17.0",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"cleanup:e2e": "node e2e/global-teardown.js"
|
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"@radix-ui/react-popover": "^1.1.3",
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -69,6 +70,7 @@
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||||
|
import { cleanParams } from "./utils/paramUtils";
|
||||||
|
import type { JsonSchemaType } from "./utils/jsonUtils";
|
||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -74,6 +76,10 @@ import ElicitationTab, {
|
||||||
PendingElicitationRequest,
|
PendingElicitationRequest,
|
||||||
ElicitationResponse,
|
ElicitationResponse,
|
||||||
} from "./components/ElicitationTab";
|
} from "./components/ElicitationTab";
|
||||||
|
import {
|
||||||
|
CustomHeaders,
|
||||||
|
migrateFromLegacyAuth,
|
||||||
|
} from "./lib/types/customHeaders";
|
||||||
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
|
|
@ -103,6 +109,14 @@ const App = () => {
|
||||||
const [transportType, setTransportType] = useState<
|
const [transportType, setTransportType] = useState<
|
||||||
"stdio" | "sse" | "streamable-http"
|
"stdio" | "sse" | "streamable-http"
|
||||||
>(getInitialTransportType);
|
>(getInitialTransportType);
|
||||||
|
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
|
||||||
|
() => {
|
||||||
|
return (
|
||||||
|
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
|
||||||
|
"proxy"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
|
|
@ -127,6 +141,43 @@ 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
|
||||||
|
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
||||||
|
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
||||||
|
if (savedHeaders) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(savedHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Fall back to migration if JSON parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from legacy auth if available
|
||||||
|
const legacyToken = localStorage.getItem("lastBearerToken") || "";
|
||||||
|
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
|
||||||
|
|
||||||
|
if (legacyToken) {
|
||||||
|
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to empty array
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer ",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
|
|
@ -213,11 +264,12 @@ const App = () => {
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
headerName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
|
oauthClientSecret,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
|
connectionType,
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
|
|
@ -303,13 +355,42 @@ const App = () => {
|
||||||
}, [transportType]);
|
}, [transportType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
localStorage.setItem("lastConnectionType", connectionType);
|
||||||
|
}, [connectionType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bearerToken) {
|
||||||
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastBearerToken");
|
||||||
|
}
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastHeaderName", headerName);
|
if (headerName) {
|
||||||
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastHeaderName");
|
||||||
|
}
|
||||||
}, [headerName]);
|
}, [headerName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
|
||||||
|
}, [customHeaders]);
|
||||||
|
|
||||||
|
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (customHeaders.length === 0 && (bearerToken || headerName)) {
|
||||||
|
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
|
||||||
|
if (migratedHeaders.length > 0) {
|
||||||
|
setCustomHeaders(migratedHeaders);
|
||||||
|
// Clear legacy auth after migration
|
||||||
|
setBearerToken("");
|
||||||
|
setHeaderName("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastOauthClientId", oauthClientId);
|
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||||
}, [oauthClientId]);
|
}, [oauthClientId]);
|
||||||
|
|
@ -318,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]);
|
||||||
|
|
@ -703,12 +788,18 @@ const App = () => {
|
||||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Find the tool schema to clean parameters properly
|
||||||
|
const tool = tools.find((t) => t.name === name);
|
||||||
|
const cleanedParams = tool?.inputSchema
|
||||||
|
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
||||||
|
: params;
|
||||||
|
|
||||||
const response = await sendMCPRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/call" as const,
|
method: "tools/call" as const,
|
||||||
params: {
|
params: {
|
||||||
name,
|
name,
|
||||||
arguments: params,
|
arguments: cleanedParams,
|
||||||
_meta: {
|
_meta: {
|
||||||
progressToken: progressTokenRef.current++,
|
progressToken: progressTokenRef.current++,
|
||||||
},
|
},
|
||||||
|
|
@ -719,6 +810,8 @@ const App = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
setToolResult(response);
|
setToolResult(response);
|
||||||
|
// Clear any validation errors since tool execution completed
|
||||||
|
setErrors((prev) => ({ ...prev, tools: null }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const toolResult: CompatibilityCallToolResult = {
|
const toolResult: CompatibilityCallToolResult = {
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -730,6 +823,8 @@ const App = () => {
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
setToolResult(toolResult);
|
setToolResult(toolResult);
|
||||||
|
// Clear validation errors - tool execution errors are shown in ToolResults
|
||||||
|
setErrors((prev) => ({ ...prev, tools: null }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -810,12 +905,12 @@ const App = () => {
|
||||||
setEnv={setEnv}
|
setEnv={setEnv}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
customHeaders={customHeaders}
|
||||||
setBearerToken={setBearerToken}
|
setCustomHeaders={setCustomHeaders}
|
||||||
headerName={headerName}
|
|
||||||
setHeaderName={setHeaderName}
|
|
||||||
oauthClientId={oauthClientId}
|
oauthClientId={oauthClientId}
|
||||||
setOauthClientId={setOauthClientId}
|
setOauthClientId={setOauthClientId}
|
||||||
|
oauthClientSecret={oauthClientSecret}
|
||||||
|
setOauthClientSecret={setOauthClientSecret}
|
||||||
oauthScope={oauthScope}
|
oauthScope={oauthScope}
|
||||||
setOauthScope={setOauthScope}
|
setOauthScope={setOauthScope}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
|
|
@ -823,6 +918,8 @@ const App = () => {
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
|
connectionType={connectionType}
|
||||||
|
setConnectionType={setConnectionType}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
onMouseDown={handleSidebarDragStart}
|
||||||
|
|
|
||||||
241
client/src/components/CustomHeaders.tsx
Normal file
241
client/src/components/CustomHeaders.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
|
||||||
|
import {
|
||||||
|
CustomHeaders as CustomHeadersType,
|
||||||
|
CustomHeader,
|
||||||
|
createEmptyHeader,
|
||||||
|
} from "@/lib/types/customHeaders";
|
||||||
|
|
||||||
|
interface CustomHeadersProps {
|
||||||
|
headers: CustomHeadersType;
|
||||||
|
onChange: (headers: CustomHeadersType) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomHeaders = ({
|
||||||
|
headers,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: CustomHeadersProps) => {
|
||||||
|
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||||
|
const [jsonValue, setJsonValue] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const updateHeader = (
|
||||||
|
index: number,
|
||||||
|
field: keyof CustomHeader,
|
||||||
|
value: string | boolean,
|
||||||
|
) => {
|
||||||
|
const newHeaders = [...headers];
|
||||||
|
newHeaders[index] = { ...newHeaders[index], [field]: value };
|
||||||
|
onChange(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
onChange([...headers, createEmptyHeader()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHeader = (index: number) => {
|
||||||
|
const newHeaders = headers.filter((_, i) => i !== index);
|
||||||
|
onChange(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleValueVisibility = (index: number) => {
|
||||||
|
const newVisible = new Set(visibleValues);
|
||||||
|
if (newVisible.has(index)) {
|
||||||
|
newVisible.delete(index);
|
||||||
|
} else {
|
||||||
|
newVisible.add(index);
|
||||||
|
}
|
||||||
|
setVisibleValues(newVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToJsonMode = () => {
|
||||||
|
const jsonObject: Record<string, string> = {};
|
||||||
|
headers.forEach((header) => {
|
||||||
|
if (header.enabled && header.name.trim() && header.value.trim()) {
|
||||||
|
jsonObject[header.name.trim()] = header.value.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setJsonValue(JSON.stringify(jsonObject, null, 2));
|
||||||
|
setJsonError(null);
|
||||||
|
setIsJsonMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToFormMode = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonValue);
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
setJsonError("JSON must be an object with string key-value pairs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHeaders: CustomHeadersType = Object.entries(parsed).map(
|
||||||
|
([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: String(value),
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(newHeaders);
|
||||||
|
setJsonError(null);
|
||||||
|
setIsJsonMode(false);
|
||||||
|
} catch {
|
||||||
|
setJsonError("Invalid JSON format");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsonChange = (value: string) => {
|
||||||
|
setJsonValue(value);
|
||||||
|
setJsonError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isJsonMode) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold flex-shrink-0">
|
||||||
|
Custom Headers (JSON)
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={switchToFormMode}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Switch to Form
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value)}
|
||||||
|
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
|
||||||
|
className="font-mono text-sm min-h-[100px] resize-none"
|
||||||
|
/>
|
||||||
|
{jsonError && <p className="text-sm text-red-600">{jsonError}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter headers as a JSON object with string key-value pairs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={switchToJsonMode}
|
||||||
|
className="text-xs px-2"
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addHeader}
|
||||||
|
className="text-xs px-2"
|
||||||
|
data-testid="add-header-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headers.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<p className="text-sm">No custom headers configured</p>
|
||||||
|
<p className="text-xs mt-1">Click "Add" to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{headers.map((header, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 p-2 border rounded-md"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={header.enabled}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
updateHeader(index, "enabled", enabled)
|
||||||
|
}
|
||||||
|
className="shrink-0 mt-2"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Header Name"
|
||||||
|
value={header.name}
|
||||||
|
onChange={(e) => updateHeader(index, "name", e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
data-testid={`header-name-input-${index}`}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
placeholder="Header Value"
|
||||||
|
value={header.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateHeader(index, "value", e.target.value)
|
||||||
|
}
|
||||||
|
type={visibleValues.has(index) ? "text" : "password"}
|
||||||
|
className="font-mono text-xs pr-8"
|
||||||
|
data-testid={`header-value-input-${index}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleValueVisibility(index)}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{visibleValues.has(index) ? (
|
||||||
|
<EyeOff className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeHeader(index)}
|
||||||
|
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{headers.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use the toggle to enable/disable headers. Only enabled headers with
|
||||||
|
both name and value will be sent.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomHeaders;
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
import { useState, memo, useMemo, useCallback } 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";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
||||||
|
import useCopy from "@/lib/hooks/useCopy";
|
||||||
|
|
||||||
interface JsonViewProps {
|
interface JsonViewProps {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
|
@ -25,21 +26,7 @@ const JsonView = memo(
|
||||||
isError = false,
|
isError = false,
|
||||||
}: JsonViewProps) => {
|
}: JsonViewProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, setCopied } = useCopy();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
|
||||||
if (copied) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [copied]);
|
|
||||||
|
|
||||||
const normalizedData = useMemo(() => {
|
const normalizedData = useMemo(() => {
|
||||||
return typeof data === "string"
|
return typeof data === "string"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { Search } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { useState, useMemo, useRef } from "react";
|
||||||
|
|
||||||
type ListPaneProps<T> = {
|
type ListPaneProps<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|
@ -20,41 +23,111 @@ const ListPane = <T extends object>({
|
||||||
title,
|
title,
|
||||||
buttonText,
|
buttonText,
|
||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => {
|
||||||
<div className="bg-card border border-border rounded-lg shadow">
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
const filteredItems = useMemo(() => {
|
||||||
<Button
|
if (!searchQuery.trim()) return items;
|
||||||
variant="outline"
|
|
||||||
className="w-full mb-4"
|
return items.filter((item) => {
|
||||||
onClick={listItems}
|
const searchableText = [
|
||||||
disabled={isButtonDisabled}
|
(item as { name?: string }).name || "",
|
||||||
>
|
(item as { description?: string }).description || "",
|
||||||
{buttonText}
|
]
|
||||||
</Button>
|
.join(" ")
|
||||||
<Button
|
.toLowerCase();
|
||||||
variant="outline"
|
return searchableText.includes(searchQuery.toLowerCase());
|
||||||
className="w-full mb-4"
|
});
|
||||||
onClick={clearItems}
|
}, [items, searchQuery]);
|
||||||
disabled={items.length === 0}
|
|
||||||
>
|
const handleSearchClick = () => {
|
||||||
Clear
|
setIsSearchExpanded(true);
|
||||||
</Button>
|
setTimeout(() => {
|
||||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
searchInputRef.current?.focus();
|
||||||
{items.map((item, index) => (
|
}, 100);
|
||||||
<div
|
};
|
||||||
key={index}
|
|
||||||
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
const handleSearchBlur = () => {
|
||||||
onClick={() => setSelectedItem(item)}
|
if (!searchQuery.trim()) {
|
||||||
>
|
setIsSearchExpanded(false);
|
||||||
{renderItem(item)}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h3 className="font-semibold dark:text-white flex-shrink-0">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-end min-w-0 flex-1">
|
||||||
|
{!isSearchExpanded ? (
|
||||||
|
<button
|
||||||
|
name="search"
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={handleSearchClick}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center w-full max-w-xs">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none z-10" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
name="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onBlur={handleSearchBlur}
|
||||||
|
className="pl-10 w-full transition-all duration-300 ease-in-out"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={listItems}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={clearItems}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
||||||
|
onClick={() => setSelectedItem(item)}
|
||||||
|
>
|
||||||
|
{renderItem(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
No items found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default ListPane;
|
export default ListPane;
|
||||||
|
|
|
||||||
|
|
@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<p className="text-xs text-blue-600 mt-1">
|
||||||
Resource metadata was added in the{" "}
|
Resource metadata was added in the{" "}
|
||||||
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
|
<a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location">
|
||||||
2025-DRAFT-v2 specification update
|
2025-06-18 specification update
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
{authState.resourceMetadataError.message}
|
{authState.resourceMetadataError.message}
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,7 @@ const PromptsTab = ({
|
||||||
clearCompletions();
|
clearCompletions();
|
||||||
}, [clearCompletions, selectedPrompt]);
|
}, [clearCompletions, selectedPrompt]);
|
||||||
|
|
||||||
const handleInputChange = async (argName: string, value: string) => {
|
const triggerCompletions = (argName: string, value: string) => {
|
||||||
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
|
|
||||||
|
|
||||||
if (selectedPrompt) {
|
if (selectedPrompt) {
|
||||||
requestCompletions(
|
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 = () => {
|
const handleGetPrompt = () => {
|
||||||
if (selectedPrompt) {
|
if (selectedPrompt) {
|
||||||
getPrompt(selectedPrompt.name, promptArgs);
|
getPrompt(selectedPrompt.name, promptArgs);
|
||||||
|
|
@ -123,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">
|
||||||
|
|
@ -143,6 +153,7 @@ const PromptsTab = ({
|
||||||
onInputChange={(value) =>
|
onInputChange={(value) =>
|
||||||
handleInputChange(arg.name, value)
|
handleInputChange(arg.name, value)
|
||||||
}
|
}
|
||||||
|
onFocus={() => handleFocus(arg.name)}
|
||||||
options={completions[arg.name] || []}
|
options={completions[arg.name] || []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import CustomHeaders from "./CustomHeaders";
|
||||||
|
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
|
||||||
import { useToast } from "../lib/hooks/useToast";
|
import { useToast } from "../lib/hooks/useToast";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|
@ -51,12 +53,13 @@ interface SidebarProps {
|
||||||
setSseUrl: (url: string) => void;
|
setSseUrl: (url: string) => void;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
setEnv: (env: Record<string, string>) => void;
|
setEnv: (env: Record<string, string>) => void;
|
||||||
bearerToken: string;
|
// Custom headers support
|
||||||
setBearerToken: (token: string) => void;
|
customHeaders: CustomHeadersType;
|
||||||
headerName?: string;
|
setCustomHeaders: (headers: CustomHeadersType) => void;
|
||||||
setHeaderName?: (name: string) => 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;
|
||||||
|
|
@ -66,6 +69,8 @@ interface SidebarProps {
|
||||||
loggingSupported: boolean;
|
loggingSupported: boolean;
|
||||||
config: InspectorConfig;
|
config: InspectorConfig;
|
||||||
setConfig: (config: InspectorConfig) => void;
|
setConfig: (config: InspectorConfig) => void;
|
||||||
|
connectionType: "direct" | "proxy";
|
||||||
|
setConnectionType: (type: "direct" | "proxy") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar = ({
|
const Sidebar = ({
|
||||||
|
|
@ -80,12 +85,12 @@ const Sidebar = ({
|
||||||
setSseUrl,
|
setSseUrl,
|
||||||
env,
|
env,
|
||||||
setEnv,
|
setEnv,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
setBearerToken,
|
setCustomHeaders,
|
||||||
headerName,
|
|
||||||
setHeaderName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
setOauthClientId,
|
setOauthClientId,
|
||||||
|
oauthClientSecret,
|
||||||
|
setOauthClientSecret,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
setOauthScope,
|
setOauthScope,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
|
@ -95,16 +100,21 @@ const Sidebar = ({
|
||||||
loggingSupported,
|
loggingSupported,
|
||||||
config,
|
config,
|
||||||
setConfig,
|
setConfig,
|
||||||
|
connectionType,
|
||||||
|
setConnectionType,
|
||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
const [theme, setTheme] = useTheme();
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
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();
|
||||||
|
|
||||||
|
const connectionTypeTip =
|
||||||
|
"Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy";
|
||||||
// Reusable error reporter for copy actions
|
// Reusable error reporter for copy actions
|
||||||
const reportError = useCallback(
|
const reportError = useCallback(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
|
|
@ -175,7 +185,9 @@ const Sidebar = ({
|
||||||
description:
|
description:
|
||||||
transportType === "stdio"
|
transportType === "stdio"
|
||||||
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
||||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
: transportType === "streamable-http"
|
||||||
|
? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client."
|
||||||
|
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -312,6 +324,35 @@ const Sidebar = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Type switch - only visible for non-STDIO transport types */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="connection-type-select"
|
||||||
|
>
|
||||||
|
Connection Type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={connectionType}
|
||||||
|
onValueChange={(value: "direct" | "proxy") =>
|
||||||
|
setConnectionType(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="connection-type-select">
|
||||||
|
<SelectValue placeholder="Select connection type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="proxy">Via Proxy</SelectItem>
|
||||||
|
<SelectItem value="direct">Direct</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{connectionTypeTip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -497,38 +538,12 @@ const Sidebar = ({
|
||||||
</Button>
|
</Button>
|
||||||
{showAuthConfig && (
|
{showAuthConfig && (
|
||||||
<>
|
<>
|
||||||
{/* Bearer Token Section */}
|
{/* Custom Headers Section */}
|
||||||
<div className="space-y-2 p-3 rounded border">
|
<div className="p-3 rounded border overflow-hidden">
|
||||||
<h4 className="text-sm font-semibold flex items-center">
|
<CustomHeaders
|
||||||
API Token Authentication
|
headers={customHeaders}
|
||||||
</h4>
|
onChange={setCustomHeaders}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<label className="text-sm font-medium">Header Name</label>
|
|
||||||
<Input
|
|
||||||
placeholder="Authorization"
|
|
||||||
onChange={(e) =>
|
|
||||||
setHeaderName && setHeaderName(e.target.value)
|
|
||||||
}
|
|
||||||
data-testid="header-input"
|
|
||||||
className="font-mono"
|
|
||||||
value={headerName}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
className="text-sm font-medium"
|
|
||||||
htmlFor="bearer-token-input"
|
|
||||||
>
|
|
||||||
Bearer Token
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="bearer-token-input"
|
|
||||||
placeholder="Bearer Token"
|
|
||||||
value={bearerToken}
|
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
|
||||||
data-testid="bearer-token-input"
|
|
||||||
className="font-mono"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{transportType !== "stdio" && (
|
{transportType !== "stdio" && (
|
||||||
// OAuth Configuration
|
// OAuth Configuration
|
||||||
|
|
@ -545,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>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm from "./DynamicJsonForm";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import {
|
import {
|
||||||
generateDefaultValue,
|
generateDefaultValue,
|
||||||
|
|
@ -17,11 +24,21 @@ import {
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Loader2,
|
||||||
|
Send,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
|
CheckCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
import ToolResults from "./ToolResults";
|
import ToolResults from "./ToolResults";
|
||||||
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
import useCopy from "@/lib/hooks/useCopy";
|
||||||
|
|
||||||
// Type guard to safely detect the optional _meta field without using `any`
|
// Type guard to safely detect the optional _meta field without using `any`
|
||||||
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
|
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
|
||||||
|
|
@ -36,6 +53,7 @@ const ToolsTab = ({
|
||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
toolResult,
|
toolResult,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
|
error,
|
||||||
resourceContent,
|
resourceContent,
|
||||||
onReadResource,
|
onReadResource,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -55,6 +73,19 @@ const ToolsTab = ({
|
||||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||||
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
|
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
|
||||||
|
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||||
|
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { copied, setCopied } = useCopy();
|
||||||
|
|
||||||
|
// Function to check if any form has validation errors
|
||||||
|
const checkValidationErrors = () => {
|
||||||
|
const errors = Object.values(formRefs.current).some(
|
||||||
|
(ref) => ref && !ref.validateJson().isValid,
|
||||||
|
);
|
||||||
|
setHasValidationErrors(errors);
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = Object.entries(
|
const params = Object.entries(
|
||||||
|
|
@ -68,6 +99,12 @@ const ToolsTab = ({
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
setParams(Object.fromEntries(params));
|
setParams(Object.fromEntries(params));
|
||||||
|
|
||||||
|
// Reset validation errors when switching tools
|
||||||
|
setHasValidationErrors(false);
|
||||||
|
|
||||||
|
// Clear form refs for the previous tool
|
||||||
|
formRefs.current = {};
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,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>
|
||||||
|
|
@ -103,7 +140,16 @@ const ToolsTab = ({
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{selectedTool ? (
|
{selectedTool ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription className="break-all">
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<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(
|
||||||
|
|
@ -143,26 +189,74 @@ const ToolsTab = ({
|
||||||
{prop.description || "Toggle this option"}
|
{prop.description || "Toggle this option"}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
) : prop.type === "string" && prop.enum ? (
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
params[key] === undefined
|
||||||
|
? ""
|
||||||
|
: String(params[key])
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "") {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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" ? (
|
) : prop.type === "string" ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
id={key}
|
id={key}
|
||||||
name={key}
|
name={key}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
value={(params[key] as string) ?? ""}
|
value={
|
||||||
onChange={(e) =>
|
params[key] === undefined
|
||||||
setParams({
|
? ""
|
||||||
...params,
|
: String(params[key])
|
||||||
[key]:
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
) : prop.type === "object" || prop.type === "array" ? (
|
) : prop.type === "object" || prop.type === "array" ? (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<DynamicJsonForm
|
<DynamicJsonForm
|
||||||
|
ref={(ref) => (formRefs.current[key] = ref)}
|
||||||
schema={{
|
schema={{
|
||||||
type: prop.type,
|
type: prop.type,
|
||||||
properties: prop.properties,
|
properties: prop.properties,
|
||||||
|
|
@ -178,6 +272,8 @@ const ToolsTab = ({
|
||||||
...params,
|
...params,
|
||||||
[key]: newValue,
|
[key]: newValue,
|
||||||
});
|
});
|
||||||
|
// Check validation after a short delay to allow form to update
|
||||||
|
setTimeout(checkValidationErrors, 100);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,19 +284,42 @@ const ToolsTab = ({
|
||||||
id={key}
|
id={key}
|
||||||
name={key}
|
name={key}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
value={(params[key] as string) ?? ""}
|
value={
|
||||||
|
params[key] === undefined
|
||||||
|
? ""
|
||||||
|
: String(params[key])
|
||||||
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setParams({
|
if (value === "") {
|
||||||
...params,
|
// Field cleared - set to undefined
|
||||||
[key]: value === "" ? "" : Number(value),
|
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"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<DynamicJsonForm
|
<DynamicJsonForm
|
||||||
|
ref={(ref) => (formRefs.current[key] = ref)}
|
||||||
schema={{
|
schema={{
|
||||||
type: prop.type,
|
type: prop.type,
|
||||||
properties: prop.properties,
|
properties: prop.properties,
|
||||||
|
|
@ -213,6 +332,8 @@ const ToolsTab = ({
|
||||||
...params,
|
...params,
|
||||||
[key]: newValue,
|
[key]: newValue,
|
||||||
});
|
});
|
||||||
|
// Check validation after a short delay to allow form to update
|
||||||
|
setTimeout(checkValidationErrors, 100);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -291,29 +412,57 @@ const ToolsTab = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
onClick={async () => {
|
<Button
|
||||||
try {
|
onClick={async () => {
|
||||||
setIsToolRunning(true);
|
// Validate JSON inputs before calling tool
|
||||||
await callTool(selectedTool.name, params);
|
if (checkValidationErrors()) return;
|
||||||
} finally {
|
|
||||||
setIsToolRunning(false);
|
try {
|
||||||
}
|
setIsToolRunning(true);
|
||||||
}}
|
await callTool(selectedTool.name, params);
|
||||||
disabled={isToolRunning}
|
} finally {
|
||||||
>
|
setIsToolRunning(false);
|
||||||
{isToolRunning ? (
|
}
|
||||||
<>
|
}}
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
disabled={isToolRunning || hasValidationErrors}
|
||||||
Running...
|
>
|
||||||
</>
|
{isToolRunning ? (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
<Send className="w-4 h-4 mr-2" />
|
Running...
|
||||||
Run Tool
|
</>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</Button>
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Run Tool
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
JSON.stringify(params, null, 2),
|
||||||
|
);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `There was an error copying input to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Copy Input
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<ToolResults
|
<ToolResults
|
||||||
toolResult={toolResult}
|
toolResult={toolResult}
|
||||||
selectedTool={selectedTool}
|
selectedTool={selectedTool}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import DynamicJsonForm from "../DynamicJsonForm";
|
import { useRef } from "react";
|
||||||
|
import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm";
|
||||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
describe("DynamicJsonForm String Fields", () => {
|
describe("DynamicJsonForm String Fields", () => {
|
||||||
|
|
@ -663,3 +664,221 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Validation Functionality", () => {
|
||||||
|
const renderFormWithRef = (props = {}) => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const formRef = useRef<DynamicJsonFormRef>(null);
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
||||||
|
},
|
||||||
|
} as unknown as JsonSchemaType,
|
||||||
|
value: { nested: "test value" },
|
||||||
|
onChange: jest.fn(),
|
||||||
|
ref: formRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DynamicJsonForm {...defaultProps} {...props} />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const result = formRef.current?.validateJson();
|
||||||
|
// Add data attributes to make validation result testable
|
||||||
|
const button = document.querySelector(
|
||||||
|
'[data-testid="validate-button"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (button && result) {
|
||||||
|
button.setAttribute(
|
||||||
|
"data-validation-valid",
|
||||||
|
result.isValid.toString(),
|
||||||
|
);
|
||||||
|
button.setAttribute(
|
||||||
|
"data-validation-error",
|
||||||
|
result.error || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="validate-button"
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(<TestComponent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("validateJson method", () => {
|
||||||
|
it("should return valid for form mode", () => {
|
||||||
|
const simpleSchema = {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Test string field",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TestComponent = () => {
|
||||||
|
const formRef = useRef<DynamicJsonFormRef>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DynamicJsonForm
|
||||||
|
ref={formRef}
|
||||||
|
schema={simpleSchema}
|
||||||
|
value="test"
|
||||||
|
onChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const result = formRef.current?.validateJson();
|
||||||
|
const button = document.querySelector(
|
||||||
|
'[data-testid="validate-button"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (button && result) {
|
||||||
|
button.setAttribute(
|
||||||
|
"data-validation-valid",
|
||||||
|
result.isValid.toString(),
|
||||||
|
);
|
||||||
|
button.setAttribute(
|
||||||
|
"data-validation-error",
|
||||||
|
result.error || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="validate-button"
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
|
||||||
|
expect(validateButton.getAttribute("data-validation-error")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return valid for valid JSON in JSON mode", () => {
|
||||||
|
renderFormWithRef();
|
||||||
|
|
||||||
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
|
||||||
|
expect(validateButton.getAttribute("data-validation-error")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return invalid for malformed JSON in JSON mode", async () => {
|
||||||
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Enter invalid JSON
|
||||||
|
const textarea = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
|
||||||
|
|
||||||
|
// Wait a bit for any debounced updates
|
||||||
|
await waitFor(() => {
|
||||||
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
expect(validateButton.getAttribute("data-validation-valid")).toBe(
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(validateButton.getAttribute("data-validation-error")).toContain(
|
||||||
|
"JSON",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return valid for empty JSON in JSON mode", () => {
|
||||||
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Clear the textarea
|
||||||
|
const textarea = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(textarea, { target: { value: "" } });
|
||||||
|
|
||||||
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
|
||||||
|
expect(validateButton.getAttribute("data-validation-error")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set error state when validation fails", async () => {
|
||||||
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Enter invalid JSON
|
||||||
|
const textarea = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(textarea, {
|
||||||
|
target: { value: '{ "trailing": "comma", }' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger validation
|
||||||
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
// Check that validation result shows error
|
||||||
|
expect(validateButton.getAttribute("data-validation-valid")).toBe(
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(validateButton.getAttribute("data-validation-error")).toContain(
|
||||||
|
"JSON",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("forwardRef functionality", () => {
|
||||||
|
it("should expose validateJson method through ref", () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
const formRef = useRef<DynamicJsonFormRef>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DynamicJsonForm
|
||||||
|
ref={formRef}
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
test: { type: "string" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={{ test: "value" }}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const hasValidateMethod =
|
||||||
|
typeof formRef.current?.validateJson === "function";
|
||||||
|
const button = document.querySelector(
|
||||||
|
'[data-testid="ref-test-button"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (button) {
|
||||||
|
button.setAttribute(
|
||||||
|
"data-has-validate-method",
|
||||||
|
hasValidateMethod.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="ref-test-button"
|
||||||
|
>
|
||||||
|
Test Ref
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
const testButton = screen.getByTestId("ref-test-button");
|
||||||
|
fireEvent.click(testButton);
|
||||||
|
|
||||||
|
expect(testButton.getAttribute("data-has-validate-method")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
203
client/src/components/__tests__/ListPane.test.tsx
Normal file
203
client/src/components/__tests__/ListPane.test.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
|
import ListPane from "../ListPane";
|
||||||
|
|
||||||
|
describe("ListPane", () => {
|
||||||
|
const mockItems = [
|
||||||
|
{ id: 1, name: "Tool 1", description: "First tool" },
|
||||||
|
{ id: 2, name: "Tool 2", description: "Second tool" },
|
||||||
|
{ id: 3, name: "Another Tool", description: "Third tool" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
items: mockItems,
|
||||||
|
listItems: jest.fn(),
|
||||||
|
clearItems: jest.fn(),
|
||||||
|
setSelectedItem: jest.fn(),
|
||||||
|
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
|
||||||
|
title: "List tools",
|
||||||
|
buttonText: "Load Tools",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderListPane = (props = {}) => {
|
||||||
|
return render(<ListPane {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rendering", () => {
|
||||||
|
it("should render with title and button", () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
expect(screen.getByText("List tools")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Load Tools" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render items when provided", () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
expect(screen.getByText("Tool 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tool 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Another Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render empty state when no items", () => {
|
||||||
|
renderListPane({ items: [] });
|
||||||
|
|
||||||
|
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render custom item content", () => {
|
||||||
|
const customRenderItem = (item: (typeof mockItems)[0]) => (
|
||||||
|
<div>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
<small>{item.description}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
renderListPane({ renderItem: customRenderItem });
|
||||||
|
|
||||||
|
expect(screen.getByText("Tool 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("First tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Search Functionality", () => {
|
||||||
|
it("should show search icon initially", () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
expect(searchButton).toBeInTheDocument();
|
||||||
|
expect(searchButton.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should expand search input when search icon is clicked", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
expect(searchInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Wait for the setTimeout to complete and focus to be set
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(searchInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter items based on search query", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "Tool" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Tool 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tool 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Another Tool")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "Another" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Another Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "NonExistent" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('No items found matching "NonExistent"'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should collapse search when input is empty and loses focus", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "test" } });
|
||||||
|
fireEvent.change(searchInput, { target: { value: "" } });
|
||||||
|
fireEvent.blur(searchInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchButtonAfterCollapse = screen.getByRole("button", {
|
||||||
|
name: "Search",
|
||||||
|
});
|
||||||
|
expect(searchButtonAfterCollapse).toBeInTheDocument();
|
||||||
|
expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep search expanded when input has content and loses focus", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "test" } });
|
||||||
|
fireEvent.blur(searchInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should search through all item properties (description)", async () => {
|
||||||
|
renderListPane();
|
||||||
|
|
||||||
|
const searchButton = screen.getByRole("button", { name: "Search" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(searchButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(searchInput, { target: { value: "First tool" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Tool 1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -44,12 +44,14 @@ 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: {},
|
||||||
setEnv: jest.fn(),
|
setEnv: jest.fn(),
|
||||||
bearerToken: "",
|
customHeaders: [],
|
||||||
setBearerToken: jest.fn(),
|
setCustomHeaders: jest.fn(),
|
||||||
onConnect: jest.fn(),
|
onConnect: jest.fn(),
|
||||||
onDisconnect: jest.fn(),
|
onDisconnect: jest.fn(),
|
||||||
stdErrNotifications: [],
|
stdErrNotifications: [],
|
||||||
|
|
@ -59,6 +61,8 @@ describe("Sidebar", () => {
|
||||||
loggingSupported: true,
|
loggingSupported: true,
|
||||||
config: DEFAULT_INSPECTOR_CONFIG,
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
setConfig: jest.fn(),
|
setConfig: jest.fn(),
|
||||||
|
connectionType: "proxy" as const,
|
||||||
|
setConnectionType: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSidebar = (props = {}) => {
|
const renderSidebar = (props = {}) => {
|
||||||
|
|
@ -446,6 +450,11 @@ describe("Sidebar", () => {
|
||||||
4,
|
4,
|
||||||
);
|
);
|
||||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Config entry copied",
|
||||||
|
description:
|
||||||
|
"Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
||||||
|
|
@ -504,6 +513,11 @@ describe("Sidebar", () => {
|
||||||
4,
|
4,
|
||||||
);
|
);
|
||||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Config entry copied",
|
||||||
|
description:
|
||||||
|
"SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
||||||
|
|
@ -554,6 +568,11 @@ describe("Sidebar", () => {
|
||||||
4,
|
4,
|
||||||
);
|
);
|
||||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Config entry copied",
|
||||||
|
description:
|
||||||
|
"Streamable HTTP URL has been copied. Use this URL directly in your MCP Client.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
||||||
|
|
@ -619,117 +638,179 @@ describe("Sidebar", () => {
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should update bearer token", () => {
|
it("should update bearer token via custom headers", async () => {
|
||||||
const setBearerToken = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
bearerToken: "",
|
customHeaders: [],
|
||||||
setBearerToken,
|
setCustomHeaders,
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
// Add a new header
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
const addButton = screen.getByTestId("add-header-button");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
// Verify that setCustomHeaders was called to add an empty header
|
||||||
|
expect(setCustomHeaders).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update header name", () => {
|
it("should update header name via custom headers", () => {
|
||||||
const setHeaderName = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
headerName: "Authorization",
|
customHeaders: [
|
||||||
setHeaderName,
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer token123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setCustomHeaders,
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
const headerNameInput = screen.getByTestId("header-name-input-0");
|
||||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
fireEvent.change(headerNameInput, { target: { value: "X-Custom-Auth" } });
|
||||||
|
|
||||||
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
expect(setCustomHeaders).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: "X-Custom-Auth",
|
||||||
|
value: "Bearer token123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should clear bearer token", () => {
|
it("should clear bearer token via custom headers", () => {
|
||||||
const setBearerToken = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
setBearerToken,
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setCustomHeaders,
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
const headerValueInput = screen.getByTestId("header-value-input-0");
|
||||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
fireEvent.change(headerValueInput, { target: { value: "" } });
|
||||||
|
|
||||||
expect(setBearerToken).toHaveBeenCalledWith("");
|
expect(setCustomHeaders).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should properly render bearer token input", () => {
|
it("should properly render header value input as password field", () => {
|
||||||
const { rerender } = renderSidebar({
|
const { rerender } = renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Token input should be a password field
|
// Token input should be a password field
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
const tokenInput = screen.getByTestId("header-value-input-0");
|
||||||
expect(tokenInput).toHaveProperty("type", "password");
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
// Update the token
|
// Update the token
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
|
||||||
|
|
||||||
// Rerender with updated token
|
// Rerender with updated token
|
||||||
rerender(
|
rerender(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
bearerToken="new_token"
|
customHeaders={[
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer new_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
transportType="sse"
|
transportType="sse"
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Token input should still exist after update
|
// Token input should still exist after update
|
||||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should maintain token visibility state after update", () => {
|
it("should maintain token visibility state after update", () => {
|
||||||
const { rerender } = renderSidebar({
|
const { rerender } = renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Token input should be a password field
|
// Token input should be a password field
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
const tokenInput = screen.getByTestId("header-value-input-0");
|
||||||
expect(tokenInput).toHaveProperty("type", "password");
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
// Update the token
|
// Update the token
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
|
||||||
|
|
||||||
// Rerender with updated token
|
// Rerender with updated token
|
||||||
rerender(
|
rerender(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
bearerToken="new_token"
|
customHeaders={[
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer new_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
transportType="sse"
|
transportType="sse"
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Token input should still exist after update
|
// Token input should still exist after update
|
||||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should maintain header name when toggling auth section", () => {
|
it("should maintain header name when toggling auth section", () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
headerName: "X-API-Key",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "X-API-Key",
|
||||||
|
value: "api-key-123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -737,7 +818,7 @@ describe("Sidebar", () => {
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Verify header name is displayed
|
// Verify header name is displayed
|
||||||
const headerInput = screen.getByTestId("header-input");
|
const headerInput = screen.getByTestId("header-name-input-0");
|
||||||
expect(headerInput).toHaveValue("X-API-Key");
|
expect(headerInput).toHaveValue("X-API-Key");
|
||||||
|
|
||||||
// Close auth section
|
// Close auth section
|
||||||
|
|
@ -748,19 +829,66 @@ describe("Sidebar", () => {
|
||||||
fireEvent.click(authButton);
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
// Verify header name is still preserved
|
// Verify header name is still preserved
|
||||||
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
expect(screen.getByTestId("header-name-input-0")).toHaveValue(
|
||||||
|
"X-API-Key",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display default header name when not specified", () => {
|
it("should display placeholder for header name when no headers exist", () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
headerName: undefined,
|
customHeaders: [],
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
// Verify that the "No custom headers configured" message is shown
|
||||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
expect(
|
||||||
|
screen.getByText("No custom headers configured"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Click "Add" to get started'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify the Add button is present
|
||||||
|
const addButton = screen.getByTestId("add-header-button");
|
||||||
|
expect(addButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow editing existing headers", () => {
|
||||||
|
const setCustomHeaders = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer token123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setCustomHeaders,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Verify header inputs are rendered
|
||||||
|
const headerNameInput = screen.getByTestId("header-name-input-0");
|
||||||
|
const headerValueInput = screen.getByTestId("header-value-input-0");
|
||||||
|
|
||||||
|
expect(headerNameInput).toHaveValue("Authorization");
|
||||||
|
expect(headerValueInput).toHaveValue("Bearer token123");
|
||||||
|
expect(headerNameInput).toHaveAttribute("placeholder", "Header Name");
|
||||||
|
expect(headerValueInput).toHaveAttribute("placeholder", "Header Value");
|
||||||
|
|
||||||
|
// Update header name
|
||||||
|
fireEvent.change(headerNameInput, { target: { value: "X-API-Key" } });
|
||||||
|
expect(setCustomHeaders).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: "X-API-Key",
|
||||||
|
value: "Bearer token123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue