Compare commits
93 Commits
0.2.0
...
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 | ||
|
|
2c04fa31e8 | ||
|
|
e700bc713a | ||
|
|
bea86af65b | ||
|
|
68a6130b17 | ||
|
|
853a3b4faf | ||
|
|
6f62066d34 | ||
|
|
c770d217e7 | ||
|
|
98470a12f9 | ||
|
|
a00564fafa | ||
|
|
62546dec58 | ||
|
|
886ac5fc7b | ||
|
|
722df4d798 | ||
|
|
407e304585 | ||
|
|
60578314aa | ||
|
|
3c4cb17d09 | ||
|
|
fbac5b78bc | ||
|
|
f876b1ec0d | ||
|
|
aecfa21d47 | ||
|
|
a3d542c0a3 | ||
|
|
2b79b6ffd4 | ||
|
|
1f28b4474c | ||
|
|
d69d67cb64 | ||
|
|
7792070d81 | ||
|
|
34a2843756 | ||
|
|
2a34770959 | ||
|
|
6b674b0827 | ||
|
|
ca8db1f417 | ||
|
|
eb4456d1e3 | ||
|
|
780b92274d | ||
|
|
b825784b8f | ||
|
|
52c7e98055 | ||
|
|
4862aa7c1d | ||
|
|
561ea91504 | ||
|
|
7c2be8d139 | ||
|
|
97d469911e | ||
|
|
11b891c6ca | ||
|
|
5139e723a4 | ||
|
|
fc5b79c9a6 | ||
|
|
47a87e1884 |
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
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -2,18 +2,49 @@
|
|||||||
|
|
||||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||
It can be run easily from `npx`. For example, in a folder where there's a built JavaScript server at `build/index.js`:
|

|
||||||
|
|
||||||
```
|
## Running the Inspector
|
||||||
|
|
||||||
|
### From an MCP server repository
|
||||||
|
|
||||||
|
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
||||||
|
|
||||||
|
```bash
|
||||||
npx @modelcontextprotocol/inspector build/index.js
|
npx @modelcontextprotocol/inspector build/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also pass arguments along to the server:
|
You can also pass arguments along which will get passed as arguments to your MCP server:
|
||||||
|
|
||||||
```
|
```
|
||||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
Development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Production mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
99
bin/cli.js
99
bin/cli.js
@@ -1,65 +1,88 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { join, dirname } from "path";
|
import { resolve, dirname } from "path";
|
||||||
|
import { spawnPromise } from "spawn-rx";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import concurrently from "concurrently";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
// Get command line arguments
|
// Get command line arguments
|
||||||
const [, , command, ...mcpServerArgs] = process.argv;
|
const [, , command, ...mcpServerArgs] = process.argv;
|
||||||
|
|
||||||
const inspectorServerPath = join(__dirname, "../server/build/index.js");
|
const inspectorServerPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"server",
|
||||||
|
"build",
|
||||||
|
"index.js",
|
||||||
|
);
|
||||||
|
|
||||||
// Path to the client entry point
|
// Path to the client entry point
|
||||||
const inspectorClientPath = join(__dirname, "../client/bin/cli.js");
|
const inspectorClientPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"client",
|
||||||
|
"bin",
|
||||||
|
"cli.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173";
|
||||||
|
const SERVER_PORT = process.env.SERVER_PORT ?? "3000";
|
||||||
|
|
||||||
console.log("Starting MCP inspector...");
|
console.log("Starting MCP inspector...");
|
||||||
|
|
||||||
function escapeArg(arg) {
|
const abort = new AbortController();
|
||||||
if (arg.includes(" ") || arg.includes("'") || arg.includes('"')) {
|
|
||||||
return `\\"${arg.replace(/"/g, '\\\\\\"')}\\"`;
|
|
||||||
}
|
|
||||||
return arg;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverCommand = [
|
let cancelled = false;
|
||||||
`node`,
|
process.on("SIGINT", () => {
|
||||||
inspectorServerPath,
|
cancelled = true;
|
||||||
command ? `--env ${escapeArg(command)}` : "",
|
abort.abort();
|
||||||
mcpServerArgs.length
|
});
|
||||||
? `--args="${mcpServerArgs.map(escapeArg).join(" ")}"`
|
|
||||||
: "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "";
|
const server = spawnPromise(
|
||||||
const SERVER_PORT = process.env.SERVER_PORT ?? "";
|
"node",
|
||||||
|
|
||||||
const { result } = concurrently(
|
|
||||||
[
|
[
|
||||||
{
|
inspectorServerPath,
|
||||||
command: `PORT=${SERVER_PORT} ${serverCommand}`,
|
...(command ? [`--env`, command] : []),
|
||||||
name: "server",
|
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||||
},
|
|
||||||
{
|
|
||||||
command: `PORT=${CLIENT_PORT} node ${inspectorClientPath}`,
|
|
||||||
name: "client",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
prefix: "name",
|
env: { ...process.env, PORT: SERVER_PORT },
|
||||||
killOthers: ["failure", "success"],
|
signal: abort.signal,
|
||||||
restartTries: 3,
|
echoOutput: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const client = spawnPromise("node", [inspectorClientPath], {
|
||||||
|
env: { ...process.env, PORT: CLIENT_PORT },
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure our server/client didn't immediately fail
|
||||||
|
await Promise.any([server, client, delay(2 * 1000)]);
|
||||||
|
const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`;
|
||||||
console.log(
|
console.log(
|
||||||
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT || 5173} 🚀`,
|
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} 🚀`,
|
||||||
);
|
);
|
||||||
|
|
||||||
result.catch((err) => {
|
try {
|
||||||
console.error("An error occurred:", err);
|
await Promise.any([server, client]);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled || process.env.DEBUG) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then((_) => process.exit(0))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.2.0",
|
"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": "0.7.0",
|
"@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";
|
||||||
@@ -54,14 +43,12 @@ import RootsTab from "./components/RootsTab";
|
|||||||
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
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";
|
||||||
import useDarkModeSync from "./lib/useDarkModeSync";
|
|
||||||
|
|
||||||
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 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[]
|
||||||
@@ -83,12 +70,9 @@ const App = () => {
|
|||||||
const [args, setArgs] = useState<string>(() => {
|
const [args, setArgs] = useState<string>(() => {
|
||||||
return localStorage.getItem("lastArgs") || "";
|
return localStorage.getItem("lastArgs") || "";
|
||||||
});
|
});
|
||||||
const [url, setUrl] = 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[]
|
||||||
@@ -139,51 +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);
|
|
||||||
|
|
||||||
useDarkModeSync();
|
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const {
|
||||||
(e: React.MouseEvent) => {
|
connectionStatus,
|
||||||
setIsDragging(true);
|
serverCapabilities,
|
||||||
dragStartY.current = e.clientY;
|
mcpClient,
|
||||||
dragStartHeight.current = historyPaneHeight;
|
requestHistory,
|
||||||
document.body.style.userSelect = "none";
|
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);
|
||||||
@@ -194,7 +191,7 @@ const App = () => {
|
|||||||
}, [args]);
|
}, [args]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("http://localhost:3000/config")
|
fetch(`${PROXY_SERVER_URL}/config`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setEnv(data.defaultEnvironment);
|
setEnv(data.defaultEnvironment);
|
||||||
@@ -214,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(
|
||||||
{
|
{
|
||||||
@@ -389,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("http://localhost:3000/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", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -472,8 +333,8 @@ const App = () => {
|
|||||||
setCommand={setCommand}
|
setCommand={setCommand}
|
||||||
args={args}
|
args={args}
|
||||||
setArgs={setArgs}
|
setArgs={setArgs}
|
||||||
url={url}
|
sseUrl={sseUrl}
|
||||||
setUrl={setUrl}
|
setSseUrl={setSseUrl}
|
||||||
env={env}
|
env={env}
|
||||||
setEnv={setEnv}
|
setEnv={setEnv}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
@@ -482,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>
|
||||||
@@ -516,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}
|
||||||
@@ -523,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);
|
||||||
@@ -547,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);
|
||||||
@@ -566,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);
|
||||||
@@ -601,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,6 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
import { Play, ChevronDown, ChevronRight, Settings } from "lucide-react";
|
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 {
|
||||||
@@ -12,6 +18,9 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
import { StdErrNotification } from "@/lib/notificationTypes";
|
||||||
|
|
||||||
|
import useTheme from "../lib/useTheme";
|
||||||
|
import { version } from "../../../package.json";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
connectionStatus: "disconnected" | "connected" | "error";
|
connectionStatus: "disconnected" | "connected" | "error";
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse";
|
||||||
@@ -20,8 +29,8 @@ interface SidebarProps {
|
|||||||
setCommand: (command: string) => void;
|
setCommand: (command: string) => void;
|
||||||
args: string;
|
args: string;
|
||||||
setArgs: (args: string) => void;
|
setArgs: (args: string) => void;
|
||||||
url: string;
|
sseUrl: string;
|
||||||
setUrl: (url: string) => void;
|
setSseUrl: (url: string) => void;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
setEnv: (env: Record<string, string>) => void;
|
setEnv: (env: Record<string, string>) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -36,20 +45,24 @@ const Sidebar = ({
|
|||||||
setCommand,
|
setCommand,
|
||||||
args,
|
args,
|
||||||
setArgs,
|
setArgs,
|
||||||
url,
|
sseUrl,
|
||||||
setUrl,
|
setSseUrl,
|
||||||
env,
|
env,
|
||||||
setEnv,
|
setEnv,
|
||||||
onConnect,
|
onConnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||||
<div className="flex items-center p-4 border-b border-gray-200">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||||
<Settings className="w-6 h-6 text-gray-500" />
|
<div className="flex items-center">
|
||||||
<h1 className="ml-2 text-lg font-semibold">MCP Inspector</h1>
|
<h1 className="ml-2 text-lg font-semibold">
|
||||||
|
MCP Inspector v{version}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex-1 overflow-auto">
|
<div className="p-4 flex-1 overflow-auto">
|
||||||
@@ -71,6 +84,7 @@ const Sidebar = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transportType === "stdio" ? (
|
{transportType === "stdio" ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -79,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">
|
||||||
@@ -87,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>
|
||||||
</>
|
</>
|
||||||
@@ -95,8 +111,9 @@ const Sidebar = ({
|
|||||||
<label className="text-sm font-medium">URL</label>
|
<label className="text-sm font-medium">URL</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={url}
|
value={sseUrl}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setSseUrl(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -128,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"
|
||||||
@@ -137,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
|
||||||
@@ -212,6 +231,58 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Select
|
||||||
|
value={theme}
|
||||||
|
onValueChange={(value: string) =>
|
||||||
|
setTheme(value as "system" | "light" | "dark")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]" id="theme-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
CallToolResult,
|
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
|
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,20 +28,44 @@ 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;
|
||||||
|
|
||||||
if ("content" in toolResult) {
|
if ("content" in toolResult) {
|
||||||
const structuredResult = toolResult as CallToolResult;
|
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||||
|
if (!parsedResult.success) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||||
|
<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, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||||
|
{parsedResult.error.errors.map((error, idx) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{JSON.stringify(error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const structuredResult = parsedResult.data;
|
||||||
const isError = structuredResult.isError ?? false;
|
const isError = structuredResult.isError ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +76,7 @@ const ToolsTab = ({
|
|||||||
{structuredResult.content.map((item, index) => (
|
{structuredResult.content.map((item, index) => (
|
||||||
<div key={index} className="mb-2">
|
<div key={index} className="mb-2">
|
||||||
{item.type === "text" && (
|
{item.type === "text" && (
|
||||||
<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">
|
||||||
{item.text}
|
{item.text}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -63,7 +88,7 @@ const ToolsTab = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === "resource" && (
|
{item.type === "resource" && (
|
||||||
<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 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
|
||||||
{JSON.stringify(item.resource, null, 2)}
|
{JSON.stringify(item.resource, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -75,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>
|
||||||
</>
|
</>
|
||||||
@@ -88,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) => (
|
||||||
<>
|
<>
|
||||||
@@ -145,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
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
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,29 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
// Listen for changes to the user's preferred color scheme
|
|
||||||
const useDarkModeSync = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
const darkModeMediaQuery = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)",
|
|
||||||
);
|
|
||||||
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
|
||||||
if (e.matches) {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (darkModeMediaQuery.matches) {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
}
|
|
||||||
|
|
||||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDarkModeSync;
|
|
||||||
51
client/src/lib/useTheme.ts
Normal file
51
client/src/lib/useTheme.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const useTheme = (): [Theme, (mode: Theme) => void] => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||||
|
return savedTheme || "system";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const darkModeMediaQuery = window.matchMedia(
|
||||||
|
"(prefers-color-scheme: dark)",
|
||||||
|
);
|
||||||
|
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
||||||
|
if (theme === "system") {
|
||||||
|
updateDocumentTheme(e.matches ? "dark" : "light");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocumentTheme = (newTheme: "light" | "dark") => {
|
||||||
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial theme based on current mode
|
||||||
|
if (theme === "system") {
|
||||||
|
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
|
||||||
|
} else {
|
||||||
|
updateDocumentTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
theme,
|
||||||
|
useCallback((newTheme: Theme) => {
|
||||||
|
setTheme(newTheme);
|
||||||
|
localStorage.setItem("theme", newTheme);
|
||||||
|
if (newTheme !== "system") {
|
||||||
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTheme;
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
import animate from "tailwindcss-animate";
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
@@ -53,5 +54,5 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [animate],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,12 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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}'],
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
mcp-inspector.png
Normal file
BIN
mcp-inspector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
3677
package-lock.json
generated
3677
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.2.0",
|
"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)",
|
||||||
@@ -27,18 +27,22 @@
|
|||||||
"build": "npm run build-server && npm run build-client",
|
"build": "npm run build-server && npm run build-client",
|
||||||
"start-server": "cd server && npm run start",
|
"start-server": "cd server && npm run start",
|
||||||
"start-client": "cd client && npm run preview",
|
"start-client": "cd client && npm run preview",
|
||||||
"start": "./bin/cli.js",
|
"start": "node ./bin/cli.js",
|
||||||
"prepare": "npm run build",
|
"prepare": "npm run build",
|
||||||
"prettier-fix": "prettier --write .",
|
"prettier-fix": "prettier --write .",
|
||||||
"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.0",
|
"@modelcontextprotocol/inspector-client": "0.3.0",
|
||||||
"@modelcontextprotocol/inspector-server": "0.2.0",
|
"@modelcontextprotocol/inspector-server": "0.3.0",
|
||||||
"concurrently": "^9.0.1"
|
"concurrently": "^9.0.1",
|
||||||
|
"shell-quote": "^1.8.2",
|
||||||
|
"spawn-rx": "^5.1.0",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.3.3",
|
"@types/node": "^22.7.5",
|
||||||
"@types/node": "^22.7.5"
|
"@types/shell-quote": "^1.7.5",
|
||||||
|
"prettier": "3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.2.0",
|
"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": "0.7.0",
|
"@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 {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import mcpProxy from "./mcpProxy.js";
|
import mcpProxy from "./mcpProxy.js";
|
||||||
|
import { findActualExecutable } from "spawn-rx";
|
||||||
|
|
||||||
// Polyfill EventSource for an SSE client in Node.js
|
// Polyfill EventSource for an SSE client in Node.js
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -37,25 +39,33 @@ 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 args = (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);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Stdio transport: command=${command}, args=${args}, env=${JSON.stringify(env)}`,
|
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command,
|
command: cmd,
|
||||||
args,
|
args,
|
||||||
env,
|
env,
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
||||||
console.log("Spawned stdio transport");
|
console.log("Spawned stdio transport");
|
||||||
return transport;
|
return transport;
|
||||||
} else if (transportType === "sse") {
|
} else if (transportType === "sse") {
|
||||||
const url = query.url as string;
|
const url = query.url as string;
|
||||||
console.log(`SSE transport: url=${url}`);
|
console.log(`SSE transport: url=${url}`);
|
||||||
|
|
||||||
const transport = new SSEClientTransport(new URL(url));
|
const transport = new SSEClientTransport(new URL(url));
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
||||||
console.log("Connected to SSE transport");
|
console.log("Connected to SSE transport");
|
||||||
return transport;
|
return transport;
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +109,7 @@ app.get("/sse", async (req, res) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Set up MCP proxy");
|
console.log("Set up MCP proxy");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in /sse route:", error);
|
console.error("Error in /sse route:", error);
|
||||||
@@ -126,6 +137,7 @@ app.post("/message", async (req, res) => {
|
|||||||
app.get("/config", (req, res) => {
|
app.get("/config", (req, res) => {
|
||||||
try {
|
try {
|
||||||
const defaultEnvironment = getDefaultEnvironment();
|
const defaultEnvironment = getDefaultEnvironment();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
defaultEnvironment,
|
defaultEnvironment,
|
||||||
defaultCommand: values.env,
|
defaultCommand: values.env,
|
||||||
|
|||||||
Reference in New Issue
Block a user