Compare commits
54 Commits
ani/fix-np
...
ashwin/tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f29b32cc | ||
|
|
586c497740 | ||
|
|
c2c2043d05 | ||
|
|
579dd42c46 | ||
|
|
1ab1aba528 | ||
|
|
1797fbfba8 | ||
|
|
8f4013c42c | ||
|
|
1abb7ca59c | ||
|
|
dfb36e1792 | ||
|
|
ffc29663c8 | ||
|
|
53226dd391 | ||
|
|
dc49d46baa | ||
|
|
ef32a8f289 | ||
|
|
54e9957ec5 | ||
|
|
7edde5001b | ||
|
|
14bda1f030 | ||
|
|
1f4d35f8a3 | ||
|
|
eb70539958 | ||
|
|
7878e1764a | ||
|
|
26f0cb3c8b | ||
|
|
8f40e052c1 | ||
|
|
024f06c1b7 | ||
|
|
1ddc63b330 | ||
|
|
27bd503240 | ||
|
|
b39c96de7c | ||
|
|
d857e1462b | ||
|
|
2eae823d65 | ||
|
|
79ba164fda | ||
|
|
2f513df6c1 | ||
|
|
ce68085e77 | ||
|
|
e96b3be159 | ||
|
|
034699524a | ||
|
|
fdc521646f | ||
|
|
bd6586bbad | ||
|
|
c340e5f1ed | ||
|
|
cc1ae05f9d | ||
|
|
9ea77a729c | ||
|
|
8c7b0c360e | ||
|
|
576ff0043a | ||
|
|
18dc4d0a99 | ||
|
|
ed5017d73e | ||
|
|
f04b161411 | ||
|
|
bd6a63603a | ||
|
|
b845444fab | ||
|
|
ace94c4d37 | ||
|
|
50640bc9cc | ||
|
|
cc17ba8d56 | ||
|
|
764f02310d | ||
|
|
945299181d | ||
|
|
79344bd495 | ||
|
|
295ccac27e | ||
|
|
f3f424f21e | ||
|
|
6b6eeb8dcd | ||
|
|
3110cf9343 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,5 +2,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
server/build
|
server/build
|
||||||
client/dist
|
client/dist
|
||||||
|
client/coverage
|
||||||
client/tsconfig.app.tsbuildinfo
|
client/tsconfig.app.tsbuildinfo
|
||||||
client/tsconfig.node.tsbuildinfo
|
client/tsconfig.node.tsbuildinfo
|
||||||
|
|||||||
@@ -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
|
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
|
### From this repository
|
||||||
|
|
||||||
If you're working on the inspector itself:
|
If you're working on the inspector itself:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function main() {
|
|||||||
[
|
[
|
||||||
inspectorServerPath,
|
inspectorServerPath,
|
||||||
...(command ? [`--env`, command] : []),
|
...(command ? [`--env`, command] : []),
|
||||||
...(mcpServerArgs ? ["--args", mcpServerArgs.join(" ")] : []),
|
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: { ...process.env, PORT: SERVER_PORT },
|
env: { ...process.env, PORT: SERVER_PORT },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.2.4",
|
"version": "0.3.0",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -18,10 +18,12 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
@@ -40,20 +42,25 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/serve-handler": "^6.1.4",
|
"@types/serve-handler": "^6.1.4",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
import { useConnection } from "./lib/hooks/useConnection";
|
||||||
import {
|
import {
|
||||||
ClientNotification,
|
|
||||||
ClientRequest,
|
ClientRequest,
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
CompatibilityCallToolResultSchema,
|
CompatibilityCallToolResultSchema,
|
||||||
CreateMessageRequestSchema,
|
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
EmptyResultSchema,
|
EmptyResultSchema,
|
||||||
GetPromptResultSchema,
|
GetPromptResultSchema,
|
||||||
ListPromptsResultSchema,
|
ListPromptsResultSchema,
|
||||||
ListResourcesResultSchema,
|
ListResourcesResultSchema,
|
||||||
ListResourceTemplatesResultSchema,
|
ListResourceTemplatesResultSchema,
|
||||||
ListRootsRequestSchema,
|
|
||||||
ListToolsResultSchema,
|
|
||||||
ProgressNotificationSchema,
|
|
||||||
ReadResourceResultSchema,
|
ReadResourceResultSchema,
|
||||||
Request,
|
ListToolsResultSchema,
|
||||||
Resource,
|
Resource,
|
||||||
ResourceTemplate,
|
ResourceTemplate,
|
||||||
Result,
|
|
||||||
Root,
|
Root,
|
||||||
ServerNotification,
|
ServerNotification,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { StdErrNotification } from "./lib/notificationTypes";
|
||||||
Notification,
|
|
||||||
StdErrNotification,
|
|
||||||
StdErrNotificationSchema,
|
|
||||||
} from "./lib/notificationTypes";
|
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -42,8 +32,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { toast } from "react-toastify";
|
import { z } from "zod";
|
||||||
import { ZodType } from "zod";
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
import HistoryAndNotifications from "./components/History";
|
import HistoryAndNotifications from "./components/History";
|
||||||
@@ -55,16 +44,11 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
|||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import ToolsTab from "./components/ToolsTab";
|
import ToolsTab from "./components/ToolsTab";
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
||||||
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [connectionStatus, setConnectionStatus] = useState<
|
|
||||||
"disconnected" | "connected" | "error"
|
|
||||||
>("disconnected");
|
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
ResourceTemplate[]
|
ResourceTemplate[]
|
||||||
@@ -89,10 +73,6 @@ const App = () => {
|
|||||||
|
|
||||||
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
|
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
|
||||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
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 [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||||
StdErrNotification[]
|
StdErrNotification[]
|
||||||
@@ -143,49 +123,64 @@ const App = () => {
|
|||||||
>();
|
>();
|
||||||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||||
const progressTokenRef = useRef(0);
|
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(
|
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
setIsDragging(true);
|
const {
|
||||||
dragStartY.current = e.clientY;
|
connectionStatus,
|
||||||
dragStartHeight.current = historyPaneHeight;
|
serverCapabilities,
|
||||||
document.body.style.userSelect = "none";
|
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],
|
onStdErrNotification: (notification) => {
|
||||||
);
|
setStdErrNotifications((prev) => [
|
||||||
|
...prev,
|
||||||
const handleDragMove = useCallback(
|
notification as StdErrNotification,
|
||||||
(e: MouseEvent) => {
|
]);
|
||||||
if (!isDragging) return;
|
|
||||||
const deltaY = dragStartY.current - e.clientY;
|
|
||||||
const newHeight = Math.max(
|
|
||||||
100,
|
|
||||||
Math.min(800, dragStartHeight.current + deltaY),
|
|
||||||
);
|
|
||||||
setHistoryPaneHeight(newHeight);
|
|
||||||
},
|
},
|
||||||
[isDragging],
|
onPendingRequest: (request, resolve, reject) => {
|
||||||
);
|
setPendingSampleRequests((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: nextRequestId.current++, request, resolve, reject },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
getRoots: () => rootsRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
setIsDragging(false);
|
request: ClientRequest,
|
||||||
document.body.style.userSelect = "";
|
schema: T,
|
||||||
}, []);
|
tabKey?: keyof typeof errors,
|
||||||
|
) => {
|
||||||
useEffect(() => {
|
try {
|
||||||
if (isDragging) {
|
const response = await makeConnectionRequest(request, schema);
|
||||||
window.addEventListener("mousemove", handleDragMove);
|
if (tabKey !== undefined) {
|
||||||
window.addEventListener("mouseup", handleDragEnd);
|
clearError(tabKey);
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", handleDragMove);
|
|
||||||
window.removeEventListener("mouseup", handleDragEnd);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [isDragging, handleDragMove, handleDragEnd]);
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
const errorString = (e as Error).message ?? String(e);
|
||||||
|
if (tabKey !== undefined) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tabKey]: errorString,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastCommand", command);
|
localStorage.setItem("lastCommand", command);
|
||||||
@@ -216,79 +211,16 @@ const App = () => {
|
|||||||
rootsRef.current = roots;
|
rootsRef.current = roots;
|
||||||
}, [roots]);
|
}, [roots]);
|
||||||
|
|
||||||
const pushHistory = (request: object, response?: object) => {
|
useEffect(() => {
|
||||||
setRequestHistory((prev) => [
|
if (!window.location.hash) {
|
||||||
...prev,
|
window.location.hash = "resources";
|
||||||
{
|
}
|
||||||
request: JSON.stringify(request),
|
}, []);
|
||||||
response: response !== undefined ? JSON.stringify(response) : undefined,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearError = (tabKey: keyof typeof errors) => {
|
const clearError = (tabKey: keyof typeof errors) => {
|
||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
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 listResources = async () => {
|
||||||
const response = await makeRequest(
|
const response = await makeRequest(
|
||||||
{
|
{
|
||||||
@@ -391,79 +323,6 @@ const App = () => {
|
|||||||
await sendNotification({ method: "notifications/roots/list_changed" });
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -484,17 +343,42 @@ const App = () => {
|
|||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{mcpClient ? (
|
{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">
|
<TabsList className="mb-4 p-0">
|
||||||
<TabsTrigger value="resources">
|
<TabsTrigger
|
||||||
|
value="resources"
|
||||||
|
disabled={!serverCapabilities?.resources}
|
||||||
|
>
|
||||||
<Files className="w-4 h-4 mr-2" />
|
<Files className="w-4 h-4 mr-2" />
|
||||||
Resources
|
Resources
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="prompts">
|
<TabsTrigger
|
||||||
|
value="prompts"
|
||||||
|
disabled={!serverCapabilities?.prompts}
|
||||||
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
Prompts
|
Prompts
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tools">
|
<TabsTrigger
|
||||||
|
value="tools"
|
||||||
|
disabled={!serverCapabilities?.tools}
|
||||||
|
>
|
||||||
<Hammer className="w-4 h-4 mr-2" />
|
<Hammer className="w-4 h-4 mr-2" />
|
||||||
Tools
|
Tools
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -518,6 +402,16 @@ const App = () => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
{!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
|
<ResourcesTab
|
||||||
resources={resources}
|
resources={resources}
|
||||||
resourceTemplates={resourceTemplates}
|
resourceTemplates={resourceTemplates}
|
||||||
@@ -525,10 +419,18 @@ const App = () => {
|
|||||||
clearError("resources");
|
clearError("resources");
|
||||||
listResources();
|
listResources();
|
||||||
}}
|
}}
|
||||||
|
clearResources={() => {
|
||||||
|
setResources([]);
|
||||||
|
setNextResourceCursor(undefined);
|
||||||
|
}}
|
||||||
listResourceTemplates={() => {
|
listResourceTemplates={() => {
|
||||||
clearError("resources");
|
clearError("resources");
|
||||||
listResourceTemplates();
|
listResourceTemplates();
|
||||||
}}
|
}}
|
||||||
|
clearResourceTemplates={() => {
|
||||||
|
setResourceTemplates([]);
|
||||||
|
setNextResourceTemplateCursor(undefined);
|
||||||
|
}}
|
||||||
readResource={(uri) => {
|
readResource={(uri) => {
|
||||||
clearError("resources");
|
clearError("resources");
|
||||||
readResource(uri);
|
readResource(uri);
|
||||||
@@ -549,6 +451,10 @@ const App = () => {
|
|||||||
clearError("prompts");
|
clearError("prompts");
|
||||||
listPrompts();
|
listPrompts();
|
||||||
}}
|
}}
|
||||||
|
clearPrompts={() => {
|
||||||
|
setPrompts([]);
|
||||||
|
setNextPromptCursor(undefined);
|
||||||
|
}}
|
||||||
getPrompt={(name, args) => {
|
getPrompt={(name, args) => {
|
||||||
clearError("prompts");
|
clearError("prompts");
|
||||||
getPrompt(name, args);
|
getPrompt(name, args);
|
||||||
@@ -568,6 +474,10 @@ const App = () => {
|
|||||||
clearError("tools");
|
clearError("tools");
|
||||||
listTools();
|
listTools();
|
||||||
}}
|
}}
|
||||||
|
clearTools={() => {
|
||||||
|
setTools([]);
|
||||||
|
setNextToolCursor(undefined);
|
||||||
|
}}
|
||||||
callTool={(name, params) => {
|
callTool={(name, params) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
callTool(name, params);
|
callTool(name, params);
|
||||||
@@ -603,6 +513,8 @@ const App = () => {
|
|||||||
setRoots={setRoots}
|
setRoots={setRoots}
|
||||||
onRootsChange={handleRootsChange}
|
onRootsChange={handleRootsChange}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
220
client/src/__tests__/App.test.tsx
Normal file
220
client/src/__tests__/App.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
client/src/__tests__/components/History.test.tsx
Normal file
61
client/src/__tests__/components/History.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
client/src/__tests__/components/ListPane.test.tsx
Normal file
84
client/src/__tests__/components/ListPane.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
client/src/__tests__/components/PingTab.test.tsx
Normal file
51
client/src/__tests__/components/PingTab.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
98
client/src/__tests__/components/PromptsTab.test.tsx
Normal file
98
client/src/__tests__/components/PromptsTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
135
client/src/__tests__/components/ResourcesTab.test.tsx
Normal file
135
client/src/__tests__/components/ResourcesTab.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
client/src/__tests__/components/RootsTab.test.tsx
Normal file
80
client/src/__tests__/components/RootsTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
client/src/__tests__/components/SamplingTab.test.tsx
Normal file
91
client/src/__tests__/components/SamplingTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
client/src/__tests__/setup/setup.ts
Normal file
34
client/src/__tests__/setup/setup.ts
Normal 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 = "";
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
|
|||||||
type ListPaneProps<T> = {
|
type ListPaneProps<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
listItems: () => void;
|
listItems: () => void;
|
||||||
|
clearItems: () => void;
|
||||||
setSelectedItem: (item: T) => void;
|
setSelectedItem: (item: T) => void;
|
||||||
renderItem: (item: T) => React.ReactNode;
|
renderItem: (item: T) => React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,6 +14,7 @@ type ListPaneProps<T> = {
|
|||||||
const ListPane = <T extends object>({
|
const ListPane = <T extends object>({
|
||||||
items,
|
items,
|
||||||
listItems,
|
listItems,
|
||||||
|
clearItems,
|
||||||
setSelectedItem,
|
setSelectedItem,
|
||||||
renderItem,
|
renderItem,
|
||||||
title,
|
title,
|
||||||
@@ -32,6 +34,14 @@ const ListPane = <T extends object>({
|
|||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</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">
|
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type Prompt = {
|
|||||||
const PromptsTab = ({
|
const PromptsTab = ({
|
||||||
prompts,
|
prompts,
|
||||||
listPrompts,
|
listPrompts,
|
||||||
|
clearPrompts,
|
||||||
getPrompt,
|
getPrompt,
|
||||||
selectedPrompt,
|
selectedPrompt,
|
||||||
setSelectedPrompt,
|
setSelectedPrompt,
|
||||||
@@ -31,6 +32,7 @@ const PromptsTab = ({
|
|||||||
}: {
|
}: {
|
||||||
prompts: Prompt[];
|
prompts: Prompt[];
|
||||||
listPrompts: () => void;
|
listPrompts: () => void;
|
||||||
|
clearPrompts: () => void;
|
||||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
selectedPrompt: Prompt | null;
|
selectedPrompt: Prompt | null;
|
||||||
setSelectedPrompt: (prompt: Prompt) => void;
|
setSelectedPrompt: (prompt: Prompt) => void;
|
||||||
@@ -55,6 +57,7 @@ const PromptsTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={prompts}
|
items={prompts}
|
||||||
listItems={listPrompts}
|
listItems={listPrompts}
|
||||||
|
clearItems={clearPrompts}
|
||||||
setSelectedItem={(prompt) => {
|
setSelectedItem={(prompt) => {
|
||||||
setSelectedPrompt(prompt);
|
setSelectedPrompt(prompt);
|
||||||
setPromptArgs({});
|
setPromptArgs({});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const ResourcesTab = ({
|
|||||||
resources,
|
resources,
|
||||||
resourceTemplates,
|
resourceTemplates,
|
||||||
listResources,
|
listResources,
|
||||||
|
clearResources,
|
||||||
listResourceTemplates,
|
listResourceTemplates,
|
||||||
|
clearResourceTemplates,
|
||||||
readResource,
|
readResource,
|
||||||
selectedResource,
|
selectedResource,
|
||||||
setSelectedResource,
|
setSelectedResource,
|
||||||
@@ -28,7 +30,9 @@ const ResourcesTab = ({
|
|||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
resourceTemplates: ResourceTemplate[];
|
resourceTemplates: ResourceTemplate[];
|
||||||
listResources: () => void;
|
listResources: () => void;
|
||||||
|
clearResources: () => void;
|
||||||
listResourceTemplates: () => void;
|
listResourceTemplates: () => void;
|
||||||
|
clearResourceTemplates: () => void;
|
||||||
readResource: (uri: string) => void;
|
readResource: (uri: string) => void;
|
||||||
selectedResource: Resource | null;
|
selectedResource: Resource | null;
|
||||||
setSelectedResource: (resource: Resource | null) => void;
|
setSelectedResource: (resource: Resource | null) => void;
|
||||||
@@ -68,6 +72,7 @@ const ResourcesTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={resources}
|
items={resources}
|
||||||
listItems={listResources}
|
listItems={listResources}
|
||||||
|
clearItems={clearResources}
|
||||||
setSelectedItem={(resource) => {
|
setSelectedItem={(resource) => {
|
||||||
setSelectedResource(resource);
|
setSelectedResource(resource);
|
||||||
readResource(resource.uri);
|
readResource(resource.uri);
|
||||||
@@ -90,6 +95,7 @@ const ResourcesTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={resourceTemplates}
|
items={resourceTemplates}
|
||||||
listItems={listResourceTemplates}
|
listItems={listResourceTemplates}
|
||||||
|
clearItems={clearResourceTemplates}
|
||||||
setSelectedItem={(template) => {
|
setSelectedItem={(template) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setSelectedResource(null);
|
setSelectedResource(null);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const RootsTab = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeRoot(index)}
|
onClick={() => removeRoot(index)}
|
||||||
|
aria-label="Remove root"
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +93,7 @@ const Sidebar = ({
|
|||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -94,6 +102,7 @@ const Sidebar = ({
|
|||||||
placeholder="Arguments (space-separated)"
|
placeholder="Arguments (space-separated)"
|
||||||
value={args}
|
value={args}
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
onChange={(e) => setArgs(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -104,6 +113,7 @@ const Sidebar = ({
|
|||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={sseUrl}
|
value={sseUrl}
|
||||||
onChange={(e) => setSseUrl(e.target.value)}
|
onChange={(e) => setSseUrl(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -135,6 +145,7 @@ const Sidebar = ({
|
|||||||
newEnv[e.target.value] = value;
|
newEnv[e.target.value] = value;
|
||||||
setEnv(newEnv);
|
setEnv(newEnv);
|
||||||
}}
|
}}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
@@ -144,6 +155,7 @@ const Sidebar = ({
|
|||||||
newEnv[key] = e.target.value;
|
newEnv[key] = e.target.value;
|
||||||
setEnv(newEnv);
|
setEnv(newEnv);
|
||||||
}}
|
}}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -220,14 +232,14 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Select
|
||||||
value={theme}
|
value={theme}
|
||||||
onValueChange={(value: string) =>
|
onValueChange={(value: string) =>
|
||||||
setTheme(value as "system" | "light" | "dark")
|
setTheme(value as "system" | "light" | "dark")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[120px]" id="theme-select">
|
<SelectTrigger className="w-[100px]" id="theme-select">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -236,6 +248,39 @@ const Sidebar = ({
|
|||||||
<SelectItem value="dark">Dark</SelectItem>
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AlertCircle, Send } from "lucide-react";
|
import { AlertCircle, Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
|
|
||||||
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
@@ -18,6 +18,7 @@ import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
listTools,
|
listTools,
|
||||||
|
clearTools,
|
||||||
callTool,
|
callTool,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
@@ -27,14 +28,18 @@ const ToolsTab = ({
|
|||||||
}: {
|
}: {
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
callTool: (name: string, params: Record<string, unknown>) => void;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: CompatibilityCallToolResult | null;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
nextCursor: ListToolsResult["nextCursor"];
|
nextCursor: ListToolsResult["nextCursor"];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
setParams({});
|
||||||
|
}, [selectedTool]);
|
||||||
|
|
||||||
const renderToolResult = () => {
|
const renderToolResult = () => {
|
||||||
if (!toolResult) return null;
|
if (!toolResult) return null;
|
||||||
@@ -95,7 +100,7 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
<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)}
|
{JSON.stringify(toolResult.toolResult, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</>
|
</>
|
||||||
@@ -108,6 +113,10 @@ const ToolsTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={tools}
|
items={tools}
|
||||||
listItems={listTools}
|
listItems={listTools}
|
||||||
|
clearItems={() => {
|
||||||
|
clearTools();
|
||||||
|
setSelectedTool(null);
|
||||||
|
}}
|
||||||
setSelectedItem={setSelectedTool}
|
setSelectedItem={setSelectedTool}
|
||||||
renderItem={(tool) => (
|
renderItem={(tool) => (
|
||||||
<>
|
<>
|
||||||
@@ -165,6 +174,30 @@ const ToolsTab = ({
|
|||||||
}
|
}
|
||||||
className="mt-1"
|
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
|
<Input
|
||||||
// @ts-expect-error value type is currently unknown
|
// @ts-expect-error value type is currently unknown
|
||||||
|
|||||||
199
client/src/lib/hooks/useConnection.ts
Normal file
199
client/src/lib/hooks/useConnection.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
client/src/lib/hooks/useDraggablePane.ts
Normal file
53
client/src/lib/hooks/useDraggablePane.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from "react-toastify";
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
|||||||
19
client/vitest.config.ts
Normal file
19
client/vitest.config.ts
Normal 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
1300
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.2.4",
|
"version": "0.3.0",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -33,14 +33,16 @@
|
|||||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-client": "0.2.4",
|
"@modelcontextprotocol/inspector-client": "0.3.0",
|
||||||
"@modelcontextprotocol/inspector-server": "0.2.4",
|
"@modelcontextprotocol/inspector-server": "0.3.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"shell-quote": "^1.8.2",
|
||||||
"spawn-rx": "^5.1.0",
|
"spawn-rx": "^5.1.0",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"prettier": "3.3.3"
|
"prettier": "3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.2.4",
|
"version": "0.3.0",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import EventSource from "eventsource";
|
import EventSource from "eventsource";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
import { parse as shellParseArgs } from "shell-quote";
|
||||||
|
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +39,7 @@ const createTransport = async (query: express.Request["query"]) => {
|
|||||||
|
|
||||||
if (transportType === "stdio") {
|
if (transportType === "stdio") {
|
||||||
const command = query.command as string;
|
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 env = query.env ? JSON.parse(query.env as string) : undefined;
|
||||||
|
|
||||||
const { cmd, args } = findActualExecutable(command, origArgs);
|
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||||
|
|||||||
Reference in New Issue
Block a user