Merge branch 'main' into completion-support-default

This commit is contained in:
Ola Hungerford 2025-07-07 05:00:52 -07:00 • committed by GitHub
commit e106194b40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 5383 additions and 929 deletions

View file

@ -1,7 +1,3 @@
#root {
margin: 0 auto;
}
.logo {
height: 6em;
padding: 1.5em;

View file

@ -19,7 +19,9 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState } from "./lib/auth-types";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import React, {
Suspense,
useCallback,
@ -28,7 +30,10 @@ import React, {
useState,
} from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import {
useDraggablePane,
useDraggableSidebar,
} from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -47,7 +52,7 @@ import { z } from "zod";
import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
import HistoryAndNotifications from "./components/HistoryAndNotifications";
import PingTab from "./components/PingTab";
import PromptsTab, { Prompt } from "./components/PromptsTab";
import ResourcesTab from "./components/ResourcesTab";
@ -58,11 +63,13 @@ import ToolsTab from "./components/ToolsTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getMCPProxyAuthToken,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
} from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -120,19 +127,8 @@ const App = () => {
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] = useState<AuthDebuggerState>({
isInitiatingAuth: false,
oauthTokens: null,
loading: true,
oauthStep: "metadata_discovery",
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
});
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Helper function to update specific auth state properties
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
@ -163,6 +159,11 @@ const App = () => {
const progressTokenRef = useRef(0);
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
isDragging: isSidebarDragging,
handleDragStart: handleSidebarDragStart,
} = useDraggableSidebar(320);
const {
connectionStatus,
@ -227,7 +228,7 @@ const App = () => {
}, [headerName]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
// Auto-connect to previously saved serverURL after OAuth callback
@ -242,27 +243,81 @@ const App = () => {
// Update OAuth debug state during debug callback
const onOAuthDebugConnect = useCallback(
({
async ({
authorizationCode,
errorMsg,
restoredState,
}: {
authorizationCode?: string;
errorMsg?: string;
restoredState?: AuthDebuggerState;
}) => {
setIsAuthDebuggerVisible(true);
if (authorizationCode) {
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
return;
}
if (restoredState && authorizationCode) {
// Restore the previous auth state and continue the OAuth flow
let currentState: AuthDebuggerState = {
...restoredState,
authorizationCode,
oauthStep: "token_request",
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
};
try {
// Create a new state machine instance to continue the flow
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
// Continue stepping through the OAuth flow from where we left off
while (
currentState.oauthStep !== "complete" &&
currentState.oauthStep !== "authorization_code"
) {
await stateMachine.executeStep(currentState);
}
if (currentState.oauthStep === "complete") {
// After the flow completes or reaches a user-input step, update the app state
updateAuthState({
...currentState,
statusMessage: {
type: "success",
message: "Authentication completed successfully",
},
isInitiatingAuth: false,
});
}
} catch (error) {
console.error("OAuth continuation error:", error);
updateAuthState({
latestError:
error instanceof Error ? error : new Error(String(error)),
statusMessage: {
type: "error",
message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
isInitiatingAuth: false,
});
}
} else if (authorizationCode) {
// Fallback to the original behavior if no state was restored
updateAuthState({
authorizationCode,
oauthStep: "token_request",
});
}
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
}
},
[],
[sseUrl],
);
// Load OAuth tokens when sseUrl changes
@ -284,8 +339,6 @@ const App = () => {
}
} catch (error) {
console.error("Error loading OAuth tokens:", error);
} finally {
updateAuthState({ loading: false });
}
};
@ -293,7 +346,14 @@ const App = () => {
}, [sseUrl]);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
const headers: HeadersInit = {};
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
if (proxyAuthToken) {
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
.then((response) => response.json())
.then((data) => {
setEnv(data.defaultEnvironment);
@ -307,8 +367,7 @@ const App = () => {
.catch((error) =>
console.error("Error fetching default environment:", error),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [config]);
useEffect(() => {
rootsRef.current = roots;
@ -473,6 +532,8 @@ const App = () => {
);
setTools(response.tools);
setNextToolCursor(response.nextCursor);
// Cache output schemas for validation
cacheToolOutputSchemas(response.tools);
};
const callTool = async (name: string, params: Record<string, unknown>) => {
@ -562,32 +623,58 @@ const App = () => {
return (
<div className="flex h-screen bg-background">
<Sidebar
connectionStatus={connectionStatus}
transportType={transportType}
setTransportType={setTransportType}
command={command}
setCommand={setCommand}
args={args}
setArgs={setArgs}
sseUrl={sseUrl}
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
<div
style={{
width: sidebarWidth,
minWidth: 200,
maxWidth: 600,
transition: isSidebarDragging ? "none" : "width 0.15s",
}}
className="bg-card border-r border-border flex flex-col h-full relative"
>
<Sidebar
connectionStatus={connectionStatus}
transportType={transportType}
setTransportType={setTransportType}
command={command}
setCommand={setCommand}
args={args}
setArgs={setArgs}
sseUrl={sseUrl}
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
{/* Drag handle for resizing sidebar */}
<div
onMouseDown={handleSidebarDragStart}
style={{
cursor: "col-resize",
position: "absolute",
top: 0,
right: 0,
width: 6,
height: "100%",
zIndex: 10,
background: isSidebarDragging ? "rgba(0,0,0,0.08)" : "transparent",
}}
aria-label="Resize sidebar"
data-testid="sidebar-drag-handle"
/>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
@ -608,7 +695,7 @@ const App = () => {
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<TabsList className="mb-4 p-0">
<TabsList className="mb-4 py-0">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
@ -659,7 +746,7 @@ const App = () => {
!serverCapabilities?.tools ? (
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
<p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP
capabilities
</p>
@ -759,6 +846,8 @@ const App = () => {
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
// Clear cached output schemas
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
clearError("tools");
@ -811,7 +900,7 @@ const App = () => {
</Tabs>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500">
<p className="text-lg text-gray-500 dark:text-gray-400">
Connect to an MCP server to start inspecting
</p>
<div className="flex items-center gap-2">
@ -836,7 +925,7 @@ const App = () => {
}}
>
<div
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50"
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
onMouseDown={handleDragStart}
>
<div className="w-8 h-1 rounded-full bg-border" />

View file

@ -0,0 +1,240 @@
import { render, waitFor } from "@testing-library/react";
import App from "../App";
import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants";
import { InspectorConfig } from "../lib/configurationTypes";
import * as configUtils from "../utils/configUtils";
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
// Mock the config utils
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn((config: InspectorConfig) => ({
token: config.MCP_PROXY_AUTH_TOKEN.value,
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => DEFAULT_INSPECTOR_CONFIG),
saveInspectorConfig: jest.fn(),
}));
// Get references to the mocked functions
const mockGetMCPProxyAuthToken = configUtils.getMCPProxyAuthToken as jest.Mock;
const mockInitializeInspectorConfig =
configUtils.initializeInspectorConfig as jest.Mock;
// Mock other dependencies
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: () => ({
connectionStatus: "disconnected",
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
}),
}));
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
// Mock fetch
global.fetch = jest.fn();
describe("App - Config Endpoint", () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
json: () =>
Promise.resolve({
defaultEnvironment: { TEST_ENV: "test" },
defaultCommand: "test-command",
defaultArgs: "test-args",
}),
});
});
afterEach(() => {
jest.clearAllMocks();
// Reset getMCPProxyAuthToken to default behavior
mockGetMCPProxyAuthToken.mockImplementation((config: InspectorConfig) => ({
token: config.MCP_PROXY_AUTH_TOKEN.value,
header: "X-MCP-Proxy-Auth",
}));
});
test("sends X-MCP-Proxy-Auth header when fetching config with proxy auth token", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
// Mock initializeInspectorConfig to return our test config
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
},
},
);
});
});
test("does not send auth header when proxy auth token is empty", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "",
},
};
// Mock initializeInspectorConfig to return our test config
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {},
},
);
});
});
test("uses custom header name if getMCPProxyAuthToken returns different header", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
// Mock to return a custom header name
mockGetMCPProxyAuthToken.mockReturnValue({
token: "test-proxy-token",
header: "X-Custom-Auth",
});
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {
"X-Custom-Auth": "Bearer test-proxy-token",
},
},
);
});
});
test("config endpoint response updates app state", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});
// Verify the fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
expect.objectContaining({
headers: expect.objectContaining({
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
}),
}),
);
});
test("handles config endpoint errors gracefully", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
// Mock fetch to reject
(global.fetch as jest.Mock).mockRejectedValue(new Error("Network error"));
// Spy on console.error
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
render(<App />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error fetching default environment:",
expect.any(Error),
);
});
consoleErrorSpy.mockRestore();
});
});

View file

@ -1,15 +1,11 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import {
auth,
discoverOAuthMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState } from "../lib/auth-types";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants";
export interface AuthDebuggerProps {
serverUrl: string;
@ -64,6 +60,27 @@ const AuthDebugger = ({
authState,
updateAuthState,
}: AuthDebuggerProps) => {
// Check for existing tokens on mount
useEffect(() => {
if (serverUrl && !authState.oauthTokens) {
const checkTokens = async () => {
try {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const existingTokens = await provider.tokens();
if (existingTokens) {
updateAuthState({
oauthTokens: existingTokens,
oauthStep: "complete",
});
}
} catch (error) {
console.error("Failed to load existing OAuth tokens:", error);
}
};
checkTokens();
}
}, [serverUrl, updateAuthState, authState.oauthTokens]);
const startOAuthFlow = useCallback(() => {
if (!serverUrl) {
updateAuthState({
@ -124,22 +141,48 @@ const AuthDebugger = ({
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
try {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
// First discover OAuth metadata separately so we can save it
const metadata = await discoverOAuthMetadata(serverUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
serverAuthProvider.saveServerMetadata(parsedMetadata);
// Step through the OAuth flow using the state machine instead of the auth() function
let currentState: AuthDebuggerState = {
...authState,
oauthStep: "metadata_discovery",
authorizationUrl: null,
latestError: null,
};
await auth(serverAuthProvider, { serverUrl: serverUrl });
const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
// Update our temporary state during the process
currentState = { ...currentState, ...updates };
// But don't call updateAuthState yet
});
// Manually step through each stage of the OAuth flow
while (currentState.oauthStep !== "complete") {
await oauthMachine.executeStep(currentState);
// In quick mode, we'll just redirect to the authorization URL
if (
currentState.oauthStep === "authorization_code" &&
currentState.authorizationUrl
) {
// Store the current auth state before redirecting
sessionStorage.setItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE,
JSON.stringify(currentState),
);
// Open the authorization URL automatically
window.location.href = currentState.authorizationUrl;
break;
}
}
// After the flow completes or reaches a user-input step, update the app state
updateAuthState({
...currentState,
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
message:
currentState.oauthStep === "complete"
? "Authentication completed successfully"
: "Please complete authentication in the opened window and enter the code",
},
});
} catch (error) {
@ -153,7 +196,7 @@ const AuthDebugger = ({
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, updateAuthState]);
}, [serverUrl, updateAuthState, authState]);
const handleClearOAuth = useCallback(() => {
if (serverUrl) {
@ -162,13 +205,7 @@ const AuthDebugger = ({
);
serverAuthProvider.clear();
updateAuthState({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
authorizationCode: "",
validationError: null,
oauthMetadata: null,
...EMPTY_DEBUGGER_STATE,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
@ -208,52 +245,48 @@ const AuthDebugger = ({
<StatusMessage message={authState.statusMessage} />
)}
{authState.loading ? (
<p>Loading authentication status...</p>
) : (
<div className="space-y-4">
{authState.oauthTokens && (
<div className="space-y-2">
<p className="text-sm font-medium">Access Token:</p>
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
{authState.oauthTokens.access_token.substring(0, 25)}...
</div>
<div className="space-y-4">
{authState.oauthTokens && (
<div className="space-y-2">
<p className="text-sm font-medium">Access Token:</p>
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
{authState.oauthTokens.access_token.substring(0, 25)}...
</div>
)}
<div className="flex gap-4">
<Button
variant="outline"
onClick={startOAuthFlow}
disabled={authState.isInitiatingAuth}
>
{authState.oauthTokens
? "Guided Token Refresh"
: "Guided OAuth Flow"}
</Button>
<Button
onClick={handleQuickOAuth}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Initiating..."
: authState.oauthTokens
? "Quick Refresh"
: "Quick OAuth Flow"}
</Button>
<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
Choose "Guided" for step-by-step instructions or "Quick" for
the standard automatic flow.
</p>
<div className="flex gap-4">
<Button
variant="outline"
onClick={startOAuthFlow}
disabled={authState.isInitiatingAuth}
>
{authState.oauthTokens
? "Guided Token Refresh"
: "Guided OAuth Flow"}
</Button>
<Button
onClick={handleQuickOAuth}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Initiating..."
: authState.oauthTokens
? "Quick Refresh"
: "Quick OAuth Flow"}
</Button>
<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
Choose "Guided" for step-by-step instructions or "Quick" for
the standard automatic flow.
</p>
</div>
</div>
<OAuthFlowProgress

View file

@ -16,10 +16,39 @@ interface DynamicJsonFormProps {
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type !== "object") return false;
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
return !!schema.items && isSimpleObject(schema.items);
}
return false;
};
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "null":
return null;
default:
return null;
}
};
const DynamicJsonForm = ({
@ -113,6 +142,8 @@ const DynamicJsonForm = ({
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
parentSchema?: JsonSchemaType,
propertyName?: string,
) => {
if (
depth >= maxDepth &&
@ -122,7 +153,8 @@ const DynamicJsonForm = ({
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
currentValue ??
generateDefaultValue(propSchema, propertyName, parentSchema),
null,
2,
)}
@ -140,6 +172,10 @@ const DynamicJsonForm = ({
);
}
// Check if this property is required in the parent schema
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
return (
@ -148,16 +184,11 @@ const DynamicJsonForm = ({
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
// Always allow setting string values, including empty strings
handleFieldChange(path, val);
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "number":
@ -167,9 +198,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -179,7 +208,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "integer":
@ -190,9 +219,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -203,7 +230,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "boolean":
@ -213,9 +240,135 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
required={isRequired}
/>
);
case "object":
if (!propSchema.properties) {
return (
<JsonEditor
value={JSON.stringify(currentValue ?? {}, null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
return (
<div className="space-y-2 border rounded p-3">
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}>
<label className="block text-sm font-medium mb-1">
{key}
{propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{renderFormFields(
subSchema as JsonSchemaType,
(currentValue as Record<string, JsonValue>)?.[key],
[...path, key],
depth + 1,
propSchema,
key,
)}
</div>
))}
</div>
);
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}
<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = getArrayItemDefault(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [...arrayValue, defaultValue]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
// For complex arrays, fall back to JSON editor
return (
<JsonEditor
value={JSON.stringify(currentValue ?? [], null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
default:
return null;
}

View file

@ -0,0 +1,155 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import JsonView from "./JsonView";
const HistoryAndNotifications = ({
requestHistory,
serverNotifications,
}: {
requestHistory: Array<{ request: string; response?: string }>;
serverNotifications: ServerNotification[];
}) => {
const [expandedRequests, setExpandedRequests] = useState<{
[key: number]: boolean;
}>({});
const [expandedNotifications, setExpandedNotifications] = useState<{
[key: number]: boolean;
}>({});
const toggleRequestExpansion = (index: number) => {
setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] }));
};
const toggleNotificationExpansion = (index: number) => {
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
};
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
<h2 className="text-lg font-semibold mb-4">History</h2>
{requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
</p>
) : (
<ul className="space-y-3">
{requestHistory
.slice()
.reverse()
.map((request, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() =>
toggleRequestExpansion(requestHistory.length - 1 - index)
}
>
<span className="font-mono">
{requestHistory.length - index}.{" "}
{JSON.parse(request.request).method}
</span>
<span>
{expandedRequests[requestHistory.length - 1 - index]
? "▼"
: "▶"}
</span>
</div>
{expandedRequests[requestHistory.length - 1 - index] && (
<>
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-blue-600">
Request:
</span>
</div>
<JsonView
data={request.request}
className="bg-background"
/>
</div>
{request.response && (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-green-600">
Response:
</span>
</div>
<JsonView
data={request.response}
className="bg-background"
/>
</div>
)}
</>
)}
</li>
))}
</ul>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
{serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet
</p>
) : (
<ul className="space-y-3">
{serverNotifications
.slice()
.reverse()
.map((notification, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() =>
toggleNotificationExpansion(
serverNotifications.length - 1 - index,
)
}
>
<span className="font-mono">
{serverNotifications.length - index}.{" "}
{notification.method}
</span>
<span>
{expandedNotifications[
serverNotifications.length - 1 - index
]
? "▼"
: "▶"}
</span>
</div>
{expandedNotifications[
serverNotifications.length - 1 - index
] && (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-purple-600">
Details:
</span>
</div>
<JsonView
data={JSON.stringify(notification, null, 2)}
className="bg-background"
/>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default HistoryAndNotifications;

View file

@ -21,8 +21,8 @@ const ListPane = <T extends object>({
buttonText,
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">
@ -46,7 +46,7 @@ const ListPane = <T extends object>({
{items.map((item, index) => (
<div
key={index}
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}

View file

@ -4,14 +4,17 @@ import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
import { AuthDebuggerState } from "@/lib/auth-types";
interface OAuthCallbackProps {
onConnect: ({
authorizationCode,
errorMsg,
restoredState,
}: {
authorizationCode?: string;
errorMsg?: string;
restoredState?: AuthDebuggerState;
}) => void;
}
@ -35,6 +38,21 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
// Try to restore the auth state
const storedState = sessionStorage.getItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE,
);
let restoredState = null;
if (storedState) {
try {
restoredState = JSON.parse(storedState);
// Clean up the stored state
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
} catch (e) {
console.error("Failed to parse stored auth state:", e);
}
}
// ServerURL isn't set, this can happen if we've opened the
// authentication request in a new tab, so we don't have the same
// session storage
@ -50,8 +68,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
}
// Instead of storing in sessionStorage, pass the code directly
// to the auth state manager through onConnect
onConnect({ authorizationCode: params.code });
// to the auth state manager through onConnect, along with restored state
onConnect({ authorizationCode: params.code, restoredState });
};
handleCallback().finally(() => {

View file

@ -2,6 +2,8 @@ import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
import { useEffect, useMemo, useState } from "react";
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
interface OAuthStepProps {
label: string;
@ -54,24 +56,57 @@ interface OAuthFlowProgressProps {
proceedToNextStep: () => Promise<void>;
}
const steps: Array<OAuthStep> = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
export const OAuthFlowProgress = ({
serverUrl,
authState,
updateAuthState,
proceedToNextStep,
}: OAuthFlowProgressProps) => {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const provider = useMemo(
() => new DebugInspectorOAuthClientProvider(serverUrl),
[serverUrl],
);
const [clientInfo, setClientInfo] = useState<OAuthClientInformation | null>(
null,
);
const steps: Array<OAuthStep> = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
useEffect(() => {
const fetchClientInfo = async () => {
if (authState.oauthClientInfo) {
setClientInfo(authState.oauthClientInfo);
} else {
try {
const info = await provider.clientInformation();
if (info) {
setClientInfo(info);
}
} catch (error) {
console.error("Failed to fetch client information:", error);
}
}
};
if (currentStepIdx > steps.indexOf("client_registration")) {
fetchClientInfo();
}
}, [
provider,
authState.oauthStep,
authState.oauthClientInfo,
currentStepIdx,
]);
// Helper to get step props
const getStepProps = (stepName: OAuthStep) => ({
isComplete:
@ -93,15 +128,86 @@ export const OAuthFlowProgress = ({
label="Metadata Discovery"
{...getStepProps("metadata_discovery")}
>
{provider.getServerMetadata() && (
{authState.oauthMetadata && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Retrieved OAuth Metadata from {serverUrl}
/.well-known/oauth-authorization-server
OAuth Metadata Sources
{!authState.resourceMetadata && " ℹ️"}
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(provider.getServerMetadata(), null, 2)}
</pre>
{authState.resourceMetadata && (
<div className="mt-2">
<p className="font-medium">Resource Metadata:</p>
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.resourceMetadata, null, 2)}
</pre>
</div>
)}
{authState.resourceMetadataError && (
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
<p className="text-sm font-medium text-blue-700">
ℹ️ Problem with resource metadata from{" "}
<a
href={
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
</a>
</p>
<p className="text-xs text-blue-600 mt-1">
Resource metadata was added in the{" "}
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
2025-DRAFT-v2 specification update
</a>
<br />
{authState.resourceMetadataError.message}
{authState.resourceMetadataError instanceof TypeError &&
" (This could indicate the endpoint doesn't exist or does not have CORS configured)"}
</p>
</div>
)}
{authState.oauthMetadata && (
<div className="mt-2">
<p className="font-medium">Authorization Server Metadata:</p>
{authState.authServerUrl && (
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-authorization-server",
authState.authServerUrl,
).href
}
</p>
)}
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthMetadata, null, 2)}
</pre>
</div>
)}
</details>
)}
</OAuthStepDetails>
@ -110,13 +216,13 @@ export const OAuthFlowProgress = ({
label="Client Registration"
{...getStepProps("client_registration")}
>
{authState.oauthClientInfo && (
{clientInfo && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Registered Client Information
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthClientInfo, null, 2)}
{JSON.stringify(clientInfo, null, 2)}
</pre>
</details>
)}

View file

@ -98,20 +98,20 @@ const PromptsTab = ({
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<div className="flex flex-col items-start">
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">
<span className="text-sm text-gray-500 text-left">
{prompt.description}
</span>
</>
</div>
)}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
isButtonDisabled={!nextCursor && prompts.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
@ -126,7 +126,7 @@ const PromptsTab = ({
) : selectedPrompt ? (
<div className="space-y-4">
{selectedPrompt.description && (
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedPrompt.description}
</p>
)}

View file

@ -16,6 +16,7 @@ import ListPane from "./ListPane";
import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
const ResourcesTab = ({
resources,
@ -79,10 +80,7 @@ const ResourcesTab = ({
template: string,
values: Record<string, string>,
): string => {
return template.replace(
/{([^}]+)}/g,
(_, key) => values[key] || `{${key}}`,
);
return new UriTemplate(template).expand(values);
};
const handleTemplateValueChange = async (key: string, value: string) => {
@ -173,8 +171,8 @@ const ResourcesTab = ({
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
@ -234,31 +232,30 @@ const ResourcesTab = ({
/>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedTemplate.description}
</p>
{selectedTemplate.uriTemplate
.match(/{([^}]+)}/g)
?.map((param) => {
const key = param.slice(1, -1);
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
{new UriTemplate(
selectedTemplate.uriTemplate,
).variableNames?.map((key) => {
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}

View file

@ -214,8 +214,8 @@ const Sidebar = ({
}, [generateMCPServerFile, toast, reportError]);
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-border">
<div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version}
@ -285,13 +285,28 @@ const Sidebar = ({
<label className="text-sm font-medium" htmlFor="sse-url-input">
URL
</label>
<Input
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
{sseUrl ? (
<Tooltip>
<TooltipTrigger asChild>
<Input
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</TooltipTrigger>
<TooltipContent>{sseUrl}</TooltipContent>
</Tooltip>
) : (
<Input
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
)}
</div>
<div className="space-y-2">
<Button
@ -646,13 +661,18 @@ const Sidebar = ({
}
})()}`}
/>
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-600 dark:text-gray-400">
{(() => {
switch (connectionStatus) {
case "connected":
return "Connected";
case "error":
return "Connection Error, is your MCP server running?";
case "error": {
const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value;
if (!hasProxyToken) {
return "Connection Error - Did you add the proxy session token in Configuration?";
}
return "Connection Error - Check if your MCP server is running and proxy token is correct";
}
case "error-connecting-to-proxy":
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
default:

View file

@ -0,0 +1,221 @@
import JsonView from "./JsonView";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null;
}
const checkContentCompatibility = (
structuredContent: unknown,
unstructuredContent: Array<{
type: string;
text?: string;
[key: string]: unknown;
}>,
): { isCompatible: boolean; message: string } => {
if (
unstructuredContent.length !== 1 ||
unstructuredContent[0].type !== "text"
) {
return {
isCompatible: false,
message: "Unstructured content is not a single text block",
};
}
const textContent = unstructuredContent[0].text;
if (!textContent) {
return {
isCompatible: false,
message: "Text content is empty",
};
}
try {
const parsedContent = JSON.parse(textContent);
const isEqual =
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
if (isEqual) {
return {
isCompatible: true,
message: "Unstructured content matches structured content",
};
} else {
return {
isCompatible: false,
message: "Parsed JSON does not match structured content",
};
}
} catch {
return {
isCompatible: false,
message: "Unstructured content is not valid JSON",
};
}
};
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
if (!toolResult) return null;
if ("content" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(toolResult);
if (!parsedResult.success) {
return (
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<JsonView data={toolResult} />
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<JsonView data={error} key={idx} />
))}
</>
);
}
const structuredResult = parsedResult.data;
const isError = structuredResult.isError ?? false;
let validationResult = null;
const toolHasOutputSchema =
selectedTool && hasOutputSchema(selectedTool.name);
if (toolHasOutputSchema) {
if (!structuredResult.structuredContent && !isError) {
validationResult = {
isValid: false,
error:
"Tool has an output schema but did not return structured content",
};
} else if (structuredResult.structuredContent) {
validationResult = validateToolOutput(
selectedTool.name,
structuredResult.structuredContent,
);
}
}
let compatibilityResult = null;
if (
structuredResult.structuredContent &&
structuredResult.content.length > 0 &&
selectedTool &&
hasOutputSchema(selectedTool.name)
) {
compatibilityResult = checkContentCompatibility(
structuredResult.structuredContent,
structuredResult.content,
);
}
return (
<>
<h4 className="font-semibold mb-2">
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
</h4>
{structuredResult.structuredContent && (
<div className="mb-4">
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
<JsonView data={structuredResult.structuredContent} />
{validationResult && (
<div
className={`mt-2 p-2 rounded text-sm ${
validationResult.isValid
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
{validationResult.isValid ? (
"✓ Valid according to output schema"
) : (
<>✗ Validation Error: {validationResult.error}</>
)}
</div>
)}
</div>
</div>
)}
{!structuredResult.structuredContent &&
validationResult &&
!validationResult.isValid && (
<div className="mb-4">
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
✗ Validation Error: {validationResult.error}
</div>
</div>
)}
{structuredResult.content.length > 0 && (
<div className="mb-4">
{structuredResult.structuredContent && (
<>
<h5 className="font-semibold mb-2 text-sm">
Unstructured Content:
</h5>
{compatibilityResult && (
<div
className={`mb-2 p-2 rounded text-sm ${
compatibilityResult.isCompatible
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
}`}
>
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
{compatibilityResult.message}
</div>
)}
</>
)}
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "text" && (
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && (
<img
src={`data:${item.mimeType};base64,${item.data}`}
alt="Tool result image"
className="max-w-full h-auto"
/>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<JsonView data={item.resource} />
))}
</div>
))}
</div>
)}
</>
);
} else if ("toolResult" in toolResult) {
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<JsonView data={toolResult.toolResult} />
</>
);
}
return null;
};
export default ToolResults;

View file

@ -7,17 +7,17 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Loader2, Send } from "lucide-react";
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
const ToolsTab = ({
tools,
@ -41,86 +41,22 @@ const ToolsTab = ({
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
useEffect(() => {
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
generateDefaultValue(
value as JsonSchemaType,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
const renderToolResult = () => {
if (!toolResult) return null;
if ("content" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(toolResult);
if (!parsedResult.success) {
return (
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<JsonView data={toolResult} />
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<JsonView data={error} key={idx} />
))}
</>
);
}
const structuredResult = parsedResult.data;
const isError = structuredResult.isError ?? false;
return (
<>
<h4 className="font-semibold mb-2">
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
</h4>
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "text" && (
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && (
<img
src={`data:${item.mimeType};base64,${item.data}`}
alt="Tool result image"
className="max-w-full h-auto"
/>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<JsonView data={item.resource} />
))}
</div>
))}
</>
);
} else if ("toolResult" in toolResult) {
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<JsonView data={toolResult.toolResult} />
</>
);
}
};
return (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
@ -145,8 +81,8 @@ const ToolsTab = ({
isButtonDisabled={!nextCursor && tools.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
@ -154,19 +90,25 @@ const ToolsTab = ({
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
@ -231,12 +173,13 @@ const ToolsTab = ({
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
onChange={(e) => {
const value = e.target.value;
setParams({
...params,
[key]: Number(e.target.value),
})
}
[key]: value === "" ? "" : Number(value),
});
}}
className="mt-1"
/>
) : (
@ -262,6 +205,42 @@ const ToolsTab = ({
);
},
)}
{selectedTool.outputSchema && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold">Output Schema:</h4>
<Button
size="sm"
variant="ghost"
onClick={() =>
setIsOutputSchemaExpanded(!isOutputSchemaExpanded)
}
className="h-6 px-2"
>
{isOutputSchemaExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Expand
</>
)}
</Button>
</div>
<div
className={`transition-all ${
isOutputSchemaExpanded
? ""
: "max-h-[8rem] overflow-y-auto"
}`}
>
<JsonView data={selectedTool.outputSchema} />
</div>
</div>
)}
<Button
onClick={async () => {
try {
@ -285,7 +264,10 @@ const ToolsTab = ({
</>
)}
</Button>
{toolResult && renderToolResult()}
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
/>
</div>
) : (
<Alert>

View file

@ -40,6 +40,8 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
discoverOAuthProtectedResourceMetadata: jest.fn(),
selectResourceURL: jest.fn(),
}));
// Import the functions to get their types
@ -49,8 +51,10 @@ import {
startAuthorization,
exchangeAuthorization,
auth,
discoverOAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
// Type the mocked functions properly
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
@ -66,6 +70,10 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
typeof exchangeAuthorization
>;
const mockAuth = auth as jest.MockedFunction<typeof auth>;
const mockDiscoverOAuthProtectedResourceMetadata =
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
typeof discoverOAuthProtectedResourceMetadata
>;
const sessionStorageMock = {
getItem: jest.fn(),
@ -77,29 +85,11 @@ Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = {
isInitiatingAuth: false,
oauthTokens: null,
loading: false,
oauthStep: "metadata_discovery" as const,
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
};
const defaultAuthState = EMPTY_DEBUGGER_STATE;
const defaultProps = {
serverUrl: "https://example.com",
serverUrl: "https://example.com/mcp",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
@ -109,8 +99,14 @@ describe("AuthDebugger", () => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
new Error("No protected resource metadata found"),
);
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
const authUrl = new URL("https://oauth.example.com/authorize");
@ -126,6 +122,10 @@ describe("AuthDebugger", () => {
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
});
afterEach(() => {
jest.restoreAllMocks();
});
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
const mergedProps = {
...defaultProps,
@ -204,19 +204,13 @@ describe("AuthDebugger", () => {
// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
new URL("https://example.com/"),
);
// Then should call auth with the server provider
expect(mockAuth).toHaveBeenCalled();
// Check that updateAuthState was called with the right info message
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
},
oauthStep: "authorization_code",
}),
);
});
@ -322,6 +316,12 @@ describe("AuthDebugger", () => {
});
expect(updateAuthState).toHaveBeenCalledWith({
authServerUrl: null,
authorizationUrl: null,
isInitiatingAuth: false,
resourceMetadata: null,
resourceMetadataError: null,
resource: null,
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
@ -363,7 +363,7 @@ describe("AuthDebugger", () => {
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
new URL("https://example.com/"),
);
});
@ -438,4 +438,174 @@ describe("AuthDebugger", () => {
});
});
});
describe("OAuth State Persistence", () => {
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
const updateAuthState = jest.fn();
// Setup mocks for OAuth flow
mockStartAuthorization.mockResolvedValue({
authorizationUrl: new URL(
"https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug",
),
codeVerifier: "test_verifier",
});
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: { ...defaultAuthState },
});
});
// Click Quick OAuth Flow
await act(async () => {
fireEvent.click(screen.getByText("Quick OAuth Flow"));
});
// Wait for the flow to reach the authorization step
await waitFor(() => {
expect(sessionStorage.setItem).toHaveBeenCalledWith(
SESSION_KEYS.AUTH_DEBUGGER_STATE,
expect.stringContaining('"oauthStep":"authorization_code"'),
);
});
// Verify the stored state includes all the accumulated data
const storedStateCall = (
sessionStorage.setItem as jest.Mock
).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE);
expect(storedStateCall).toBeDefined();
const storedState = JSON.parse(storedStateCall![1] as string);
expect(storedState).toMatchObject({
oauthStep: "authorization_code",
authorizationUrl: expect.stringMatching(
/^https:\/\/oauth\.example\.com\/authorize/,
),
oauthMetadata: expect.objectContaining({
token_endpoint: "https://oauth.example.com/token",
}),
oauthClientInfo: expect.objectContaining({
client_id: "test_client_id",
}),
});
});
});
describe("OAuth Protected Resource Metadata", () => {
it("should successfully fetch and display protected resource metadata", async () => {
const updateAuthState = jest.fn();
const mockResourceMetadata = {
resource: "https://example.com/mcp",
authorization_servers: ["https://custom-auth.example.com"],
bearer_methods_supported: ["header", "body"],
resource_documentation: "https://example.com/mcp/docs",
resource_policy_uri: "https://example.com/mcp/policy",
};
// Mock successful metadata discovery
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
mockResourceMetadata,
);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: { ...defaultAuthState },
});
});
// Click Guided OAuth Flow to start the process
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
// Verify that the flow started with metadata discovery
expect(updateAuthState).toHaveBeenCalledWith({
oauthStep: "metadata_discovery",
authorizationUrl: null,
statusMessage: null,
latestError: null,
});
// Click Continue to trigger metadata discovery
const continueButton = await screen.findByText("Continue");
await act(async () => {
fireEvent.click(continueButton);
});
// Wait for the metadata to be fetched
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com/mcp",
);
});
// Verify the state was updated with the resource metadata
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata: mockResourceMetadata,
authServerUrl: new URL("https://custom-auth.example.com"),
oauthStep: "client_registration",
}),
);
});
});
it("should handle protected resource metadata fetch failure gracefully", async () => {
const updateAuthState = jest.fn();
const mockError = new Error("Failed to fetch resource metadata");
// Mock failed metadata discovery
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
// But OAuth metadata should still work with the original URL
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: { ...defaultAuthState },
});
});
// Click Guided OAuth Flow
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
// Click Continue to trigger metadata discovery
const continueButton = await screen.findByText("Continue");
await act(async () => {
fireEvent.click(continueButton);
});
// Wait for the metadata fetch to fail
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com/mcp",
);
});
// Verify the flow continues despite the error
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadataError: mockError,
// Should use the original server URL as fallback
authServerUrl: new URL("https://example.com/"),
oauthStep: "client_registration",
}),
);
});
// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
});
});
});

View file

@ -0,0 +1,256 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm Array Fields", () => {
const renderSimpleArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test array field",
items: {
type: "string" as const,
description: "Array item",
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
const renderComplexArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test complex array field",
items: {
type: "object" as const,
properties: {
nested: { type: "object" as const },
},
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Simple Array Rendering", () => {
it("should render form fields for simple array items", () => {
renderSimpleArrayForm({ value: ["item1", "item2"] });
// Should show array description
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Items: Array item")).toBeDefined();
// Should show input fields for each item
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "item1");
expect(inputs[1]).toHaveProperty("value", "item2");
// Should show remove buttons
const removeButtons = screen.getAllByText("Remove");
expect(removeButtons).toHaveLength(2);
// Should show add button
expect(screen.getByText("Add Item")).toBeDefined();
});
it("should add new items when Add Item button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
});
it("should remove items when Remove button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
const removeButtons = screen.getAllByText("Remove");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["item2"]);
});
it("should update item values when input changes", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "updated item" } });
expect(onChange).toHaveBeenCalledWith(["updated item"]);
});
it("should handle empty arrays", () => {
renderSimpleArrayForm({ value: [] });
// Should show description and add button but no items
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Complex Array Fallback", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
// Should not show form-specific array controls
expect(screen.queryByText("Add Item")).toBeNull();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Array Type Detection", () => {
it("should detect string arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema, value: ["test"] });
// Should render form fields, not JSON editor
expect(screen.getByRole("textbox")).not.toHaveProperty(
"type",
"textarea",
);
});
it("should detect number arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "number" as const },
};
renderSimpleArrayForm({ schema, value: [1, 2] });
// Should render form fields (number inputs)
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
});
it("should detect boolean arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
renderSimpleArrayForm({ schema, value: [true, false] });
// Should render form fields (checkboxes)
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("should detect simple object arrays as simple", () => {
const schema = {
type: "array" as const,
items: {
type: "object" as const,
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
},
};
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
// Should render form fields for simple objects
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.getByText("Remove")).toBeDefined();
});
});
describe("Array with Different Item Types", () => {
it("should handle integer array items", () => {
const schema = {
type: "array" as const,
items: { type: "integer" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "1");
expect(inputs[1]).toHaveProperty("value", "2");
// Test adding new integer item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
});
it("should handle boolean array items", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [true, false], onChange });
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveProperty("checked", true);
expect(checkboxes[1]).toHaveProperty("checked", false);
// Test adding new boolean item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([true, false, false]);
});
});
describe("Array Item Descriptions", () => {
it("should show item description when available", () => {
const schema = {
type: "array" as const,
description: "List of names",
items: {
type: "string" as const,
description: "Person name",
},
};
renderSimpleArrayForm({ schema });
expect(screen.getByText("List of names")).toBeDefined();
expect(screen.getByText("Items: Person name")).toBeDefined();
});
it("should use item description in add button title", () => {
const schema = {
type: "array" as const,
items: {
type: "string" as const,
description: "Email address",
},
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new Email address");
});
it("should use default title when no item description", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new item");
});
});
});

View file

@ -0,0 +1,226 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import HistoryAndNotifications from "../HistoryAndNotifications";
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
// Mock JsonView component
jest.mock("../JsonView", () => {
return function JsonView({ data }: { data: string }) {
return <div data-testid="json-view">{data}</div>;
};
});
describe("HistoryAndNotifications", () => {
const mockRequestHistory = [
{
request: JSON.stringify({ method: "test/method1", params: {} }),
response: JSON.stringify({ result: "success" }),
},
{
request: JSON.stringify({ method: "test/method2", params: {} }),
response: JSON.stringify({ result: "success" }),
},
];
const mockNotifications: ServerNotification[] = [
{
method: "notifications/message",
params: {
level: "info" as const,
message: "First notification",
},
},
{
method: "notifications/progress",
params: {
progressToken: "test-token",
progress: 50,
message: "Second notification",
},
},
];
it("renders history and notifications sections", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={mockNotifications}
/>,
);
expect(screen.getByText("History")).toBeTruthy();
expect(screen.getByText("Server Notifications")).toBeTruthy();
});
it("displays request history items with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. test/method2")).toBeTruthy();
expect(screen.getByText("1. test/method1")).toBeTruthy();
});
it("displays server notifications with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
expect(screen.getByText("1. notifications/message")).toBeTruthy();
});
it("expands and collapses request items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
const firstRequestHeader = screen.getByText("2. test/method2");
// Initially collapsed - should show ▶ arrows (there are multiple)
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Request:")).toBeNull();
// Click to expand
fireEvent.click(firstRequestHeader);
// Should now be expanded - one ▼ and one ▶
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("expands and collapses notification items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
const firstNotificationHeader = screen.getByText(
"2. notifications/progress",
);
// Initially collapsed
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Details:")).toBeNull();
// Click to expand
fireEvent.click(firstNotificationHeader);
// Should now be expanded
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new notifications are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Find and expand the older notification (should be "1. notifications/message")
const olderNotificationHeader = screen.getByText(
"1. notifications/message",
);
fireEvent.click(olderNotificationHeader);
// Verify it's expanded
expect(screen.getByText("Details:")).toBeTruthy();
// Add a new notification at the beginning (simulating real behavior)
const newNotifications: ServerNotification[] = [
{
method: "notifications/resources/updated",
params: { uri: "file://test.txt" },
},
...mockNotifications,
];
// Re-render with new notifications
rerender(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={newNotifications}
/>,
);
// The original notification should still be expanded
// It's now numbered as "2. notifications/message" due to the new item
expect(screen.getByText("3. notifications/progress")).toBeTruthy();
expect(screen.getByText("2. notifications/message")).toBeTruthy();
expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy();
// The originally expanded notification should still show its details
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new requests are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Find and expand the older request (should be "1. test/method1")
const olderRequestHeader = screen.getByText("1. test/method1");
fireEvent.click(olderRequestHeader);
// Verify it's expanded
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
// Add a new request at the beginning
const newRequestHistory = [
{
request: JSON.stringify({ method: "test/new_method", params: {} }),
response: JSON.stringify({ result: "new success" }),
},
...mockRequestHistory,
];
// Re-render with new request history
rerender(
<HistoryAndNotifications
requestHistory={newRequestHistory}
serverNotifications={[]}
/>,
);
// The original request should still be expanded
// It's now numbered as "2. test/method1" due to the new item
expect(screen.getByText("3. test/method2")).toBeTruthy();
expect(screen.getByText("2. test/method1")).toBeTruthy();
expect(screen.getByText("1. test/new_method")).toBeTruthy();
// The originally expanded request should still show its details
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("displays empty state messages when no data is available", () => {
render(
<HistoryAndNotifications requestHistory={[]} serverNotifications={[]} />,
);
expect(screen.getByText("No history yet")).toBeTruthy();
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
});

View file

@ -0,0 +1,308 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Tabs } from "@/components/ui/tabs";
import ResourcesTab from "../ResourcesTab";
import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js";
// Mock the hooks and components
jest.mock("@/lib/hooks/useCompletionState", () => ({
useCompletionState: () => ({
completions: {},
clearCompletions: jest.fn(),
requestCompletions: jest.fn(),
}),
}));
jest.mock("../JsonView", () => {
return function MockJsonView({ data }: { data: string }) {
return <div data-testid="json-view">{data}</div>;
};
});
jest.mock("@/components/ui/combobox", () => ({
Combobox: ({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}) => (
<input
id={id}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
data-testid={`combobox-${id}`}
/>
),
}));
jest.mock("@/components/ui/label", () => ({
Label: ({
htmlFor,
children,
}: {
htmlFor: string;
children: React.ReactNode;
}) => (
<label htmlFor={htmlFor} data-testid={`label-${htmlFor}`}>
{children}
</label>
),
}));
jest.mock("@/components/ui/button", () => ({
Button: ({
children,
onClick,
disabled,
...props
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
[key: string]: unknown;
}) => (
<button
onClick={onClick}
disabled={disabled}
data-testid="button"
{...props}
>
{children}
</button>
),
}));
describe("ResourcesTab - Template Query Parameters", () => {
const mockListResources = jest.fn();
const mockClearResources = jest.fn();
const mockListResourceTemplates = jest.fn();
const mockClearResourceTemplates = jest.fn();
const mockReadResource = jest.fn();
const mockSetSelectedResource = jest.fn();
const mockHandleCompletion = jest.fn();
const mockSubscribeToResource = jest.fn();
const mockUnsubscribeFromResource = jest.fn();
const mockResourceTemplate: ResourceTemplate = {
name: "Users API",
uriTemplate: "test://users{?name,limit,offset}",
description: "Fetch users with optional filtering and pagination",
};
const mockResource: Resource = {
uri: "test://users?name=john&limit=10&offset=0",
name: "Users Resource",
description: "Expanded users resource",
};
const defaultProps = {
resources: [],
resourceTemplates: [mockResourceTemplate],
listResources: mockListResources,
clearResources: mockClearResources,
listResourceTemplates: mockListResourceTemplates,
clearResourceTemplates: mockClearResourceTemplates,
readResource: mockReadResource,
selectedResource: null,
setSelectedResource: mockSetSelectedResource,
handleCompletion: mockHandleCompletion,
completionsSupported: true,
resourceContent: "",
nextCursor: undefined,
nextTemplateCursor: undefined,
error: null,
resourceSubscriptionsSupported: false,
resourceSubscriptions: new Set<string>(),
subscribeToResource: mockSubscribeToResource,
unsubscribeFromResource: mockUnsubscribeFromResource,
};
const renderResourcesTab = (props = {}) =>
render(
<Tabs defaultValue="resources">
<ResourcesTab {...defaultProps} {...props} />
</Tabs>,
);
beforeEach(() => {
jest.clearAllMocks();
});
it("should parse and display template variables from URI template", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Check that input fields are rendered for each template variable
expect(screen.getByTestId("combobox-name")).toBeInTheDocument();
expect(screen.getByTestId("combobox-limit")).toBeInTheDocument();
expect(screen.getByTestId("combobox-offset")).toBeInTheDocument();
});
it("should display template description when template is selected", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
expect(
screen.getByText("Fetch users with optional filtering and pagination"),
).toBeInTheDocument();
});
it("should handle template value changes", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Find and fill template value inputs
const nameInput = screen.getByTestId("combobox-name");
const limitInput = screen.getByTestId("combobox-limit");
const offsetInput = screen.getByTestId("combobox-offset");
fireEvent.change(nameInput, { target: { value: "john" } });
fireEvent.change(limitInput, { target: { value: "10" } });
fireEvent.change(offsetInput, { target: { value: "0" } });
expect(nameInput).toHaveValue("john");
expect(limitInput).toHaveValue("10");
expect(offsetInput).toHaveValue("0");
});
it("should expand template and read resource when Read Resource button is clicked", async () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Fill template values
const nameInput = screen.getByTestId("combobox-name");
const limitInput = screen.getByTestId("combobox-limit");
const offsetInput = screen.getByTestId("combobox-offset");
fireEvent.change(nameInput, { target: { value: "john" } });
fireEvent.change(limitInput, { target: { value: "10" } });
fireEvent.change(offsetInput, { target: { value: "0" } });
// Click Read Resource button
const readResourceButton = screen.getByText("Read Resource");
expect(readResourceButton).not.toBeDisabled();
fireEvent.click(readResourceButton);
// Verify that readResource was called with the expanded URI
expect(mockReadResource).toHaveBeenCalledWith(
"test://users?name=john&limit=10&offset=0",
);
// Verify that setSelectedResource was called with the expanded resource
expect(mockSetSelectedResource).toHaveBeenCalledWith({
uri: "test://users?name=john&limit=10&offset=0",
name: "test://users?name=john&limit=10&offset=0",
});
});
it("should disable Read Resource button when no template values are provided", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Read Resource button should be disabled when no values are provided
const readResourceButton = screen.getByText("Read Resource");
expect(readResourceButton).toBeDisabled();
});
it("should handle partial template values correctly", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Fill only some template values
const nameInput = screen.getByTestId("combobox-name");
fireEvent.change(nameInput, { target: { value: "john" } });
// Read Resource button should be enabled with partial values
const readResourceButton = screen.getByText("Read Resource");
expect(readResourceButton).not.toBeDisabled();
fireEvent.click(readResourceButton);
// Should expand with only the provided values
expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john");
});
it("should handle special characters in template values", () => {
renderResourcesTab();
// Click on the resource template to select it
fireEvent.click(screen.getByText("Users API"));
// Fill template values with special characters
const nameInput = screen.getByTestId("combobox-name");
fireEvent.change(nameInput, { target: { value: "john doe" } });
fireEvent.click(screen.getByText("Read Resource"));
// Should properly encode special characters
expect(mockReadResource).toHaveBeenCalledWith(
"test://users?name=john%20doe",
);
});
it("should clear template values when switching between templates", () => {
const anotherTemplate: ResourceTemplate = {
name: "Posts API",
uriTemplate: "test://posts{?author,category}",
description: "Fetch posts by author and category",
};
renderResourcesTab({
resourceTemplates: [mockResourceTemplate, anotherTemplate],
});
// Select first template and fill values
fireEvent.click(screen.getByText("Users API"));
const nameInput = screen.getByTestId("combobox-name");
fireEvent.change(nameInput, { target: { value: "john" } });
// Switch to second template
fireEvent.click(screen.getByText("Posts API"));
// Should show new template fields and clear previous values
expect(screen.getByTestId("combobox-author")).toBeInTheDocument();
expect(screen.getByTestId("combobox-category")).toBeInTheDocument();
expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument();
});
it("should display resource content when a resource is selected", () => {
const resourceContent = '{"users": [{"id": 1, "name": "John"}]}';
renderResourcesTab({
selectedResource: mockResource,
resourceContent: resourceContent,
});
expect(screen.getByTestId("json-view")).toBeInTheDocument();
expect(screen.getByText(resourceContent)).toBeInTheDocument();
});
it("should show alert when no resource or template is selected", () => {
renderResourcesTab();
expect(
screen.getByText(
"Select a resource or template from the list to view its contents",
),
).toBeInTheDocument();
});
});

View file

@ -519,6 +519,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
is_session_item: false,
},
}),
);
@ -544,6 +545,7 @@ describe("Sidebar Environment Variables", () => {
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "http://localhost:8080",
is_session_item: false,
},
}),
);
@ -569,6 +571,7 @@ describe("Sidebar Environment Variables", () => {
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 10000,
is_session_item: false,
},
}),
);
@ -591,6 +594,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 0,
is_session_item: false,
},
}),
);
@ -637,6 +641,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
is_session_item: false,
},
}),
);

View file

@ -1,11 +1,17 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
describe("ToolsTab", () => {
beforeEach(() => {
// Clear the output schema cache before each test
cacheToolOutputSchemas([]);
});
const mockTools: Tool[] = [
{
name: "tool1",
@ -105,6 +111,27 @@ describe("ToolsTab", () => {
});
});
it("should allow typing negative numbers", async () => {
renderToolsTab({
selectedTool: mockTools[0],
});
const input = screen.getByRole("spinbutton") as HTMLInputElement;
// Complete the negative number
fireEvent.change(input, { target: { value: "-42" } });
expect(input.value).toBe("-42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;
@ -141,4 +168,217 @@ describe("ToolsTab", () => {
expect(submitButton.getAttribute("disabled")).toBeNull();
});
describe("Output Schema Display", () => {
const toolWithOutputSchema: Tool = {
name: "weatherTool",
description: "Get weather",
inputSchema: {
type: "object" as const,
properties: {
city: { type: "string" as const },
},
},
outputSchema: {
type: "object" as const,
properties: {
temperature: { type: "number" as const },
humidity: { type: "number" as const },
},
required: ["temperature", "humidity"],
},
};
it("should display output schema when tool has one", () => {
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
});
expect(screen.getByText("Output Schema:")).toBeInTheDocument();
// Check for expand/collapse button
expect(
screen.getByRole("button", { name: /expand/i }),
).toBeInTheDocument();
});
it("should not display output schema section when tool doesn't have one", () => {
renderToolsTab({
selectedTool: mockTools[0], // Tool without outputSchema
});
expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
});
it("should toggle output schema expansion", () => {
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
});
const toggleButton = screen.getByRole("button", { name: /expand/i });
// Click to expand
fireEvent.click(toggleButton);
expect(
screen.getByRole("button", { name: /collapse/i }),
).toBeInTheDocument();
// Click to collapse
fireEvent.click(toggleButton);
expect(
screen.getByRole("button", { name: /expand/i }),
).toBeInTheDocument();
});
});
describe("Structured Output Results", () => {
const toolWithOutputSchema: Tool = {
name: "weatherTool",
description: "Get weather",
inputSchema: {
type: "object" as const,
properties: {},
},
outputSchema: {
type: "object" as const,
properties: {
temperature: { type: "number" as const },
},
required: ["temperature"],
},
};
it("should display structured content when present", () => {
// Cache the tool's output schema so hasOutputSchema returns true
cacheToolOutputSchemas([toolWithOutputSchema]);
const structuredResult = {
content: [],
structuredContent: {
temperature: 25,
},
};
renderToolsTab({
selectedTool: toolWithOutputSchema,
toolResult: structuredResult,
});
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
expect(
screen.getByText(/Valid according to output schema/),
).toBeInTheDocument();
});
it("should show validation error for invalid structured content", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const invalidResult = {
content: [],
structuredContent: {
temperature: "25", // String instead of number
},
};
renderToolsTab({
selectedTool: toolWithOutputSchema,
toolResult: invalidResult,
});
expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
});
it("should show error when tool with output schema doesn't return structured content", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const resultWithoutStructured = {
content: [{ type: "text", text: "some result" }],
// No structuredContent
};
renderToolsTab({
selectedTool: toolWithOutputSchema,
toolResult: resultWithoutStructured,
});
expect(
screen.getByText(
/Tool has an output schema but did not return structured content/,
),
).toBeInTheDocument();
});
it("should show unstructured content title when both structured and unstructured exist", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const resultWithBoth = {
content: [{ type: "text", text: '{"temperature": 25}' }],
structuredContent: { temperature: 25 },
};
renderToolsTab({
selectedTool: toolWithOutputSchema,
toolResult: resultWithBoth,
});
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
});
it("should not show unstructured content title when only unstructured exists", () => {
const resultWithUnstructuredOnly = {
content: [{ type: "text", text: "some result" }],
};
renderToolsTab({
selectedTool: mockTools[0], // Tool without output schema
toolResult: resultWithUnstructuredOnly,
});
expect(
screen.queryByText("Unstructured Content:"),
).not.toBeInTheDocument();
});
it("should show compatibility check when tool has output schema", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const compatibleResult = {
content: [{ type: "text", text: '{"temperature": 25}' }],
structuredContent: { temperature: 25 },
};
renderToolsTab({
selectedTool: toolWithOutputSchema,
toolResult: compatibleResult,
});
// Should show compatibility result
expect(
screen.getByText(
/matches structured content|not a single text block|not valid JSON|does not match/,
),
).toBeInTheDocument();
});
it("should not show compatibility check when tool has no output schema", () => {
const resultWithBoth = {
content: [{ type: "text", text: '{"data": "value"}' }],
structuredContent: { different: "data" },
};
renderToolsTab({
selectedTool: mockTools[0], // Tool without output schema
toolResult: resultWithBoth,
});
// Should not show any compatibility messages
expect(
screen.queryByText(
/matches structured content|not a single text block|not valid JSON|does not match/,
),
).not.toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show more