Merge branch 'main' into resizable-left-sidebar

This commit is contained in:
Marc Abramowitz 2025-06-03 22:29:15 -07:00
commit 13d31596af
15 changed files with 1066 additions and 168 deletions

View file

@ -25,6 +25,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.5",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",

View file

@ -20,6 +20,7 @@ import {
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState } from "./lib/auth-types";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import React, {
Suspense,
useCallback,
@ -481,6 +482,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>) => {
@ -642,7 +645,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}
@ -693,7 +696,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>
@ -793,6 +796,8 @@ const App = () => {
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
// Clear cached output schemas
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
clearError("tools");
@ -845,7 +850,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">
@ -870,7 +875,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

@ -29,7 +29,9 @@ const HistoryAndNotifications = ({
<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 italic">No history yet</p>
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
</p>
) : (
<ul className="space-y-3">
{requestHistory
@ -38,7 +40,7 @@ const HistoryAndNotifications = ({
.map((request, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary p-2 rounded"
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
@ -93,7 +95,9 @@ const HistoryAndNotifications = ({
<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 italic">No notifications yet</p>
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet
</p>
) : (
<ul className="space-y-3">
{serverNotifications
@ -102,7 +106,7 @@ const HistoryAndNotifications = ({
.map((notification, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary p-2 rounded"
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"

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

@ -110,8 +110,8 @@ const PromptsTab = ({
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

@ -173,8 +173,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,7 +234,7 @@ 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

View file

@ -215,7 +215,7 @@ const Sidebar = ({
return (
<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-gray-800">
<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}
@ -646,7 +646,7 @@ const Sidebar = ({
}
})()}`}
/>
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-600 dark:text-gray-400">
{(() => {
switch (connectionStatus) {
case "connected":

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

@ -9,15 +9,15 @@ import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } 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,6 +41,7 @@ const ToolsTab = ({
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
useEffect(() => {
const params = Object.entries(
@ -52,75 +53,6 @@ const ToolsTab = ({
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 +77,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,7 +86,7 @@ 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(
@ -164,7 +96,7 @@ const ToolsTab = ({
<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}
</Label>
@ -262,6 +194,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 +253,10 @@ const ToolsTab = ({
</>
)}
</Button>
{toolResult && renderToolResult()}
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
/>
</div>
) : (
<Alert>

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",
@ -141,4 +147,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();
});
});
});

View file

@ -93,8 +93,8 @@ h1 {
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--border: 217.2 24% 24%;
--input: 217.2 24% 24%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;

View file

@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
}));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn(),
SSEClientTransport: jest.fn((url) => ({
toString: () => url,
})),
SseError: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: jest.fn((url) => ({
toString: () => url,
})),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
}));
@ -163,4 +171,92 @@ describe("useConnection", () => {
result.current.makeRequest(mockRequest, mockSchema),
).rejects.toThrow("MCP client not connected");
});
describe("URL Port Handling", () => {
const SSEClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/sse.js",
).SSEClientTransport;
const StreamableHTTPClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/streamableHttp.js",
).StreamableHTTPClientTransport;
beforeEach(() => {
jest.clearAllMocks();
});
test("preserves HTTPS port number when connecting", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com:8443/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
);
});
test("preserves HTTP port number when connecting", async () => {
const props = {
...defaultProps,
sseUrl: "http://localhost:3000/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=http%3A%2F%2Flocalhost%3A3000%2Fapi",
);
});
test("uses default port for HTTPS when not specified", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi");
expect(call.toString()).not.toContain("%3A443");
});
test("preserves port number in streamable-http transport", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com:8443/api",
transportType: "streamable-http" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = StreamableHTTPClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
);
});
});
});

View file

@ -45,6 +45,7 @@ import {
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
interface UseConnectionOptions {
transportType: "stdio" | "sse" | "streamable-http";
@ -83,6 +84,9 @@ export function useConnection({
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [clientTransport, setClientTransport] = useState<Transport | null>(
null,
);
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
@ -304,7 +308,14 @@ export function useConnection({
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`;
// Add custom header name as a special request header to let the server know which header to pass through
if (authHeaderName.toLowerCase() !== "authorization") {
headers[authHeaderName] = token;
headers["x-custom-auth-header"] = authHeaderName;
} else {
headers[authHeaderName] = `Bearer ${token}`;
}
}
// Create appropriate transport
@ -377,14 +388,6 @@ export function useConnection({
transportType,
);
const clientTransport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
if (onNotification) {
[
CancelledNotificationSchema,
@ -414,7 +417,20 @@ export function useConnection({
let capabilities;
try {
await client.connect(clientTransport);
const transport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(
mcpProxyServerUrl as URL,
transportOptions,
);
await client.connect(transport as Transport);
setClientTransport(transport);
capabilities = client.getServerCapabilities();
const initializeRequest = {
@ -468,10 +484,15 @@ export function useConnection({
};
const disconnect = async () => {
if (transportType === "streamable-http")
await (
clientTransport as StreamableHTTPClientTransport
).terminateSession();
await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl);
authProvider.clear();
setMcpClient(null);
setClientTransport(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);

View file

@ -1,5 +1,13 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import {
generateDefaultValue,
formatFieldLabel,
cacheToolOutputSchemas,
getToolOutputValidator,
validateToolOutput,
hasOutputSchema,
} from "../schemaUtils";
import type { JsonSchemaType } from "../jsonUtils";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
describe("generateDefaultValue", () => {
test("generates default string", () => {
@ -137,3 +145,220 @@ describe("formatFieldLabel", () => {
expect(formatFieldLabel("")).toBe("");
});
});
describe("Output Schema Validation", () => {
const mockTools: Tool[] = [
{
name: "weatherTool",
description: "Get weather information",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
outputSchema: {
type: "object",
properties: {
temperature: { type: "number" },
humidity: { type: "number" },
},
required: ["temperature", "humidity"],
},
},
{
name: "noOutputSchema",
description: "Tool without output schema",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "complexOutputSchema",
description: "Tool with complex output schema",
inputSchema: {
type: "object",
properties: {},
},
outputSchema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
},
tags: {
type: "array",
items: { type: "string" },
},
},
required: ["user"],
},
},
];
beforeEach(() => {
// Clear cache before each test
cacheToolOutputSchemas([]);
});
describe("cacheToolOutputSchemas", () => {
test("caches validators for tools with output schemas", () => {
cacheToolOutputSchemas(mockTools);
expect(hasOutputSchema("weatherTool")).toBe(true);
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
expect(hasOutputSchema("noOutputSchema")).toBe(false);
});
test("clears existing cache when called", () => {
cacheToolOutputSchemas(mockTools);
expect(hasOutputSchema("weatherTool")).toBe(true);
cacheToolOutputSchemas([]);
expect(hasOutputSchema("weatherTool")).toBe(false);
});
test("handles invalid output schemas gracefully", () => {
const toolsWithInvalidSchema: Tool[] = [
{
name: "invalidSchemaTool",
description: "Tool with invalid schema",
inputSchema: { type: "object", properties: {} },
outputSchema: {
// @ts-expect-error Testing with invalid type
type: "invalid-type",
},
},
];
// Should not throw
expect(() =>
cacheToolOutputSchemas(toolsWithInvalidSchema),
).not.toThrow();
expect(hasOutputSchema("invalidSchemaTool")).toBe(false);
});
});
describe("validateToolOutput", () => {
beforeEach(() => {
cacheToolOutputSchemas(mockTools);
});
test("validates correct structured content", () => {
const result = validateToolOutput("weatherTool", {
temperature: 25.5,
humidity: 60,
});
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
test("rejects invalid structured content", () => {
const result = validateToolOutput("weatherTool", {
temperature: "25.5", // Should be number
humidity: 60,
});
expect(result.isValid).toBe(false);
expect(result.error).toContain("should be number");
});
test("rejects missing required fields", () => {
const result = validateToolOutput("weatherTool", {
temperature: 25.5,
// Missing humidity
});
expect(result.isValid).toBe(false);
expect(result.error).toContain("required");
});
test("validates complex nested structures", () => {
const validResult = validateToolOutput("complexOutputSchema", {
user: {
name: "John",
age: 30,
},
tags: ["tag1", "tag2"],
});
expect(validResult.isValid).toBe(true);
const invalidResult = validateToolOutput("complexOutputSchema", {
user: {
// Missing required 'name'
age: 30,
},
});
expect(invalidResult.isValid).toBe(false);
});
test("returns valid for tools without validators", () => {
const result = validateToolOutput("nonExistentTool", { any: "data" });
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
test("validates additional properties restriction", () => {
const result = validateToolOutput("weatherTool", {
temperature: 25.5,
humidity: 60,
extraField: "should not be here",
});
// This depends on whether additionalProperties is set to false in the schema
// If it is, this should fail
expect(result.isValid).toBe(true); // By default, additional properties are allowed
});
});
describe("getToolOutputValidator", () => {
beforeEach(() => {
cacheToolOutputSchemas(mockTools);
});
test("returns validator for cached tool", () => {
const validator = getToolOutputValidator("weatherTool");
expect(validator).toBeDefined();
expect(typeof validator).toBe("function");
});
test("returns undefined for tool without output schema", () => {
const validator = getToolOutputValidator("noOutputSchema");
expect(validator).toBeUndefined();
});
test("returns undefined for non-existent tool", () => {
const validator = getToolOutputValidator("nonExistentTool");
expect(validator).toBeUndefined();
});
});
describe("hasOutputSchema", () => {
beforeEach(() => {
cacheToolOutputSchemas(mockTools);
});
test("returns true for tools with output schemas", () => {
expect(hasOutputSchema("weatherTool")).toBe(true);
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
});
test("returns false for tools without output schemas", () => {
expect(hasOutputSchema("noOutputSchema")).toBe(false);
});
test("returns false for non-existent tools", () => {
expect(hasOutputSchema("nonExistentTool")).toBe(false);
});
});
});

View file

@ -1,4 +1,82 @@
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
const ajv = new Ajv();
// Cache for compiled validators
const toolOutputValidators = new Map<string, ValidateFunction>();
/**
* Compiles and caches output schema validators for a list of tools
* Following the same pattern as SDK's Client.cacheToolOutputSchemas
* @param tools Array of tools that may have output schemas
*/
export function cacheToolOutputSchemas(tools: Tool[]): void {
toolOutputValidators.clear();
for (const tool of tools) {
if (tool.outputSchema) {
try {
const validator = ajv.compile(tool.outputSchema);
toolOutputValidators.set(tool.name, validator);
} catch (error) {
console.warn(
`Failed to compile output schema for tool ${tool.name}:`,
error,
);
}
}
}
}
/**
* Gets the cached output schema validator for a tool
* Following the same pattern as SDK's Client.getToolOutputValidator
* @param toolName Name of the tool
* @returns The compiled validator function, or undefined if not found
*/
export function getToolOutputValidator(
toolName: string,
): ValidateFunction | undefined {
return toolOutputValidators.get(toolName);
}
/**
* Validates structured content against a tool's output schema
* Returns validation result with detailed error messages
* @param toolName Name of the tool
* @param structuredContent The structured content to validate
* @returns An object with isValid boolean and optional error message
*/
export function validateToolOutput(
toolName: string,
structuredContent: unknown,
): { isValid: boolean; error?: string } {
const validator = getToolOutputValidator(toolName);
if (!validator) {
return { isValid: true }; // No validator means no schema to validate against
}
const isValid = validator(structuredContent);
if (!isValid) {
return {
isValid: false,
error: ajv.errorsText(validator.errors),
};
}
return { isValid: true };
}
/**
* Checks if a tool has an output schema
* @param toolName Name of the tool
* @returns true if the tool has an output schema
*/
export function hasOutputSchema(toolName: string): boolean {
return toolOutputValidators.has(toolName);
}
/**
* Generates a default value based on a JSON schema type