Compare commits

...

39 Commits

Author SHA1 Message Date
Ashwin Bhat
97f29b32cc chore: add coverage directory to gitignore 2024-12-20 15:51:43 -08:00
Ashwin Bhat
586c497740 style: run prettier on src directory 2024-12-20 15:51:41 -08:00
Ashwin Bhat
c2c2043d05 test: add test coverage reporting
- Add @vitest/coverage-v8 for test coverage reporting
- Current coverage: 65.59% statements, 83.83% branches, 42.7% functions
- Components like ConsoleTab, ListPane, PingTab, RootsTab, SamplingTab at 100%
- Identified areas needing more coverage: ToolsTab, Sidebar, hooks
2024-12-20 15:51:38 -08:00
Ashwin Bhat
579dd42c46 test: add tests for App component
- Add comprehensive tests for App.tsx
- Fix test setup to handle window.matchMedia and URL params
- Wrap state updates in act()
- Use more specific selectors for finding elements
- Fix sampling tab test to properly simulate pending requests
2024-12-20 15:36:57 -08:00
Ashwin Bhat
1ab1aba528 test: move component tests to __tests__ directory 2024-12-20 15:36:54 -08:00
David Soria Parra
1797fbfba8 Merge pull request #118 from modelcontextprotocol/ashwin/githublink
feat: add GitHub link to sidebar for bug reports and contributions
2024-12-20 10:57:01 +00:00
David Soria Parra
8f4013c42c Merge pull request #117 from modelcontextprotocol/ashwin/state
refactor: extract draggable pane and connection logic into hooks
2024-12-20 10:56:33 +00:00
David Soria Parra
1abb7ca59c Merge pull request #119 from modelcontextprotocol/ashwin/font
feat: use monospace font for all input fields in sidebar
2024-12-20 10:56:05 +00:00
Ashwin Bhat
dfb36e1792 feat: use monospace font for all input fields in sidebar
Makes command, arguments, URL and environment variables easier to read and edit.
2024-12-19 13:18:52 -08:00
Ashwin Bhat
ffc29663c8 fix: reduce theme selector width to 100px to prevent crowding with icon buttons 2024-12-19 13:17:16 -08:00
Ashwin Bhat
53226dd391 feat: add GitHub link to sidebar for bug reports and contributions 2024-12-19 13:06:00 -08:00
Ashwin Bhat
dc49d46baa refactor: extract draggable pane and connection logic into hooks
- Create useDraggablePane hook for history pane drag behavior
- Create useConnection hook for MCP client connection and requests
- Update App.tsx to use both hooks
2024-12-18 12:54:24 -08:00
Ashwin Bhat
ef32a8f289 Merge pull request #116 from modelcontextprotocol/claude/update-readme
feat: add help and debug links to sidebar
2024-12-18 09:14:31 -08:00
David Soria Parra
54e9957ec5 feat: add help and debug links to sidebar 2024-12-18 11:39:24 +00:00
Ashwin Bhat
7edde5001b Merge pull request #112 from modelcontextprotocol/devin/1734088716-fix-object-params
fix: properly handle object type parameters in tools
2024-12-17 14:50:23 -08:00
Devin AI
14bda1f030 chore: revert unrelated changes to TypeScript comments and formatting
- Reverted @ts-expect-error messages back to original text
- Removed unnecessary line breaks in placeholder and type properties
- Kept object parameter handling functionality intact

Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2024-12-13 12:12:33 +00:00
Devin AI
1f4d35f8a3 fix: properly handle object type parameters in tools
- Add special handling for object type parameters
- Parse JSON input for object parameters
- Maintain raw input if JSON parsing fails
- Fixes #110

Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2024-12-13 11:21:35 +00:00
Ashwin Bhat
eb70539958 Merge pull request #105 from devin-open-source/devin/1733551277-capability-negotiation
feat: implement capability negotiation for UI tabs
2024-12-09 03:56:58 -08:00
Jeffrey Ling
7878e1764a address comment 2024-12-09 04:52:15 -07:00
Jeffrey Ling
26f0cb3c8b merge conflict 2024-12-09 04:43:48 -07:00
Jeffrey Ling
8f40e052c1 Merge remote-tracking branch 'origin/main' into devin/1733551277-capability-negotiation 2024-12-09 04:31:45 -07:00
Jeffrey Ling
024f06c1b7 cleanup diffs 2024-12-09 04:29:30 -07:00
Devin AI
1ddc63b330 refactor: remove disabled state from Sampling and Roots tabs 2024-12-09 11:07:42 +00:00
Devin AI
27bd503240 fix: remove duplicate ServerCapabilities type declarations 2024-12-09 10:37:56 +00:00
Devin AI
b39c96de7c refactor: revert tab files to main and restore tab disabling 2024-12-09 10:36:56 +00:00
Devin AI
d857e1462b refactor: simplify capability handling and remove context provider
- Remove redundant useEffect for capability checking
- Remove CapabilityContext provider pattern
- Set default tab to first supported capability
- Add fallback UI for unsupported capabilities
- Delete unused contexts.ts file
2024-12-09 10:11:03 +00:00
Ashwin Bhat
2eae823d65 Merge pull request #107 from JensWallgren/tool-result-legacy-dark-fix
Add dark mode support for the legacy Tool Result display
2024-12-09 02:03:34 -08:00
David Soria Parra
79ba164fda Merge pull request #108 from 8enmann/ben/persist
feat: improve request history and tab persistence
2024-12-09 09:27:19 +00:00
Ben Mann
2f513df6c1 feat: improve request history and tab persistence
- Add failed requests to history with error messages for better debugging
- Persist selected tab in URL hash and restore on page load
- Fix formatting of timeout parameter parsing

🤖 Generated with Claude CLI.

Co-Authored-By: Claude <noreply@anthropic.com>
2024-12-08 21:15:34 +02:00
Jens Wallgren
ce68085e77 Add dark mode support for the legacy Tool Result display 2024-12-08 14:14:40 +01:00
devin-ai-integration[bot]
e96b3be159 feat: implement capability negotiation for UI tabs
- Add CapabilityContext to manage server capabilities
- Disable tabs when server doesn't support feature
- Show error message in tab content when capability missing
- Implements #85
2024-12-07 06:15:21 +00:00
Ashwin Bhat
034699524a Merge pull request #104 from devin-open-source/fix-shell-quote
Fix shell argument parsing issue: #96
2024-12-06 17:07:50 -08:00
Jeffrey Ling
fdc521646f no need to prettier format everything right now 2024-12-06 12:48:48 -07:00
devin-ai-integration[bot]
bd6586bbad style: apply prettier formatting 2024-12-06 19:43:48 +00:00
devin-ai-integration[bot]
c340e5f1ed chore: move shell-quote to main dependencies 2024-12-06 19:43:11 +00:00
devin-ai-integration[bot]
cc1ae05f9d fix: use shell-quote for proper argument parsing 2024-12-06 19:34:03 +00:00
devin-ai-integration[bot]
9ea77a729c chore: add shell-quote package and types 2024-12-06 19:33:08 +00:00
Ashwin Bhat
8c7b0c360e Merge pull request #103 from evalstate/fix/tool-tab-parameters
Tool Tab - Parameter Handling Fixes.
2024-12-05 17:51:05 -08:00
evalstate
ed5017d73e Two fixes to the Tools Tab:
1) Tool Parameters were stale when switching between Tools causing incorrect messages to be sent.
2) Tool List is emptied when "Clear" is selected, so invalid messages can't be sent.
2024-12-05 15:32:55 +00:00
23 changed files with 2704 additions and 328 deletions

1
.gitignore vendored
View File

@@ -2,5 +2,6 @@
node_modules
server/build
client/dist
client/coverage
client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo

View File

@@ -26,7 +26,7 @@ The inspector runs both a client UI (default port 5173) and an MCP proxy server
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector).
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### From this repository

View File

@@ -18,7 +18,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
@@ -40,20 +42,25 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.7.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.20",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
"vite": "^5.4.8",
"vitest": "^2.1.8"
}
}

View File

@@ -1,36 +1,26 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { useConnection } from "./lib/hooks/useConnection";
import {
ClientNotification,
ClientRequest,
CompatibilityCallToolResult,
CompatibilityCallToolResultSchema,
CreateMessageRequestSchema,
CreateMessageResult,
EmptyResultSchema,
GetPromptResultSchema,
ListPromptsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
ProgressNotificationSchema,
ReadResourceResultSchema,
Request,
ListToolsResultSchema,
Resource,
ResourceTemplate,
Result,
Root,
ServerNotification,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Notification,
StdErrNotification,
StdErrNotificationSchema,
} from "./lib/notificationTypes";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -42,8 +32,7 @@ import {
MessageSquare,
} from "lucide-react";
import { toast } from "react-toastify";
import { ZodType } from "zod";
import { z } from "zod";
import "./App.css";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
@@ -55,17 +44,11 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000";
const REQUEST_TIMEOUT = parseInt(params.get("timeout") ?? "") || DEFAULT_REQUEST_TIMEOUT_MSEC;
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
const App = () => {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
@@ -90,10 +73,6 @@ const App = () => {
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
@@ -144,49 +123,64 @@ const App = () => {
>();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [historyPaneHeight, setHistoryPaneHeight] = useState<number>(300);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = historyPaneHeight;
document.body.style.userSelect = "none";
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest: makeConnectionRequest,
sendNotification,
connect: connectMcpServer,
} = useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
[historyPaneHeight],
);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHistoryPaneHeight(newHeight);
onStdErrNotification: (notification) => {
setStdErrNotifications((prev) => [
...prev,
notification as StdErrNotification,
]);
},
[isDragging],
);
onPendingRequest: (request, resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
]);
},
getRoots: () => rootsRef.current,
});
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
try {
const response = await makeConnectionRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
}, [isDragging, handleDragMove, handleDragEnd]);
};
useEffect(() => {
localStorage.setItem("lastCommand", command);
@@ -217,79 +211,16 @@ const App = () => {
rootsRef.current = roots;
}, [roots]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
useEffect(() => {
if (!window.location.hash) {
window.location.hash = "resources";
}
}, []);
const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
const makeRequest = async <T extends ZodType<object>>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, REQUEST_TIMEOUT);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: abortController.signal,
});
} finally {
clearTimeout(timeoutId);
}
pushHistory(request, response);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e: unknown) {
const errorString = (e as Error).message ?? String(e);
if (tabKey === undefined) {
toast.error(errorString);
} else {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
pushHistory(notification);
} catch (e: unknown) {
toast.error((e as Error).message ?? String(e));
throw e;
}
};
const listResources = async () => {
const response = await makeRequest(
{
@@ -392,79 +323,6 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
const connectMcpServer = async () => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: "0.0.1",
},
{
capabilities: {
// Support all client capabilities since we're an inspector tool
sampling: {},
roots: {
listChanged: true,
},
},
},
);
const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
const clientTransport = new SSEClientTransport(backendUrl);
client.setNotificationHandler(
ProgressNotificationSchema,
(notification) => {
setNotifications((prevNotifications) => [
...prevNotifications,
notification,
]);
},
);
client.setNotificationHandler(
StdErrNotificationSchema,
(notification) => {
setStdErrNotifications((prevErrorNotifications) => [
...prevErrorNotifications,
notification,
]);
},
);
await client.connect(clientTransport);
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise<CreateMessageResult>((resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
]);
});
});
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: rootsRef.current };
});
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
return (
<div className="flex h-screen bg-background">
<Sidebar
@@ -485,17 +343,42 @@ const App = () => {
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs defaultValue="resources" className="w-full p-4">
<Tabs
defaultValue={
Object.keys(serverCapabilities ?? {}).includes(
window.location.hash.slice(1),
)
? window.location.hash.slice(1)
: serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping"
}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<TabsList className="mb-4 p-0">
<TabsTrigger value="resources">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger value="prompts">
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger value="tools">
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
@@ -519,107 +402,119 @@ const App = () => {
</TabsList>
<div className="w-full">
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
clearError("resources");
listResources();
}}
clearResources={() => {
setResources([]);
setNextResourceCursor(undefined);
}}
listResourceTemplates={() => {
clearError("resources");
listResourceTemplates();
}}
clearResourceTemplates={() => {
setResourceTemplates([]);
setNextResourceTemplateCursor(undefined);
}}
readResource={(uri) => {
clearError("resources");
readResource(uri);
}}
selectedResource={selectedResource}
setSelectedResource={(resource) => {
clearError("resources");
setSelectedResource(resource);
}}
resourceContent={resourceContent}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
error={errors.resources}
/>
<PromptsTab
prompts={prompts}
listPrompts={() => {
clearError("prompts");
listPrompts();
}}
clearPrompts={() => {
setPrompts([]);
setNextPromptCursor(undefined);
}}
getPrompt={(name, args) => {
clearError("prompts");
getPrompt(name, args);
}}
selectedPrompt={selectedPrompt}
setSelectedPrompt={(prompt) => {
clearError("prompts");
setSelectedPrompt(prompt);
}}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}
/>
<ToolsTab
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
}}
callTool={(name, params) => {
clearError("tools");
callTool(name, params);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
clearError("tools");
setSelectedTool(tool);
setToolResult(null);
}}
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
void makeRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
<SamplingTab
pendingRequests={pendingSampleRequests}
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<RootsTab
roots={roots}
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
</p>
</div>
) : (
<>
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
clearError("resources");
listResources();
}}
clearResources={() => {
setResources([]);
setNextResourceCursor(undefined);
}}
listResourceTemplates={() => {
clearError("resources");
listResourceTemplates();
}}
clearResourceTemplates={() => {
setResourceTemplates([]);
setNextResourceTemplateCursor(undefined);
}}
readResource={(uri) => {
clearError("resources");
readResource(uri);
}}
selectedResource={selectedResource}
setSelectedResource={(resource) => {
clearError("resources");
setSelectedResource(resource);
}}
resourceContent={resourceContent}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
error={errors.resources}
/>
<PromptsTab
prompts={prompts}
listPrompts={() => {
clearError("prompts");
listPrompts();
}}
clearPrompts={() => {
setPrompts([]);
setNextPromptCursor(undefined);
}}
getPrompt={(name, args) => {
clearError("prompts");
getPrompt(name, args);
}}
selectedPrompt={selectedPrompt}
setSelectedPrompt={(prompt) => {
clearError("prompts");
setSelectedPrompt(prompt);
}}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}
/>
<ToolsTab
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
}}
callTool={(name, params) => {
clearError("tools");
callTool(name, params);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
clearError("tools");
setSelectedTool(tool);
setToolResult(null);
}}
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
void makeRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
<SamplingTab
pendingRequests={pendingSampleRequests}
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<RootsTab
roots={roots}
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
</>
)}
</div>
</Tabs>
) : (

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import { useDraggablePane } from "../lib/hooks/useDraggablePane";
// Mock URL params
const mockURLSearchParams = vi.fn();
vi.stubGlobal("URLSearchParams", mockURLSearchParams);
// Mock the hooks
vi.mock("../lib/hooks/useConnection", () => ({
useConnection: vi.fn(),
}));
vi.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: vi.fn(),
}));
// Mock fetch for config
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("App", () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Mock URL params
mockURLSearchParams.mockReturnValue({
get: () => "3000",
});
// Mock fetch response
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
defaultEnvironment: {},
defaultCommand: "test-command",
defaultArgs: "--test",
}),
});
// Mock useConnection hook
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "disconnected",
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
// Mock useDraggablePane hook
const mockUseDraggablePane = useDraggablePane as jest.Mock;
mockUseDraggablePane.mockReturnValue({
height: 300,
handleDragStart: vi.fn(),
});
});
it("renders initial disconnected state", async () => {
await act(async () => {
render(<App />);
});
expect(
screen.getByText("Connect to an MCP server to start inspecting"),
).toBeInTheDocument();
});
it("loads config on mount", async () => {
await act(async () => {
render(<App />);
});
expect(mockFetch).toHaveBeenCalledWith("http://localhost:3000/config");
});
it("shows connected interface when mcpClient is available", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {
resources: true,
prompts: true,
tools: true,
},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
// Use more specific selectors
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
const toolsTab = screen.getByRole("tab", { name: /tools/i });
expect(resourcesTab).toBeInTheDocument();
expect(promptsTab).toBeInTheDocument();
expect(toolsTab).toBeInTheDocument();
});
it("disables tabs based on server capabilities", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {
resources: false,
prompts: true,
tools: false,
},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
// Resources tab should be disabled
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
expect(resourcesTab).toHaveAttribute("disabled");
// Prompts tab should be enabled
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
expect(promptsTab).not.toHaveAttribute("disabled");
// Tools tab should be disabled
const toolsTab = screen.getByRole("tab", { name: /tools/i });
expect(toolsTab).toHaveAttribute("disabled");
});
it("shows notification count in sampling tab", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: { sampling: true },
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
onPendingRequest: (request, resolve, reject) => {
// Simulate a pending request
setPendingSampleRequests((prev) => [
...prev,
{ id: 1, request, resolve, reject },
]);
},
});
await act(async () => {
render(<App />);
});
// Initially no notification count
const samplingTab = screen.getByRole("tab", { name: /sampling/i });
expect(samplingTab.querySelector(".bg-red-500")).not.toBeInTheDocument();
// Simulate a pending request
await act(async () => {
mockUseConnection.mock.calls[0][0].onPendingRequest(
{ method: "test", params: {} },
() => {},
() => {},
);
});
// Should show notification count
expect(samplingTab.querySelector(".bg-red-500")).toBeInTheDocument();
});
it("persists command and args to localStorage", async () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
await act(async () => {
render(<App />);
});
// Simulate command change
await act(async () => {
const commandInput = screen.getByPlaceholderText(/command/i);
fireEvent.change(commandInput, { target: { value: "new-command" } });
});
expect(setItemSpy).toHaveBeenCalledWith("lastCommand", "new-command");
});
it("shows error message when server has no capabilities", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
expect(
screen.getByText(
"The connected server does not support any MCP capabilities",
),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import HistoryAndNotifications from "../../components/History";
describe("HistoryAndNotifications", () => {
const mockHistory = [
{
request: JSON.stringify({ method: "test1" }),
response: JSON.stringify({ result: "output1" }),
},
{
request: JSON.stringify({ method: "test2" }),
response: JSON.stringify({ result: "output2" }),
},
];
it("renders history items", () => {
render(
<HistoryAndNotifications
requestHistory={mockHistory}
serverNotifications={[]}
/>,
);
const items = screen.getAllByText(/test[12]/, { exact: false });
expect(items).toHaveLength(2);
});
it("expands history item when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={mockHistory}
serverNotifications={[]}
/>,
);
const firstItem = screen.getByText(/test1/, { exact: false });
fireEvent.click(firstItem);
expect(screen.getByText("Request:")).toBeInTheDocument();
expect(screen.getByText(/output1/, { exact: false })).toBeInTheDocument();
});
it("renders and expands server notifications", () => {
const notifications = [
{ method: "notify1", params: { data: "test data 1" } },
{ method: "notify2", params: { data: "test data 2" } },
];
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={notifications}
/>,
);
const items = screen.getAllByText(/notify[12]/, { exact: false });
expect(items).toHaveLength(2);
fireEvent.click(items[0]);
expect(screen.getByText("Details:")).toBeInTheDocument();
expect(screen.getByText(/test data/, { exact: false })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ListPane from "../../components/ListPane";
describe("ListPane", () => {
type TestItem = {
id: number;
name: string;
};
const mockItems: TestItem[] = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const defaultProps = {
items: mockItems,
listItems: vi.fn(),
clearItems: vi.fn(),
setSelectedItem: vi.fn(),
renderItem: (item: TestItem) => (
<>
<span className="flex-1">{item.name}</span>
<span className="text-sm text-gray-500">ID: {item.id}</span>
</>
),
title: "Test Items",
buttonText: "List Items",
};
it("renders title and buttons", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Test Items")).toBeInTheDocument();
expect(screen.getByText("List Items")).toBeInTheDocument();
expect(screen.getByText("Clear")).toBeInTheDocument();
});
it("renders list of items using renderItem prop", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("ID: 1")).toBeInTheDocument();
expect(screen.getByText("ID: 2")).toBeInTheDocument();
});
it("calls listItems when List Items button is clicked", () => {
const listItems = vi.fn();
render(<ListPane {...defaultProps} listItems={listItems} />);
fireEvent.click(screen.getByText("List Items"));
expect(listItems).toHaveBeenCalled();
});
it("calls clearItems when Clear button is clicked", () => {
const clearItems = vi.fn();
render(<ListPane {...defaultProps} clearItems={clearItems} />);
fireEvent.click(screen.getByText("Clear"));
expect(clearItems).toHaveBeenCalled();
});
it("calls setSelectedItem when an item is clicked", () => {
const setSelectedItem = vi.fn();
render(<ListPane {...defaultProps} setSelectedItem={setSelectedItem} />);
fireEvent.click(screen.getByText("Item 1"));
expect(setSelectedItem).toHaveBeenCalledWith(mockItems[0]);
});
it("disables Clear button when items array is empty", () => {
render(<ListPane {...defaultProps} items={[]} />);
expect(screen.getByText("Clear")).toBeDisabled();
});
it("disables List Items button when isButtonDisabled is true", () => {
render(<ListPane {...defaultProps} isButtonDisabled={true} />);
expect(screen.getByText("List Items")).toBeDisabled();
});
it("enables List Items button when isButtonDisabled is false", () => {
render(<ListPane {...defaultProps} isButtonDisabled={false} />);
expect(screen.getByText("List Items")).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import PingTab from "../../components/PingTab";
import { Tabs } from "@/components/ui/tabs";
describe("PingTab", () => {
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="ping">{component}</Tabs>);
};
it("renders the MEGA PING button", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass(
"bg-gradient-to-r",
"from-purple-500",
"to-pink-500",
);
});
it("includes rocket and explosion emojis", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
expect(screen.getByText("🚀")).toBeInTheDocument();
expect(screen.getByText("💥")).toBeInTheDocument();
});
it("calls onPingClick when button is clicked", () => {
const onPingClick = vi.fn();
renderWithTabs(<PingTab onPingClick={onPingClick} />);
fireEvent.click(screen.getByRole("button", { name: /mega ping/i }));
expect(onPingClick).toHaveBeenCalledTimes(1);
});
it("has animation classes for visual feedback", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toHaveClass(
"animate-pulse",
"hover:scale-110",
"transition",
);
});
it("has focus styles for accessibility", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toHaveClass("focus:outline-none", "focus:ring-4");
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import PromptsTab from "../../components/PromptsTab";
import type { Prompt } from "../../components/PromptsTab";
import { Tabs } from "@/components/ui/tabs";
describe("PromptsTab", () => {
const mockPrompts: Prompt[] = [
{
name: "test-prompt-1",
description: "Test prompt 1 description",
arguments: [
{ name: "arg1", description: "Argument 1", required: true },
{ name: "arg2", description: "Argument 2" },
],
},
{
name: "test-prompt-2",
description: "Test prompt 2 description",
},
];
const defaultProps = {
prompts: mockPrompts,
listPrompts: vi.fn(),
clearPrompts: vi.fn(),
getPrompt: vi.fn(),
selectedPrompt: null,
setSelectedPrompt: vi.fn(),
promptContent: "",
nextCursor: null,
error: null,
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="prompts">{component}</Tabs>);
};
it("renders list of prompts", () => {
renderWithTabs(<PromptsTab {...defaultProps} />);
expect(screen.getByText("test-prompt-1")).toBeInTheDocument();
expect(screen.getByText("test-prompt-2")).toBeInTheDocument();
});
it("shows prompt details when selected", () => {
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
};
renderWithTabs(<PromptsTab {...props} />);
expect(
screen.getByText("Test prompt 1 description", {
selector: "p.text-sm.text-gray-600",
}),
).toBeInTheDocument();
expect(screen.getByText("arg1")).toBeInTheDocument();
expect(screen.getByText("arg2")).toBeInTheDocument();
});
it("handles argument input", () => {
const getPrompt = vi.fn();
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
getPrompt,
};
renderWithTabs(<PromptsTab {...props} />);
const arg1Input = screen.getByPlaceholderText("Enter arg1");
fireEvent.change(arg1Input, { target: { value: "test value" } });
const getPromptButton = screen.getByText("Get Prompt");
fireEvent.click(getPromptButton);
expect(getPrompt).toHaveBeenCalledWith("test-prompt-1", {
arg1: "test value",
});
});
it("shows error message when error prop is provided", () => {
const props = {
...defaultProps,
error: "Test error message",
};
renderWithTabs(<PromptsTab {...props} />);
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("shows prompt content when provided", () => {
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
promptContent: "Test prompt content",
};
renderWithTabs(<PromptsTab {...props} />);
expect(screen.getByText("Test prompt content")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ResourcesTab from "../../components/ResourcesTab";
import { Tabs } from "@/components/ui/tabs";
import type {
Resource,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/types.js";
describe("ResourcesTab", () => {
const mockResources: Resource[] = [
{ uri: "file:///test1.txt", name: "Test 1" },
{ uri: "file:///test2.txt", name: "Test 2" },
];
const mockTemplates: ResourceTemplate[] = [
{
name: "Template 1",
description: "Test template 1",
uriTemplate: "file:///test/{param1}/{param2}.txt",
},
{
name: "Template 2",
description: "Test template 2",
uriTemplate: "file:///other/{name}.txt",
},
];
const defaultProps = {
resources: mockResources,
resourceTemplates: mockTemplates,
listResources: vi.fn(),
clearResources: vi.fn(),
listResourceTemplates: vi.fn(),
clearResourceTemplates: vi.fn(),
readResource: vi.fn(),
selectedResource: null,
setSelectedResource: vi.fn(),
resourceContent: "",
nextCursor: null,
nextTemplateCursor: null,
error: null,
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="resources">{component}</Tabs>);
};
it("renders resources list", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
expect(screen.getByText("Test 1")).toBeInTheDocument();
expect(screen.getByText("Test 2")).toBeInTheDocument();
});
it("renders templates list", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
expect(screen.getByText("Template 2")).toBeInTheDocument();
});
it("shows resource content when resource is selected", () => {
const props = {
...defaultProps,
selectedResource: mockResources[0],
resourceContent: "Test content",
};
renderWithTabs(<ResourcesTab {...props} />);
expect(screen.getByText("Test content")).toBeInTheDocument();
});
it("shows template form when template is selected", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
fireEvent.click(screen.getByText("Template 1"));
expect(screen.getByText("Test template 1")).toBeInTheDocument();
expect(screen.getByLabelText("param1")).toBeInTheDocument();
expect(screen.getByLabelText("param2")).toBeInTheDocument();
});
it("fills template and reads resource", () => {
const readResource = vi.fn();
const setSelectedResource = vi.fn();
renderWithTabs(
<ResourcesTab
{...defaultProps}
readResource={readResource}
setSelectedResource={setSelectedResource}
/>,
);
// Select template
fireEvent.click(screen.getByText("Template 1"));
// Fill in template parameters
fireEvent.change(screen.getByLabelText("param1"), {
target: { value: "value1" },
});
fireEvent.change(screen.getByLabelText("param2"), {
target: { value: "value2" },
});
// Submit form
fireEvent.click(screen.getByText("Read Resource"));
expect(readResource).toHaveBeenCalledWith("file:///test/value1/value2.txt");
expect(setSelectedResource).toHaveBeenCalledWith(
expect.objectContaining({
uri: "file:///test/value1/value2.txt",
name: "file:///test/value1/value2.txt",
}),
);
});
it("shows error message when error prop is provided", () => {
const props = {
...defaultProps,
error: "Test error message",
};
renderWithTabs(<ResourcesTab {...props} />);
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("refreshes resource content when refresh button is clicked", () => {
const readResource = vi.fn();
const props = {
...defaultProps,
selectedResource: mockResources[0],
readResource,
};
renderWithTabs(<ResourcesTab {...props} />);
fireEvent.click(screen.getByText("Refresh"));
expect(readResource).toHaveBeenCalledWith(mockResources[0].uri);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import RootsTab from "../../components/RootsTab";
import { Tabs } from "@/components/ui/tabs";
import type { Root } from "@modelcontextprotocol/sdk/types.js";
describe("RootsTab", () => {
const mockRoots: Root[] = [
{ uri: "file:///test/path1", name: "test1" },
{ uri: "file:///test/path2", name: "test2" },
];
const defaultProps = {
roots: mockRoots,
setRoots: vi.fn(),
onRootsChange: vi.fn(),
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="roots">{component}</Tabs>);
};
it("renders list of roots", () => {
renderWithTabs(<RootsTab {...defaultProps} />);
expect(screen.getByDisplayValue("file:///test/path1")).toBeInTheDocument();
expect(screen.getByDisplayValue("file:///test/path2")).toBeInTheDocument();
});
it("adds a new root when Add Root button is clicked", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
fireEvent.click(screen.getByText("Add Root"));
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result).toEqual([...mockRoots, { uri: "file://", name: "" }]);
});
it("removes a root when remove button is clicked", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
const removeButtons = screen.getAllByRole("button", {
name: /remove root/i,
});
fireEvent.click(removeButtons[0]);
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result).toEqual([mockRoots[1]]);
});
it("updates root URI when input changes", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
const firstInput = screen.getByDisplayValue("file:///test/path1");
fireEvent.change(firstInput, { target: { value: "file:///new/path" } });
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result[0].uri).toBe("file:///new/path");
expect(result[1]).toEqual(mockRoots[1]);
});
it("calls onRootsChange when Save Changes is clicked", () => {
const onRootsChange = vi.fn();
renderWithTabs(
<RootsTab {...defaultProps} onRootsChange={onRootsChange} />,
);
fireEvent.click(screen.getByText("Save Changes"));
expect(onRootsChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import SamplingTab from "../../components/SamplingTab";
import { Tabs } from "@/components/ui/tabs";
import type { CreateMessageRequest } from "@modelcontextprotocol/sdk/types.js";
describe("SamplingTab", () => {
const mockRequest: CreateMessageRequest = {
model: "test-model",
role: "user",
content: {
type: "text",
text: "Test message",
},
};
const mockPendingRequests = [
{ id: 1, request: mockRequest },
{
id: 2,
request: {
...mockRequest,
content: { type: "text", text: "Another test" },
},
},
];
const defaultProps = {
pendingRequests: mockPendingRequests,
onApprove: vi.fn(),
onReject: vi.fn(),
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="sampling">{component}</Tabs>);
};
it("renders empty state when no requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} pendingRequests={[]} />);
expect(screen.getByText("No pending requests")).toBeInTheDocument();
});
it("renders list of pending requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
expect(screen.getByText(/Test message/)).toBeInTheDocument();
expect(screen.getByText(/Another test/)).toBeInTheDocument();
});
it("shows request details in JSON format", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
const requestJson = screen.getAllByText((content) =>
content.includes('"model": "test-model"'),
);
expect(requestJson).toHaveLength(2);
});
it("calls onApprove with stub response when Approve is clicked", () => {
const onApprove = vi.fn();
renderWithTabs(<SamplingTab {...defaultProps} onApprove={onApprove} />);
const approveButtons = screen.getAllByText("Approve");
fireEvent.click(approveButtons[0]);
expect(onApprove).toHaveBeenCalledWith(1, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "This is a stub response.",
},
});
});
it("calls onReject when Reject is clicked", () => {
const onReject = vi.fn();
renderWithTabs(<SamplingTab {...defaultProps} onReject={onReject} />);
const rejectButtons = screen.getAllByText("Reject");
fireEvent.click(rejectButtons[0]);
expect(onReject).toHaveBeenCalledWith(1);
});
it("shows informational alert about sampling requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
expect(
screen.getByText(/When the server requests LLM sampling/),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import "@testing-library/jest-dom";
import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
// @ts-ignore
expect.extend(matchers);
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock window.location.hash
Object.defineProperty(window, "location", {
writable: true,
value: { hash: "" },
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.location.hash = "";
});

View File

@@ -54,6 +54,7 @@ const RootsTab = ({
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
aria-label="Remove root"
>
<Minus className="h-4 w-4" />
</Button>

View File

@@ -1,5 +1,12 @@
import { useState } from "react";
import { Play, ChevronDown, ChevronRight } from "lucide-react";
import {
Play,
ChevronDown,
ChevronRight,
CircleHelp,
Bug,
Github,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -86,6 +93,7 @@ const Sidebar = ({
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
@@ -94,6 +102,7 @@ const Sidebar = ({
placeholder="Arguments (space-separated)"
value={args}
onChange={(e) => setArgs(e.target.value)}
className="font-mono"
/>
</div>
</>
@@ -104,6 +113,7 @@ const Sidebar = ({
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</div>
)}
@@ -135,6 +145,7 @@ const Sidebar = ({
newEnv[e.target.value] = value;
setEnv(newEnv);
}}
className="font-mono"
/>
<Input
placeholder="Value"
@@ -144,6 +155,7 @@ const Sidebar = ({
newEnv[key] = e.target.value;
setEnv(newEnv);
}}
className="font-mono"
/>
</div>
<Button
@@ -220,14 +232,14 @@ const Sidebar = ({
</div>
</div>
<div className="p-4 border-t">
<div className="flex items-center space-x-2">
<div className="flex items-center justify-between">
<Select
value={theme}
onValueChange={(value: string) =>
setTheme(value as "system" | "light" | "dark")
}
>
<SelectTrigger className="w-[120px]" id="theme-select">
<SelectTrigger className="w-[100px]" id="theme-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -236,6 +248,39 @@ const Sidebar = ({
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<a
href="https://modelcontextprotocol.io/docs/tools/inspector"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" title="Inspector Documentation">
<CircleHelp className="w-4 h-4 text-gray-800" />
</Button>
</a>
<a
href="https://modelcontextprotocol.io/docs/tools/debugging"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" title="Debugging Guide">
<Bug className="w-4 h-4 text-gray-800" />
</Button>
</a>
<a
href="https://github.com/modelcontextprotocol/inspector"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="ghost"
title="Report bugs or contribute on GitHub"
>
<Github className="w-4 h-4 text-gray-800" />
</Button>
</a>
</div>
</div>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import {
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
@@ -31,12 +31,15 @@ const ToolsTab = ({
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => void;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool) => void;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
useEffect(() => {
setParams({});
}, [selectedTool]);
const renderToolResult = () => {
if (!toolResult) return null;
@@ -97,7 +100,7 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult.toolResult, null, 2)}
</pre>
</>
@@ -110,7 +113,10 @@ const ToolsTab = ({
<ListPane
items={tools}
listItems={listTools}
clearItems={clearTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
@@ -168,6 +174,30 @@ const ToolsTab = ({
}
className="mt-1"
/>
) : /* @ts-expect-error value type is currently unknown */
value.type === "object" ? (
<Textarea
id={key}
name={key}
// @ts-expect-error value type is currently unknown
placeholder={value.description}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setParams({
...params,
[key]: parsed,
});
} catch (err) {
// If invalid JSON, store as string - will be validated on submit
setParams({
...params,
[key]: e.target.value,
});
}
}}
className="mt-1"
/>
) : (
<Input
// @ts-expect-error value type is currently unknown

View File

@@ -0,0 +1,199 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
Request,
Result,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { z } from "zod";
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
interface UseConnectionOptions {
transportType: "stdio" | "sse";
command: string;
args: string;
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
requestTimeout?: number;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
getRoots?: () => any[];
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
onNotification,
onStdErrNotification,
onPendingRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, requestTimeout);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: abortController.signal,
});
pushHistory(request, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
return response;
} catch (e: unknown) {
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
pushHistory(notification);
} catch (e: unknown) {
toast.error((e as Error).message ?? String(e));
throw e;
}
};
const connect = async () => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: "0.0.1",
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
},
},
);
const backendUrl = new URL(`${proxyServerUrl}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
const clientTransport = new SSEClientTransport(backendUrl);
if (onNotification) {
client.setNotificationHandler(
ProgressNotificationSchema,
onNotification,
);
}
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
await client.connect(clientTransport);
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
});
}
if (getRoots) {
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: getRoots() };
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
return {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest,
sendNotification,
connect,
};
}

View File

@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useDraggablePane(initialHeight: number) {
const [height, setHeight] = useState(initialHeight);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
document.body.style.userSelect = "none";
},
[height],
);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHeight(newHeight);
},
[isDragging],
);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
}
}, [isDragging, handleDragMove, handleDragEnd]);
return {
height,
isDragging,
handleDragStart,
};
}

View File

@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import App from "./App.tsx";
import "./index.css";

19
client/vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup/setup.ts'],
include: ['src/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
})

1277
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,11 +36,13 @@
"@modelcontextprotocol/inspector-client": "0.3.0",
"@modelcontextprotocol/inspector-server": "0.3.0",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.0",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5",
"prettier": "3.3.3"
}
}

View File

@@ -3,6 +3,7 @@
import cors from "cors";
import EventSource from "eventsource";
import { parseArgs } from "node:util";
import { parse as shellParseArgs } from "shell-quote";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
@@ -38,7 +39,7 @@ const createTransport = async (query: express.Request["query"]) => {
if (transportType === "stdio") {
const command = query.command as string;
const origArgs = (query.args as string).split(/\s+/);
const origArgs = shellParseArgs(query.args as string) as string[];
const env = query.env ? JSON.parse(query.env as string) : undefined;
const { cmd, args } = findActualExecutable(command, origArgs);