Compare commits

..

54 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
Ashwin Bhat
576ff0043a bump version to 0.3.0 2024-12-05 08:01:57 -08:00
Ashwin Bhat
18dc4d0a99 Merge pull request #100 from evalstate/fix/tool-timeout
Allow setting the timeout with the "timeout" URL parameter
2024-12-05 07:59:55 -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
=
f04b161411 Allow setting timeout via "timeout" URL parameter 2024-12-05 08:11:35 +00:00
Ashwin Bhat
bd6a63603a Merge pull request #102 from modelcontextprotocol/ashwin/sdk
update sdk to 1.0.3
2024-12-04 14:20:09 -08:00
Ashwin Bhat
b845444fab update sdk to 1.0.3 2024-12-04 09:56:04 -08:00
Justin Spahr-Summers
ace94c4d37 Merge pull request #95 from modelcontextprotocol/ashwin-ant-patch-1
link to MCP docs site in readme
2024-12-02 06:56:07 -06:00
Ashwin Bhat
50640bc9cc Merge pull request #98 from heuperman/add-button-to-clear-items
Add button to clear loaded items
2024-12-01 11:08:32 -05:00
Kees Heuperman
cc17ba8d56 feat: Add button to clear loaded items
Add a button to the ListPane component that clears loaded items. This
will allow the user to clear and reload resources, resource templates,
prompts or tools when they expect the available items to have changed.
2024-12-01 09:50:53 +01:00
Ashwin Bhat
764f02310d link to MCP docs site in readme 2024-11-29 16:26:56 -05:00
Ashwin Bhat
945299181d bump version to 0.2.7 2024-11-29 08:44:12 -05:00
David Soria Parra
79344bd495 Merge pull request #91 from modelcontextprotocol/ashwin/spawnfix
fix arg passing
2024-11-29 11:34:25 +00:00
Ashwin Bhat
295ccac27e fix arg passing 2024-11-27 19:17:47 -05:00
Ashwin Bhat
f3f424f21e bump version 2024-11-27 17:29:02 -05:00
Ashwin Bhat
6b6eeb8dcd bump version to 0.2.5 2024-11-27 17:24:19 -05:00
Ashwin Bhat
3110cf9343 Merge pull request #88 from modelcontextprotocol/ani/fix-npx
Enable using 'npx' as your command on Windows
2024-11-27 16:03:37 -05:00
28 changed files with 2746 additions and 330 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,6 +26,8 @@ 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 help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### From this repository
If you're working on the inspector itself:

View File

@@ -49,7 +49,7 @@ async function main() {
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? ["--args", mcpServerArgs.join(" ")] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: { ...process.env, PORT: SERVER_PORT },

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.2.4",
"version": "0.3.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -18,10 +18,12 @@
"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.1",
"@modelcontextprotocol/sdk": "^1.0.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
@@ -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,16 +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 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[]
@@ -89,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[]
@@ -143,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);
@@ -216,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");
}, DEFAULT_REQUEST_TIMEOUT_MSEC);
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(
{
@@ -391,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
@@ -484,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>
@@ -518,91 +402,119 @@ const App = () => {
</TabsList>
<div className="w-full">
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
clearError("resources");
listResources();
}}
listResourceTemplates={() => {
clearError("resources");
listResourceTemplates();
}}
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();
}}
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();
}}
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

@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
type ListPaneProps<T> = {
items: T[];
listItems: () => void;
clearItems: () => void;
setSelectedItem: (item: T) => void;
renderItem: (item: T) => React.ReactNode;
title: string;
@@ -13,6 +14,7 @@ type ListPaneProps<T> = {
const ListPane = <T extends object>({
items,
listItems,
clearItems,
setSelectedItem,
renderItem,
title,
@@ -32,6 +34,14 @@ const ListPane = <T extends object>({
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{items.map((item, index) => (
<div

View File

@@ -22,6 +22,7 @@ export type Prompt = {
const PromptsTab = ({
prompts,
listPrompts,
clearPrompts,
getPrompt,
selectedPrompt,
setSelectedPrompt,
@@ -31,6 +32,7 @@ const PromptsTab = ({
}: {
prompts: Prompt[];
listPrompts: () => void;
clearPrompts: () => void;
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
@@ -55,6 +57,7 @@ const PromptsTab = ({
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});

View File

@@ -16,7 +16,9 @@ const ResourcesTab = ({
resources,
resourceTemplates,
listResources,
clearResources,
listResourceTemplates,
clearResourceTemplates,
readResource,
selectedResource,
setSelectedResource,
@@ -28,7 +30,9 @@ const ResourcesTab = ({
resources: Resource[];
resourceTemplates: ResourceTemplate[];
listResources: () => void;
clearResources: () => void;
listResourceTemplates: () => void;
clearResourceTemplates: () => void;
readResource: (uri: string) => void;
selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void;
@@ -68,6 +72,7 @@ const ResourcesTab = ({
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
@@ -90,6 +95,7 @@ const ResourcesTab = ({
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);

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";
@@ -18,6 +18,7 @@ import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"
const ToolsTab = ({
tools,
listTools,
clearTools,
callTool,
selectedTool,
setSelectedTool,
@@ -27,14 +28,18 @@ const ToolsTab = ({
}: {
tools: Tool[];
listTools: () => void;
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;
@@ -50,7 +55,7 @@ const ToolsTab = ({
</pre>
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<pre
<pre
key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
>
@@ -95,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>
</>
@@ -108,6 +113,10 @@ const ToolsTab = ({
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
@@ -165,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}'],
},
})

1300
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.2.4",
"version": "0.3.0",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -33,14 +33,16 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "0.2.4",
"@modelcontextprotocol/inspector-server": "0.2.4",
"@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

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.2.4",
"version": "0.3.0",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.1",
"@modelcontextprotocol/sdk": "^1.0.3",
"cors": "^2.8.5",
"eventsource": "^2.0.2",
"express": "^4.21.0",

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);