Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
node_modules
server/build
client/dist
client/coverage
client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo
11 changes: 9 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
58 changes: 37 additions & 21 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
ResourceTemplate,
Root,
ServerNotification,
Tool
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { useEffect, useRef, useState } from "react";

Expand Down Expand Up @@ -124,10 +124,7 @@ const App = () => {
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);

const {
height: historyPaneHeight,
handleDragStart
} = useDraggablePane(300);
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);

const {
connectionStatus,
Expand All @@ -136,7 +133,7 @@ const App = () => {
requestHistory,
makeRequest: makeConnectionRequest,
sendNotification,
connect: connectMcpServer
connect: connectMcpServer,
} = useConnection({
transportType,
command,
Expand All @@ -145,18 +142,21 @@ const App = () => {
env,
proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => {
setNotifications(prev => [...prev, notification as ServerNotification]);
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
onStdErrNotification: (notification) => {
setStdErrNotifications(prev => [...prev, notification as StdErrNotification]);
setStdErrNotifications((prev) => [
...prev,
notification as StdErrNotification,
]);
},
onPendingRequest: (request, resolve, reject) => {
setPendingSampleRequests(prev => [
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject }
{ id: nextRequestId.current++, request, resolve, reject },
]);
},
getRoots: () => rootsRef.current
getRoots: () => rootsRef.current,
});

const makeRequest = async <T extends z.ZodType>(
Expand Down Expand Up @@ -345,26 +345,40 @@ const App = () => {
{mcpClient ? (
<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"
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" disabled={!serverCapabilities?.resources}>
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger value="prompts" disabled={!serverCapabilities?.prompts}>
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger value="tools" disabled={!serverCapabilities?.tools}>
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
Expand All @@ -388,7 +402,9 @@ const App = () => {
</TabsList>

<div className="w-full">
{!serverCapabilities?.resources && !serverCapabilities?.prompts && !serverCapabilities?.tools ? (
{!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
Expand Down
220 changes: 220 additions & 0 deletions client/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading