Compare commits
152 Commits
0.7.0
...
0.8.1-hotf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032a67d4e | ||
|
|
f0651baf4a | ||
|
|
3f73ec83a2 | ||
|
|
bdab26dbeb | ||
|
|
0f075af42c | ||
|
|
06fcc74638 | ||
|
|
9092c780f7 | ||
|
|
a75dd7ba1f | ||
|
|
a414033354 | ||
|
|
ce1a9d3905 | ||
|
|
0bd51fa84a | ||
|
|
2a544294ba | ||
|
|
897e637db4 | ||
|
|
5db5fc26c7 | ||
|
|
8b31f495ba | ||
|
|
c964ff5cfe | ||
|
|
e69bfc58bc | ||
|
|
debb00344a | ||
|
|
c9ee22b781 | ||
|
|
cc70fbd0f5 | ||
|
|
8586d63e6d | ||
|
|
539de0fd85 | ||
|
|
f9cb2c1bd0 | ||
|
|
80f2986fd6 | ||
|
|
1504d1307e | ||
|
|
d1e155f984 | ||
|
|
c48670f426 | ||
|
|
e9a90b9caf | ||
|
|
affd207c9e | ||
|
|
4afe2d6adb | ||
|
|
e4d4ff0148 | ||
|
|
d127ee86ce | ||
|
|
f7d39fb252 | ||
|
|
8faadc0588 | ||
|
|
0dcd10c1dd | ||
|
|
65f38a4827 | ||
|
|
51f2f72677 | ||
|
|
d2db697d89 | ||
|
|
93c9c74dc9 | ||
|
|
dada1c4ba6 | ||
|
|
c3117d0fea | ||
|
|
992fdb33ed | ||
|
|
2a9230d507 | ||
|
|
51c7eda6a6 | ||
|
|
b82c744583 | ||
|
|
9ab213bc89 | ||
|
|
2ee0a53e36 | ||
|
|
fa7f9c80cd | ||
|
|
7dc2c6fb58 | ||
|
|
f49b2d1442 | ||
|
|
d1746f53a4 | ||
|
|
b99cf276ae | ||
|
|
6c67bf6d6d | ||
|
|
80f5ab1136 | ||
|
|
2f854304a7 | ||
|
|
6b63bacbcd | ||
|
|
fb29ca0113 | ||
|
|
83ceefca79 | ||
|
|
c3bd1fb1c6 | ||
|
|
45d8202de8 | ||
|
|
180760c4db | ||
|
|
538fc97289 | ||
|
|
04442b52a2 | ||
|
|
7753b275e5 | ||
|
|
7ac1e40c9d | ||
|
|
d2696e48a5 | ||
|
|
7b055b6b9a | ||
|
|
da9dd09765 | ||
|
|
539f32bf3b | ||
|
|
c6174116b0 | ||
|
|
65fc6d0490 | ||
|
|
834eb0c934 | ||
|
|
054741be03 | ||
|
|
eaa8055fd1 | ||
|
|
9e1186f5ac | ||
|
|
81c0ef29ab | ||
|
|
75537c7cae | ||
|
|
dbaa731097 | ||
|
|
dd0434c4ed | ||
|
|
ed31f9893f | ||
|
|
38fb710f2f | ||
|
|
240c67037c | ||
|
|
02155570df | ||
|
|
7227909df3 | ||
|
|
84335ae5f4 | ||
|
|
8486696240 | ||
|
|
6a6b15ab45 | ||
|
|
804a144ffb | ||
|
|
1d4ca435b8 | ||
|
|
1b754f52ca | ||
|
|
e47b1d8f4d | ||
|
|
d2e211a597 | ||
|
|
205e70736c | ||
|
|
827d867aae | ||
|
|
da0c855ef5 | ||
|
|
8180b0bd6f | ||
|
|
f09d2b6096 | ||
|
|
f846c154f5 | ||
|
|
9244ecf859 | ||
|
|
0891077402 | ||
|
|
353d6b549b | ||
|
|
18ca6e28a7 | ||
|
|
2d252a389c | ||
|
|
e6f5da8383 | ||
|
|
196fd67ce9 | ||
|
|
4d4bb9110c | ||
|
|
0d17082480 | ||
|
|
cdf1f1508a | ||
|
|
b97233f43a | ||
|
|
885932ac70 | ||
|
|
00118f7cf9 | ||
|
|
806cdb204f | ||
|
|
fcc06ab556 | ||
|
|
03c1ba3092 | ||
|
|
029823bb92 | ||
|
|
2588f3aeb3 | ||
|
|
88ffb5087e | ||
|
|
1f17132ca1 | ||
|
|
0e667acf7d | ||
|
|
8e9e5facaf | ||
|
|
25f5bb7620 | ||
|
|
6efdcb626f | ||
|
|
0656e15a22 | ||
|
|
d204dd6e7e | ||
|
|
f0b28d4760 | ||
|
|
65a0d46816 | ||
|
|
951db44bad | ||
|
|
379486b5ea | ||
|
|
40213bb1ed | ||
|
|
b7fa23676a | ||
|
|
a7f25153c4 | ||
|
|
fa3e2867c9 | ||
|
|
5735f2347a | ||
|
|
cab1ed3dd8 | ||
|
|
61e229a552 | ||
|
|
451704471c | ||
|
|
668cc915e4 | ||
|
|
85f0e21679 | ||
|
|
fc76a7c7d4 | ||
|
|
210975e385 | ||
|
|
ec73831487 | ||
|
|
9b0da1f892 | ||
|
|
3ac00598ff | ||
|
|
484e2820bc | ||
|
|
4a23585066 | ||
|
|
dcbd1dad41 | ||
|
|
ce81fb976b | ||
|
|
27b54104c1 | ||
|
|
536b7e0a99 | ||
|
|
dd460bd877 | ||
|
|
cda3905e5a | ||
|
|
fb667fd4d0 |
@@ -7,13 +7,13 @@ Thanks for your interest in contributing! This guide explains how to get involve
|
|||||||
1. Fork the repository and clone it locally
|
1. Fork the repository and clone it locally
|
||||||
2. Install dependencies with `npm install`
|
2. Install dependencies with `npm install`
|
||||||
3. Run `npm run dev` to start both client and server in development mode
|
3. Run `npm run dev` to start both client and server in development mode
|
||||||
4. Use the web UI at http://localhost:5173 to interact with the inspector
|
4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector
|
||||||
|
|
||||||
## Development Process & Pull Requests
|
## Development Process & Pull Requests
|
||||||
|
|
||||||
1. Create a new branch for your changes
|
1. Create a new branch for your changes
|
||||||
2. Make your changes following existing code style and conventions
|
2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable.
|
||||||
3. Test changes locally
|
3. Test changes locally by running `npm test`
|
||||||
4. Update documentation as needed
|
4. Update documentation as needed
|
||||||
5. Use clear commit messages explaining your changes
|
5. Use clear commit messages explaining your changes
|
||||||
6. Verify all changes work as expected
|
6. Verify all changes work as expected
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/inde
|
|||||||
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
|
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
|
||||||
@@ -42,6 +42,19 @@ For more details on ways to use the inspector, see the [Inspector section of the
|
|||||||
|
|
||||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :
|
||||||
|
|
||||||
|
| Name | Purpose | Default Value |
|
||||||
|
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
|
||||||
|
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |
|
||||||
|
| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` |
|
||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
|
|
||||||
If you're working on the inspector itself:
|
If you're working on the inspector itself:
|
||||||
|
|||||||
74
bin/cli.js
74
bin/cli.js
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -61,8 +61,8 @@ async function main() {
|
|||||||
"cli.js",
|
"cli.js",
|
||||||
);
|
);
|
||||||
|
|
||||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173";
|
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||||
const SERVER_PORT = process.env.SERVER_PORT ?? "3000";
|
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
|
||||||
|
|
||||||
console.log("Starting MCP inspector...");
|
console.log("Starting MCP inspector...");
|
||||||
|
|
||||||
@@ -73,42 +73,40 @@ async function main() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
abort.abort();
|
abort.abort();
|
||||||
});
|
});
|
||||||
|
let server, serverOk;
|
||||||
const server = spawnPromise(
|
|
||||||
"node",
|
|
||||||
[
|
|
||||||
inspectorServerPath,
|
|
||||||
...(command ? [`--env`, command] : []),
|
|
||||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PORT: SERVER_PORT,
|
|
||||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
|
||||||
},
|
|
||||||
signal: abort.signal,
|
|
||||||
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(
|
|
||||||
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} 🚀`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.any([server, client]);
|
server = spawnPromise(
|
||||||
} catch (e) {
|
"node",
|
||||||
if (!cancelled || process.env.DEBUG) throw e;
|
[
|
||||||
|
inspectorServerPath,
|
||||||
|
...(command ? [`--env`, command] : []),
|
||||||
|
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: SERVER_PORT,
|
||||||
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
|
},
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure server started before starting client
|
||||||
|
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
if (serverOk) {
|
||||||
|
try {
|
||||||
|
await spawnPromise("node", [inspectorClientPath], {
|
||||||
|
env: { ...process.env, PORT: CLIENT_PORT },
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled || process.env.DEBUG) throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -15,5 +15,19 @@ const server = http.createServer((request, response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.PORT || 5173;
|
const port = process.env.PORT || 6274;
|
||||||
server.listen(port, () => {});
|
server.on("listening", () => {
|
||||||
|
console.log(
|
||||||
|
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
server.on("error", (err) => {
|
||||||
|
if (err.message.includes(`EADDRINUSE`)) {
|
||||||
|
console.error(
|
||||||
|
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} ❌ `,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(port);
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ module.exports = {
|
|||||||
testEnvironment: "jsdom",
|
testEnvironment: "jsdom",
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
"^../components/DynamicJsonForm$":
|
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
|
||||||
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
|
||||||
"^../../components/DynamicJsonForm$":
|
|
||||||
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
"^.+\\.tsx?$": [
|
"^.+\\.tsx?$": [
|
||||||
"ts-jest",
|
"ts-jest",
|
||||||
{
|
{
|
||||||
useESM: true,
|
|
||||||
jsx: "react-jsx",
|
jsx: "react-jsx",
|
||||||
tsconfig: "tsconfig.jest.json",
|
tsconfig: "tsconfig.jest.json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"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,31 +18,32 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --port 6274",
|
||||||
"test": "jest --config jest.config.cjs",
|
"test": "jest --config jest.config.cjs",
|
||||||
"test:watch": "jest --config jest.config.cjs --watch"
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.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-popover": "^1.1.3",
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
"pkce-challenge": "^4.1.0",
|
"pkce-challenge": "^4.1.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"react-toastify": "^10.0.6",
|
|
||||||
"serve-handler": "^6.1.6",
|
"serve-handler": "^6.1.6",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -50,6 +51,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useConnection } from "./lib/hooks/useConnection";
|
import { useConnection } from "./lib/hooks/useConnection";
|
||||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||||
|
|
||||||
import { StdErrNotification } from "./lib/notificationTypes";
|
import { StdErrNotification } from "./lib/notificationTypes";
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -33,7 +32,6 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
@@ -45,23 +43,20 @@ 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 { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
||||||
|
import { InspectorConfig } from "./lib/configurationTypes";
|
||||||
|
import {
|
||||||
|
getMCPProxyAddress,
|
||||||
|
getMCPServerRequestTimeout,
|
||||||
|
} from "./utils/configUtils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
// Handle OAuth callback route
|
// Handle OAuth callback route
|
||||||
if (window.location.pathname === "/oauth/callback") {
|
|
||||||
const OAuthCallback = React.lazy(
|
|
||||||
() => import("./components/OAuthCallback"),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<OAuthCallback />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
ResourceTemplate[]
|
ResourceTemplate[]
|
||||||
@@ -99,6 +94,17 @@ const App = () => {
|
|||||||
>([]);
|
>([]);
|
||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
const [env, setEnv] = useState<Record<string, string>>({});
|
const [env, setEnv] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<InspectorConfig>(() => {
|
||||||
|
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
|
||||||
|
if (savedConfig) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
...JSON.parse(savedConfig),
|
||||||
|
} as InspectorConfig;
|
||||||
|
}
|
||||||
|
return DEFAULT_INSPECTOR_CONFIG;
|
||||||
|
});
|
||||||
const [bearerToken, setBearerToken] = useState<string>(() => {
|
const [bearerToken, setBearerToken] = useState<string>(() => {
|
||||||
return localStorage.getItem("lastBearerToken") || "";
|
return localStorage.getItem("lastBearerToken") || "";
|
||||||
});
|
});
|
||||||
@@ -114,22 +120,6 @@ const App = () => {
|
|||||||
const nextRequestId = useRef(0);
|
const nextRequestId = useRef(0);
|
||||||
const rootsRef = useRef<Root[]>([]);
|
const rootsRef = useRef<Root[]>([]);
|
||||||
|
|
||||||
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
|
||||||
setPendingSampleRequests((prev) => {
|
|
||||||
const request = prev.find((r) => r.id === id);
|
|
||||||
request?.resolve(result);
|
|
||||||
return prev.filter((r) => r.id !== id);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRejectSampling = (id: number) => {
|
|
||||||
setPendingSampleRequests((prev) => {
|
|
||||||
const request = prev.find((r) => r.id === id);
|
|
||||||
request?.reject(new Error("Sampling request rejected"));
|
|
||||||
return prev.filter((r) => r.id !== id);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -163,6 +153,7 @@ const App = () => {
|
|||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
connect: connectMcpServer,
|
connect: connectMcpServer,
|
||||||
|
disconnect: disconnectMcpServer,
|
||||||
} = useConnection({
|
} = useConnection({
|
||||||
transportType,
|
transportType,
|
||||||
command,
|
command,
|
||||||
@@ -170,7 +161,8 @@ const App = () => {
|
|||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
proxyServerUrl: PROXY_SERVER_URL,
|
proxyServerUrl: getMCPProxyAddress(config),
|
||||||
|
requestTimeout: getMCPServerRequestTimeout(config),
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
@@ -209,8 +201,17 @@ const App = () => {
|
|||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const hasProcessedRef = useRef(false);
|
||||||
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasProcessedRef.current) {
|
||||||
|
// Only try to connect once
|
||||||
|
return;
|
||||||
|
}
|
||||||
const serverUrl = params.get("serverUrl");
|
const serverUrl = params.get("serverUrl");
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
setSseUrl(serverUrl);
|
setSseUrl(serverUrl);
|
||||||
@@ -220,14 +221,18 @@ const App = () => {
|
|||||||
newUrl.searchParams.delete("serverUrl");
|
newUrl.searchParams.delete("serverUrl");
|
||||||
window.history.replaceState({}, "", newUrl.toString());
|
window.history.replaceState({}, "", newUrl.toString());
|
||||||
// Show success toast for OAuth
|
// Show success toast for OAuth
|
||||||
toast.success("Successfully authenticated with OAuth");
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Successfully authenticated with OAuth",
|
||||||
|
});
|
||||||
|
hasProcessedRef.current = true;
|
||||||
// Connect to the server
|
// Connect to the server
|
||||||
connectMcpServer();
|
connectMcpServer();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [connectMcpServer, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${PROXY_SERVER_URL}/config`)
|
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setEnv(data.defaultEnvironment);
|
setEnv(data.defaultEnvironment);
|
||||||
@@ -241,6 +246,7 @@ const App = () => {
|
|||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
console.error("Error fetching default environment:", error),
|
console.error("Error fetching default environment:", error),
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -253,6 +259,22 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
||||||
|
setPendingSampleRequests((prev) => {
|
||||||
|
const request = prev.find((r) => r.id === id);
|
||||||
|
request?.resolve(result);
|
||||||
|
return prev.filter((r) => r.id !== id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectSampling = (id: number) => {
|
||||||
|
setPendingSampleRequests((prev) => {
|
||||||
|
const request = prev.find((r) => r.id === id);
|
||||||
|
request?.reject(new Error("Sampling request rejected"));
|
||||||
|
return prev.filter((r) => r.id !== id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const clearError = (tabKey: keyof typeof errors) => {
|
const clearError = (tabKey: keyof typeof errors) => {
|
||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
};
|
};
|
||||||
@@ -425,6 +447,17 @@ const App = () => {
|
|||||||
setLogLevel(level);
|
setLogLevel(level);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (window.location.pathname === "/oauth/callback") {
|
||||||
|
const OAuthCallback = React.lazy(
|
||||||
|
() => import("./components/OAuthCallback"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<OAuthCallback />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -439,9 +472,12 @@ const App = () => {
|
|||||||
setSseUrl={setSseUrl}
|
setSseUrl={setSseUrl}
|
||||||
env={env}
|
env={env}
|
||||||
setEnv={setEnv}
|
setEnv={setEnv}
|
||||||
|
config={config}
|
||||||
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
bearerToken={bearerToken}
|
||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
|
|||||||
1
client/src/__mocks__/styleMock.js
Normal file
1
client/src/__mocks__/styleMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
@@ -108,6 +108,21 @@ const DynamicJsonForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatJson = () => {
|
||||||
|
try {
|
||||||
|
const jsonStr = rawJsonValue.trim();
|
||||||
|
if (!jsonStr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2);
|
||||||
|
setRawJsonValue(formatted);
|
||||||
|
debouncedUpdateParent(formatted);
|
||||||
|
setJsonError(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderFormFields = (
|
const renderFormFields = (
|
||||||
propSchema: JsonSchemaType,
|
propSchema: JsonSchemaType,
|
||||||
currentValue: JsonValue,
|
currentValue: JsonValue,
|
||||||
@@ -353,7 +368,12 @@ const DynamicJsonForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end space-x-2">
|
||||||
|
{isJsonMode && (
|
||||||
|
<Button variant="outline" size="sm" onClick={formatJson}>
|
||||||
|
Format JSON
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Copy } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const HistoryAndNotifications = ({
|
const HistoryAndNotifications = ({
|
||||||
requestHistory,
|
requestHistory,
|
||||||
@@ -24,10 +24,6 @@ const HistoryAndNotifications = ({
|
|||||||
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
|
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card overflow-hidden flex h-full">
|
<div className="bg-card overflow-hidden flex h-full">
|
||||||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||||
@@ -67,16 +63,12 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-blue-600">
|
<span className="font-semibold text-blue-600">
|
||||||
Request:
|
Request:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(request.request)}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
|
||||||
{JSON.stringify(JSON.parse(request.request), null, 2)}
|
<JsonView
|
||||||
</pre>
|
data={request.request}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{request.response && (
|
{request.response && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -84,20 +76,11 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-green-600">
|
<span className="font-semibold text-green-600">
|
||||||
Response:
|
Response:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(request.response!)}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(
|
data={request.response}
|
||||||
JSON.parse(request.response),
|
className="bg-background"
|
||||||
null,
|
/>
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -137,18 +120,11 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-purple-600">
|
<span className="font-semibold text-purple-600">
|
||||||
Details:
|
Details:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
copyToClipboard(JSON.stringify(notification))
|
|
||||||
}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(notification, null, 2)}
|
data={JSON.stringify(notification, null, 2)}
|
||||||
</pre>
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Editor from "react-simple-code-editor";
|
|||||||
import Prism from "prismjs";
|
import Prism from "prismjs";
|
||||||
import "prismjs/components/prism-json";
|
import "prismjs/components/prism-json";
|
||||||
import "prismjs/themes/prism.css";
|
import "prismjs/themes/prism.css";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface JsonEditorProps {
|
interface JsonEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -16,49 +15,25 @@ const JsonEditor = ({
|
|||||||
onChange,
|
onChange,
|
||||||
error: externalError,
|
error: externalError,
|
||||||
}: JsonEditorProps) => {
|
}: JsonEditorProps) => {
|
||||||
const [editorContent, setEditorContent] = useState(value);
|
const [editorContent, setEditorContent] = useState(value || "");
|
||||||
const [internalError, setInternalError] = useState<string | undefined>(
|
const [internalError, setInternalError] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditorContent(value);
|
setEditorContent(value || "");
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const formatJson = (json: string): string => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(json), null, 2);
|
|
||||||
} catch {
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditorChange = (newContent: string) => {
|
const handleEditorChange = (newContent: string) => {
|
||||||
setEditorContent(newContent);
|
setEditorContent(newContent);
|
||||||
setInternalError(undefined);
|
setInternalError(undefined);
|
||||||
onChange(newContent);
|
onChange(newContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormatJson = () => {
|
|
||||||
try {
|
|
||||||
const formatted = formatJson(editorContent);
|
|
||||||
setEditorContent(formatted);
|
|
||||||
onChange(formatted);
|
|
||||||
setInternalError(undefined);
|
|
||||||
} catch (err) {
|
|
||||||
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayError = internalError || externalError;
|
const displayError = internalError || externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-2">
|
<div className="relative">
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleFormatJson}>
|
|
||||||
Format JSON
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-md ${
|
className={`border rounded-md ${
|
||||||
displayError
|
displayError
|
||||||
|
|||||||
290
client/src/components/JsonView.tsx
Normal file
290
client/src/components/JsonView.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { JsonValue } from "./DynamicJsonForm";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Copy, CheckCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface JsonViewProps {
|
||||||
|
data: unknown;
|
||||||
|
name?: string;
|
||||||
|
initialExpandDepth?: number;
|
||||||
|
className?: string;
|
||||||
|
withCopyButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (
|
||||||
|
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||||
|
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||||
|
) {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { success: true, data: JSON.parse(str) };
|
||||||
|
} catch {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonView = memo(
|
||||||
|
({
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
initialExpandDepth = 3,
|
||||||
|
className,
|
||||||
|
withCopyButton = true,
|
||||||
|
}: JsonViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
if (copied) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const normalizedData = useMemo(() => {
|
||||||
|
return typeof data === "string"
|
||||||
|
? tryParseJson(data).success
|
||||||
|
? tryParseJson(data).data
|
||||||
|
: data
|
||||||
|
: data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
typeof normalizedData === "string"
|
||||||
|
? normalizedData
|
||||||
|
: JSON.stringify(normalizedData, null, 2),
|
||||||
|
);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [toast, normalizedData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("p-4 border rounded relative", className)}>
|
||||||
|
{withCopyButton && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="font-mono text-sm transition-all duration-300">
|
||||||
|
<JsonNode
|
||||||
|
data={normalizedData as JsonValue}
|
||||||
|
name={name}
|
||||||
|
depth={0}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonView.displayName = "JsonView";
|
||||||
|
|
||||||
|
interface JsonNodeProps {
|
||||||
|
data: JsonValue;
|
||||||
|
name?: string;
|
||||||
|
depth: number;
|
||||||
|
initialExpandDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonNode = memo(
|
||||||
|
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||||
|
|
||||||
|
const getDataType = (value: JsonValue): string => {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === null) return "null";
|
||||||
|
return typeof value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataType = getDataType(data);
|
||||||
|
|
||||||
|
const typeStyleMap: Record<string, string> = {
|
||||||
|
number: "text-blue-600",
|
||||||
|
boolean: "text-amber-600",
|
||||||
|
null: "text-purple-600",
|
||||||
|
undefined: "text-gray-600",
|
||||||
|
string: "text-green-600 break-all whitespace-pre-wrap",
|
||||||
|
default: "text-gray-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCollapsible = (isArray: boolean) => {
|
||||||
|
const items = isArray
|
||||||
|
? (data as JsonValue[])
|
||||||
|
: Object.entries(data as Record<string, JsonValue>);
|
||||||
|
const itemCount = items.length;
|
||||||
|
const isEmpty = itemCount === 0;
|
||||||
|
|
||||||
|
const symbolMap = {
|
||||||
|
open: isArray ? "[" : "{",
|
||||||
|
close: isArray ? "]" : "}",
|
||||||
|
collapsed: isArray ? "[ ... ]" : "{ ... }",
|
||||||
|
empty: isArray ? "[]" : "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">{symbolMap.empty}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpanded ? (
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{symbolMap.open}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{symbolMap.collapsed}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
|
||||||
|
{isArray
|
||||||
|
? (items as JsonValue[]).map((item, index) => (
|
||||||
|
<div key={index} className="my-1">
|
||||||
|
<JsonNode
|
||||||
|
data={item}
|
||||||
|
name={`${index}`}
|
||||||
|
depth={depth + 1}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: (items as [string, JsonValue][]).map(([key, value]) => (
|
||||||
|
<div key={key} className="my-1">
|
||||||
|
<JsonNode
|
||||||
|
data={value}
|
||||||
|
name={key}
|
||||||
|
depth={depth + 1}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{symbolMap.close}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderString = (value: string) => {
|
||||||
|
const maxLength = 100;
|
||||||
|
const isTooLong = value.length > maxLength;
|
||||||
|
|
||||||
|
if (!isTooLong) {
|
||||||
|
return (
|
||||||
|
<div className="flex mr-1 rounded hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre className={typeStyleMap.string}>"{value}"</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre
|
||||||
|
className={clsx(
|
||||||
|
typeStyleMap.string,
|
||||||
|
"cursor-pointer group-hover:text-green-500",
|
||||||
|
)}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||||
|
>
|
||||||
|
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (dataType) {
|
||||||
|
case "object":
|
||||||
|
case "array":
|
||||||
|
return renderCollapsible(dataType === "array");
|
||||||
|
case "string":
|
||||||
|
return renderString(data as string);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
||||||
|
{data === null ? "null" : String(data)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode.displayName = "JsonNode";
|
||||||
|
|
||||||
|
export default JsonView;
|
||||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Label } from "@/components/ui/label";
|
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 {
|
import {
|
||||||
ListPromptsResult,
|
ListPromptsResult,
|
||||||
PromptReference,
|
PromptReference,
|
||||||
@@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
export type Prompt = {
|
export type Prompt = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -151,11 +152,7 @@ const PromptsTab = ({
|
|||||||
Get Prompt
|
Get Prompt
|
||||||
</Button>
|
</Button>
|
||||||
{promptContent && (
|
{promptContent && (
|
||||||
<Textarea
|
<JsonView data={promptContent} withCopyButton={false} />
|
||||||
value={promptContent}
|
|
||||||
readOnly
|
|
||||||
className="h-64 font-mono"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
|
|||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const ResourcesTab = ({
|
const ResourcesTab = ({
|
||||||
resources,
|
resources,
|
||||||
@@ -214,9 +215,10 @@ const ResourcesTab = ({
|
|||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : selectedResource ? (
|
) : selectedResource ? (
|
||||||
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100">
|
<JsonView
|
||||||
{resourceContent}
|
data={resourceContent}
|
||||||
</pre>
|
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
) : selectedTemplate ? (
|
) : selectedTemplate ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
export type PendingRequest = {
|
export type PendingRequest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,9 +44,11 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
|||||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||||
{pendingRequests.map((request) => (
|
{pendingRequests.map((request) => (
|
||||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(request.request, null, 2)}
|
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||||
</pre>
|
data={JSON.stringify(request.request)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
||||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
RefreshCwOff,
|
||||||
} from "lucide-react";
|
} 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";
|
||||||
@@ -23,12 +27,18 @@ import {
|
|||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { ConnectionStatus } from "@/lib/constants";
|
||||||
import useTheme from "../lib/useTheme";
|
import useTheme from "../lib/useTheme";
|
||||||
import { version } from "../../../package.json";
|
import { version } from "../../../package.json";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
connectionStatus: "disconnected" | "connected" | "error";
|
connectionStatus: ConnectionStatus;
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse";
|
||||||
setTransportType: (type: "stdio" | "sse") => void;
|
setTransportType: (type: "stdio" | "sse") => void;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -42,10 +52,13 @@ interface SidebarProps {
|
|||||||
bearerToken: string;
|
bearerToken: string;
|
||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
logLevel: LoggingLevel;
|
logLevel: LoggingLevel;
|
||||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
loggingSupported: boolean;
|
loggingSupported: boolean;
|
||||||
|
config: InspectorConfig;
|
||||||
|
setConfig: (config: InspectorConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar = ({
|
const Sidebar = ({
|
||||||
@@ -63,14 +76,18 @@ const Sidebar = ({
|
|||||||
bearerToken,
|
bearerToken,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
logLevel,
|
logLevel,
|
||||||
sendLogLevelRequest,
|
sendLogLevelRequest,
|
||||||
loggingSupported,
|
loggingSupported,
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
const [theme, setTheme] = useTheme();
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
const [showBearerToken, setShowBearerToken] = useState(false);
|
const [showBearerToken, setShowBearerToken] = useState(false);
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,6 +186,7 @@ const Sidebar = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowEnvVars(!showEnvVars)}
|
onClick={() => setShowEnvVars(!showEnvVars)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
|
data-testid="env-vars-button"
|
||||||
>
|
>
|
||||||
{showEnvVars ? (
|
{showEnvVars ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -284,28 +302,147 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button className="w-full" onClick={onConnect}>
|
<Button
|
||||||
<Play className="w-4 h-4 mr-2" />
|
variant="outline"
|
||||||
Connect
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
|
className="flex items-center w-full"
|
||||||
|
data-testid="config-button"
|
||||||
|
>
|
||||||
|
{showConfig ? (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Configuration
|
||||||
</Button>
|
</Button>
|
||||||
|
{showConfig && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(config).map(([key, configItem]) => {
|
||||||
|
const configKey = key as keyof InspectorConfig;
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="text-sm font-medium text-green-600">
|
||||||
|
{configKey}
|
||||||
|
</label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{configItem.description}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{typeof configItem.value === "number" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
data-testid={`${configKey}-input`}
|
||||||
|
value={configItem.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: Number(e.target.value),
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
) : typeof configItem.value === "boolean" ? (
|
||||||
|
<Select
|
||||||
|
data-testid={`${configKey}-select`}
|
||||||
|
value={configItem.value.toString()}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: val === "true",
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">True</SelectItem>
|
||||||
|
<SelectItem value="false">False</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
data-testid={`${configKey}-input`}
|
||||||
|
value={configItem.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: e.target.value,
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connectionStatus === "connected" && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button data-testid="connect-button" onClick={onConnect}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onDisconnect}>
|
||||||
|
<RefreshCwOff className="w-4 h-4 mr-2" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connectionStatus !== "connected" && (
|
||||||
|
<Button className="w-full" onClick={onConnect}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-2 mb-4">
|
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={`w-2 h-2 rounded-full ${(() => {
|
||||||
connectionStatus === "connected"
|
switch (connectionStatus) {
|
||||||
? "bg-green-500"
|
case "connected":
|
||||||
: connectionStatus === "error"
|
return "bg-green-500";
|
||||||
? "bg-red-500"
|
case "error":
|
||||||
: "bg-gray-500"
|
return "bg-red-500";
|
||||||
}`}
|
case "error-connecting-to-proxy":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
})()}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{connectionStatus === "connected"
|
{(() => {
|
||||||
? "Connected"
|
switch (connectionStatus) {
|
||||||
: connectionStatus === "error"
|
case "connected":
|
||||||
? "Connection Error"
|
return "Connected";
|
||||||
: "Disconnected"}
|
case "error":
|
||||||
|
return "Connection Error, is your MCP server running?";
|
||||||
|
case "error-connecting-to-proxy":
|
||||||
|
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
|
||||||
|
default:
|
||||||
|
return "Disconnected";
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -371,36 +508,37 @@ const Sidebar = ({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<a
|
<Button variant="ghost" title="Inspector Documentation" asChild>
|
||||||
href="https://modelcontextprotocol.io/docs/tools/inspector"
|
<a
|
||||||
target="_blank"
|
href="https://modelcontextprotocol.io/docs/tools/inspector"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<CircleHelp className="w-4 h-4 text-foreground" />
|
||||||
</Button>
|
</a>
|
||||||
</a>
|
</Button>
|
||||||
|
<Button variant="ghost" title="Debugging Guide" asChild>
|
||||||
|
<a
|
||||||
|
href="https://modelcontextprotocol.io/docs/tools/debugging"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Bug className="w-4 h-4 text-foreground" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
title="Report bugs or contribute on GitHub"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/modelcontextprotocol/inspector"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Github className="w-4 h-4 text-foreground" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AlertCircle, Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import { escapeUnicode } from "@/utils/escapeUnicode";
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
@@ -27,7 +27,6 @@ const ToolsTab = ({
|
|||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
toolResult,
|
toolResult,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
error,
|
|
||||||
}: {
|
}: {
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
@@ -53,17 +52,10 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
<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">
|
<JsonView data={toolResult} />
|
||||||
{escapeUnicode(toolResult)}
|
|
||||||
</pre>
|
|
||||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||||
{parsedResult.error.errors.map((error, idx) => (
|
{parsedResult.error.errors.map((error, idx) => (
|
||||||
<pre
|
<JsonView data={error} key={idx} />
|
||||||
key={idx}
|
|
||||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
|
|
||||||
>
|
|
||||||
{escapeUnicode(error)}
|
|
||||||
</pre>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -78,11 +70,7 @@ const ToolsTab = ({
|
|||||||
</h4>
|
</h4>
|
||||||
{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" && <JsonView data={item.text} />}
|
||||||
<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}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{item.type === "image" && (
|
{item.type === "image" && (
|
||||||
<img
|
<img
|
||||||
src={`data:${item.mimeType};base64,${item.data}`}
|
src={`data:${item.mimeType};base64,${item.data}`}
|
||||||
@@ -100,9 +88,7 @@ const ToolsTab = ({
|
|||||||
<p>Your browser does not support audio playback</p>
|
<p>Your browser does not support audio playback</p>
|
||||||
</audio>
|
</audio>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<JsonView data={item.resource} />
|
||||||
{escapeUnicode(item.resource)}
|
|
||||||
</pre>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -112,9 +98,8 @@ 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 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
|
|
||||||
{escapeUnicode(toolResult.toolResult)}
|
<JsonView data={toolResult.toolResult} />
|
||||||
</pre>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,13 +135,7 @@ const ToolsTab = ({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{error ? (
|
{selectedTool ? (
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : selectedTool ? (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{selectedTool.description}
|
{selectedTool.description}
|
||||||
@@ -229,7 +208,11 @@ const ToolsTab = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type={prop.type === "number" ? "number" : "text"}
|
type={
|
||||||
|
prop.type === "number" || prop.type === "integer"
|
||||||
|
? "number"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
id={key}
|
id={key}
|
||||||
name={key}
|
name={key}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
@@ -238,7 +221,8 @@ const ToolsTab = ({
|
|||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]:
|
[key]:
|
||||||
prop.type === "number"
|
prop.type === "number" ||
|
||||||
|
prop.type === "integer"
|
||||||
? Number(e.target.value)
|
? Number(e.target.value)
|
||||||
: e.target.value,
|
: e.target.value,
|
||||||
})
|
})
|
||||||
|
|||||||
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal file
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
|
import type { JsonSchemaType } from "../DynamicJsonForm";
|
||||||
|
|
||||||
|
describe("DynamicJsonForm String Fields", () => {
|
||||||
|
const renderForm = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Test string field",
|
||||||
|
} satisfies JsonSchemaType,
|
||||||
|
value: undefined,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Type Validation", () => {
|
||||||
|
it("should handle numeric input as string type", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: "123321" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("123321");
|
||||||
|
// Verify the value is a string, not a number
|
||||||
|
expect(typeof onChange.mock.calls[0][0]).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render as text input, not number input", () => {
|
||||||
|
renderForm();
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Integer Fields", () => {
|
||||||
|
const renderForm = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "integer" as const,
|
||||||
|
description: "Test integer field",
|
||||||
|
} satisfies JsonSchemaType,
|
||||||
|
value: undefined,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Basic Operations", () => {
|
||||||
|
it("should render number input with step=1", () => {
|
||||||
|
renderForm();
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
expect(input).toHaveProperty("type", "number");
|
||||||
|
expect(input).toHaveProperty("step", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass integer values to onChange", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(42);
|
||||||
|
// Verify the value is a number, not a string
|
||||||
|
expect(typeof onChange.mock.calls[0][0]).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not pass string values to onChange", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases", () => {
|
||||||
|
it("should handle non-numeric input by not calling onChange", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
419
client/src/components/__tests__/Sidebar.test.tsx
Normal file
419
client/src/components/__tests__/Sidebar.test.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
|
import Sidebar from "../Sidebar";
|
||||||
|
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// Mock theme hook
|
||||||
|
jest.mock("../../lib/useTheme", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => ["light", jest.fn()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Sidebar Environment Variables", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
connectionStatus: "disconnected" as const,
|
||||||
|
transportType: "stdio" as const,
|
||||||
|
setTransportType: jest.fn(),
|
||||||
|
command: "",
|
||||||
|
setCommand: jest.fn(),
|
||||||
|
args: "",
|
||||||
|
setArgs: jest.fn(),
|
||||||
|
sseUrl: "",
|
||||||
|
setSseUrl: jest.fn(),
|
||||||
|
env: {},
|
||||||
|
setEnv: jest.fn(),
|
||||||
|
bearerToken: "",
|
||||||
|
setBearerToken: jest.fn(),
|
||||||
|
onConnect: jest.fn(),
|
||||||
|
onDisconnect: jest.fn(),
|
||||||
|
stdErrNotifications: [],
|
||||||
|
logLevel: "info" as const,
|
||||||
|
sendLogLevelRequest: jest.fn(),
|
||||||
|
loggingSupported: true,
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
setConfig: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSidebar = (props = {}) => {
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} {...props} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEnvVarsSection = () => {
|
||||||
|
const button = screen.getByTestId("env-vars-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Basic Operations", () => {
|
||||||
|
it("should add a new environment variable", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
renderSidebar({ env: {}, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const addButton = screen.getByText("Add Environment Variable");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "": "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove an environment variable", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const removeButton = screen.getByRole("button", { name: "×" });
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update environment variable value", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
fireEvent.change(valueInput, { target: { value: "new_value" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle value visibility", () => {
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(valueInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
expect(valueInput).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Key Editing", () => {
|
||||||
|
it("should maintain order when editing first key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
|
||||||
|
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
NEW_FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order when editing middle key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
|
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
NEW_SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order when editing last key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const lastKeyInput = screen.getByDisplayValue("THIRD_KEY");
|
||||||
|
fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
NEW_THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order during key editing", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
KEY1: "value1",
|
||||||
|
KEY2: "value2",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// Type "NEW_" one character at a time
|
||||||
|
const key1Input = screen.getByDisplayValue("KEY1");
|
||||||
|
"NEW_".split("").forEach((char) => {
|
||||||
|
fireEvent.change(key1Input, {
|
||||||
|
target: { value: char + "KEY1".slice(1) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the last setEnv call maintains the order
|
||||||
|
const lastCall = setEnv.mock.calls[
|
||||||
|
setEnv.mock.calls.length - 1
|
||||||
|
][0] as Record<string, string>;
|
||||||
|
const entries = Object.entries(lastCall);
|
||||||
|
|
||||||
|
// The values should stay with their original keys
|
||||||
|
expect(entries[0][1]).toBe("value1"); // First entry should still have value1
|
||||||
|
expect(entries[1][1]).toBe("value2"); // Second entry should still have value2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple Operations", () => {
|
||||||
|
it("should maintain state after multiple key edits", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
};
|
||||||
|
const { rerender } = renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// First key edit
|
||||||
|
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
|
||||||
|
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
|
||||||
|
|
||||||
|
// Get the updated env from the first setEnv call
|
||||||
|
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
|
||||||
|
|
||||||
|
// Rerender with the updated env
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second key edit
|
||||||
|
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
|
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
||||||
|
|
||||||
|
// Verify the final state matches what we expect
|
||||||
|
expect(setEnv).toHaveBeenLastCalledWith({
|
||||||
|
NEW_FIRST_KEY: "first_value",
|
||||||
|
NEW_SECOND_KEY: "second_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain visibility state after key edit", () => {
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
const { rerender } = renderSidebar({ env: initialEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// Show the value
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(valueInput).toHaveProperty("type", "text");
|
||||||
|
|
||||||
|
// Edit the key
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
|
||||||
|
|
||||||
|
// Rerender with updated env
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Value should still be visible
|
||||||
|
const updatedValueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(updatedValueInput).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases", () => {
|
||||||
|
it("should handle empty key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle special characters in key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unicode characters", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "TEST_🔑" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very long key names", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
const longKey = "A".repeat(100);
|
||||||
|
fireEvent.change(keyInput, { target: { value: longKey } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Configuration Operations", () => {
|
||||||
|
const openConfigSection = () => {
|
||||||
|
const button = screen.getByTestId("config-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update MCP server request timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 5000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid timeout values entered by user", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain configuration state after multiple updates", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
setConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
// First update
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
// Get the updated config from the first setConfig call
|
||||||
|
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
|
||||||
|
|
||||||
|
// Rerender with the updated config
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
config={updatedConfig}
|
||||||
|
setConfig={setConfig}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second update
|
||||||
|
const updatedTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
||||||
|
|
||||||
|
// Verify the final state matches what we expect
|
||||||
|
expect(setConfig).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 3000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
client/src/components/__tests__/ToolsTab.test.tsx
Normal file
102
client/src/components/__tests__/ToolsTab.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import ToolsTab from "../ToolsTab";
|
||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
describe("ToolsTab", () => {
|
||||||
|
const mockTools: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "tool1",
|
||||||
|
description: "First tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
num: { type: "number" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool3",
|
||||||
|
description: "Integer tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
count: { type: "integer" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool2",
|
||||||
|
description: "Second tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
num: { type: "number" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
tools: mockTools,
|
||||||
|
listTools: jest.fn(),
|
||||||
|
clearTools: jest.fn(),
|
||||||
|
callTool: jest.fn(),
|
||||||
|
selectedTool: null,
|
||||||
|
setSelectedTool: jest.fn(),
|
||||||
|
toolResult: null,
|
||||||
|
nextCursor: "",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderToolsTab = (props = {}) => {
|
||||||
|
return render(
|
||||||
|
<Tabs defaultValue="tools">
|
||||||
|
<ToolsTab {...defaultProps} {...props} />
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should reset input values when switching tools", () => {
|
||||||
|
const { rerender } = renderToolsTab({
|
||||||
|
selectedTool: mockTools[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter a value in the first tool's input
|
||||||
|
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
|
// Switch to second tool
|
||||||
|
rerender(
|
||||||
|
<Tabs defaultValue="tools">
|
||||||
|
<ToolsTab {...defaultProps} selectedTool={mockTools[2]} />
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify input is reset
|
||||||
|
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
|
expect(newInput.value).toBe("");
|
||||||
|
});
|
||||||
|
it("should handle integer type inputs", () => {
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[1], // Use the tool with integer type
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton", {
|
||||||
|
name: /count/i,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(input).toHaveProperty("type", "number");
|
||||||
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||||
|
count: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 hover:border-[#646cff] hover:border-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
126
client/src/components/ui/toast.tsx
Normal file
126
client/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
33
client/src/components/ui/toaster.tsx
Normal file
33
client/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/src/components/ui/tooltip.tsx
Normal file
30
client/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
191
client/src/hooks/use-toast.ts
Normal file
191
client/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
@@ -38,29 +38,6 @@ h1 {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
button[role="checkbox"] {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
@@ -69,9 +46,6 @@ button[role="checkbox"] {
|
|||||||
a:hover {
|
a:hover {
|
||||||
color: #747bff;
|
color: #747bff;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
19
client/src/lib/configurationTypes.ts
Normal file
19
client/src/lib/configurationTypes.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type ConfigItem = {
|
||||||
|
description: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration interface for the MCP Inspector, including settings for the MCP Client,
|
||||||
|
* Proxy Server, and Inspector UI/UX.
|
||||||
|
*
|
||||||
|
* Note: Configuration related to which MCP Server to use or any other MCP Server
|
||||||
|
* specific settings are outside the scope of this interface as of now.
|
||||||
|
*/
|
||||||
|
export type InspectorConfig = {
|
||||||
|
/**
|
||||||
|
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
||||||
|
*/
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
||||||
|
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { InspectorConfig } from "./configurationTypes";
|
||||||
|
|
||||||
// OAuth-related session storage keys
|
// OAuth-related session storage keys
|
||||||
export const SESSION_KEYS = {
|
export const SESSION_KEYS = {
|
||||||
CODE_VERIFIER: "mcp_code_verifier",
|
CODE_VERIFIER: "mcp_code_verifier",
|
||||||
@@ -5,3 +7,27 @@ export const SESSION_KEYS = {
|
|||||||
TOKENS: "mcp_tokens",
|
TOKENS: "mcp_tokens",
|
||||||
CLIENT_INFORMATION: "mcp_client_information",
|
CLIENT_INFORMATION: "mcp_client_information",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| "disconnected"
|
||||||
|
| "connected"
|
||||||
|
| "error"
|
||||||
|
| "error-connecting-to-proxy";
|
||||||
|
|
||||||
|
export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser.
|
||||||
|
* Future plans: Provide json config file + Browser local_storage to override default values
|
||||||
|
**/
|
||||||
|
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 10000,
|
||||||
|
},
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
description:
|
||||||
|
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ import {
|
|||||||
McpError,
|
McpError,
|
||||||
CompleteResultSchema,
|
CompleteResultSchema,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
CancelledNotificationSchema,
|
||||||
|
ResourceListChangedNotificationSchema,
|
||||||
|
ToolListChangedNotificationSchema,
|
||||||
|
PromptListChangedNotificationSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SESSION_KEYS } from "../constants";
|
import { ConnectionStatus, SESSION_KEYS } from "../constants";
|
||||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { authProvider } from "../auth";
|
import { authProvider } from "../auth";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MSEC =
|
|
||||||
parseInt(params.get("timeout") ?? "") || 10000;
|
|
||||||
|
|
||||||
interface UseConnectionOptions {
|
interface UseConnectionOptions {
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse";
|
||||||
command: string;
|
command: string;
|
||||||
@@ -44,7 +44,9 @@ interface UseConnectionOptions {
|
|||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
onNotification?: (notification: Notification) => void;
|
onNotification?: (notification: Notification) => void;
|
||||||
onStdErrNotification?: (notification: Notification) => void;
|
onStdErrNotification?: (notification: Notification) => void;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
|
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
getRoots?: () => any[];
|
getRoots?: () => any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,15 +64,15 @@ export function useConnection({
|
|||||||
env,
|
env,
|
||||||
proxyServerUrl,
|
proxyServerUrl,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
|
requestTimeout,
|
||||||
onNotification,
|
onNotification,
|
||||||
onStdErrNotification,
|
onStdErrNotification,
|
||||||
onPendingRequest,
|
onPendingRequest,
|
||||||
getRoots,
|
getRoots,
|
||||||
}: UseConnectionOptions) {
|
}: UseConnectionOptions) {
|
||||||
const [connectionStatus, setConnectionStatus] = useState<
|
const [connectionStatus, setConnectionStatus] =
|
||||||
"disconnected" | "connected" | "error"
|
useState<ConnectionStatus>("disconnected");
|
||||||
>("disconnected");
|
const { toast } = useToast();
|
||||||
const [serverCapabilities, setServerCapabilities] =
|
const [serverCapabilities, setServerCapabilities] =
|
||||||
useState<ServerCapabilities | null>(null);
|
useState<ServerCapabilities | null>(null);
|
||||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
||||||
@@ -123,7 +125,11 @@ export function useConnection({
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (!options?.suppressToast) {
|
if (!options?.suppressToast) {
|
||||||
const errorString = (e as Error).message ?? String(e);
|
const errorString = (e as Error).message ?? String(e);
|
||||||
toast.error(errorString);
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: errorString,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -165,7 +171,11 @@ export function useConnection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unexpected errors - show toast and rethrow
|
// Unexpected errors - show toast and rethrow
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -173,7 +183,11 @@ export function useConnection({
|
|||||||
const sendNotification = async (notification: ClientNotification) => {
|
const sendNotification = async (notification: ClientNotification) => {
|
||||||
if (!mcpClient) {
|
if (!mcpClient) {
|
||||||
const error = new Error("MCP client not connected");
|
const error = new Error("MCP client not connected");
|
||||||
toast.error(error.message);
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +200,25 @@ export function useConnection({
|
|||||||
// Log MCP protocol errors
|
// Log MCP protocol errors
|
||||||
pushHistory(notification, { error: e.message });
|
pushHistory(notification, { error: e.message });
|
||||||
}
|
}
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkProxyHealth = async () => {
|
||||||
|
try {
|
||||||
|
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
|
||||||
|
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
||||||
|
const proxyHealth = await proxyHealthResponse.json();
|
||||||
|
if (proxyHealth?.status !== "ok") {
|
||||||
|
throw new Error("MCP Proxy Server is not healthy");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Couldn't connect to MCP Proxy Server", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -203,33 +235,38 @@ export function useConnection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const connect = async (_e?: unknown, retryCount: number = 0) => {
|
const connect = async (_e?: unknown, retryCount: number = 0) => {
|
||||||
try {
|
const client = new Client<Request, Notification, Result>(
|
||||||
const client = new Client<Request, Notification, Result>(
|
{
|
||||||
{
|
name: "mcp-inspector",
|
||||||
name: "mcp-inspector",
|
version: packageJson.version,
|
||||||
version: packageJson.version,
|
},
|
||||||
},
|
{
|
||||||
{
|
capabilities: {
|
||||||
capabilities: {
|
sampling: {},
|
||||||
sampling: {},
|
roots: {
|
||||||
roots: {
|
listChanged: true,
|
||||||
listChanged: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const backendUrl = new URL(`${proxyServerUrl}/sse`);
|
try {
|
||||||
|
await checkProxyHealth();
|
||||||
backendUrl.searchParams.append("transportType", transportType);
|
} catch {
|
||||||
if (transportType === "stdio") {
|
setConnectionStatus("error-connecting-to-proxy");
|
||||||
backendUrl.searchParams.append("command", command);
|
return;
|
||||||
backendUrl.searchParams.append("args", args);
|
}
|
||||||
backendUrl.searchParams.append("env", JSON.stringify(env));
|
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
|
||||||
} else {
|
mcpProxyServerUrl.searchParams.append("transportType", transportType);
|
||||||
backendUrl.searchParams.append("url", sseUrl);
|
if (transportType === "stdio") {
|
||||||
}
|
mcpProxyServerUrl.searchParams.append("command", command);
|
||||||
|
mcpProxyServerUrl.searchParams.append("args", args);
|
||||||
|
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||||
|
} else {
|
||||||
|
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||||
// proxying through the inspector server first.
|
// proxying through the inspector server first.
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
@@ -240,7 +277,7 @@ export function useConnection({
|
|||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientTransport = new SSEClientTransport(backendUrl, {
|
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||||
},
|
},
|
||||||
@@ -250,20 +287,24 @@ export function useConnection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (onNotification) {
|
if (onNotification) {
|
||||||
client.setNotificationHandler(
|
[
|
||||||
|
CancelledNotificationSchema,
|
||||||
ProgressNotificationSchema,
|
ProgressNotificationSchema,
|
||||||
onNotification,
|
|
||||||
);
|
|
||||||
|
|
||||||
client.setNotificationHandler(
|
|
||||||
ResourceUpdatedNotificationSchema,
|
|
||||||
onNotification,
|
|
||||||
);
|
|
||||||
|
|
||||||
client.setNotificationHandler(
|
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
onNotification,
|
ResourceUpdatedNotificationSchema,
|
||||||
);
|
ResourceListChangedNotificationSchema,
|
||||||
|
ToolListChangedNotificationSchema,
|
||||||
|
PromptListChangedNotificationSchema,
|
||||||
|
].forEach((notificationSchema) => {
|
||||||
|
client.setNotificationHandler(notificationSchema, onNotification);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.fallbackNotificationHandler = (
|
||||||
|
notification: Notification,
|
||||||
|
): Promise<void> => {
|
||||||
|
onNotification(notification);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onStdErrNotification) {
|
if (onStdErrNotification) {
|
||||||
@@ -276,7 +317,10 @@ export function useConnection({
|
|||||||
try {
|
try {
|
||||||
await client.connect(clientTransport);
|
await client.connect(clientTransport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to connect to MCP server:", error);
|
console.error(
|
||||||
|
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
const shouldRetry = await handleAuthError(error);
|
const shouldRetry = await handleAuthError(error);
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
return connect(undefined, retryCount + 1);
|
return connect(undefined, retryCount + 1);
|
||||||
@@ -315,6 +359,14 @@ export function useConnection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await mcpClient?.close();
|
||||||
|
setMcpClient(null);
|
||||||
|
setConnectionStatus("disconnected");
|
||||||
|
setCompletionsSupported(false);
|
||||||
|
setServerCapabilities(null);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
@@ -325,5 +377,6 @@ export function useConnection({
|
|||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
connect,
|
connect,
|
||||||
|
disconnect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
|
|||||||
|
|
||||||
export const NotificationSchema = ClientNotificationSchema.or(
|
export const NotificationSchema = ClientNotificationSchema.or(
|
||||||
StdErrNotificationSchema,
|
StdErrNotificationSchema,
|
||||||
).or(ServerNotificationSchema);
|
)
|
||||||
|
.or(ServerNotificationSchema)
|
||||||
|
.or(BaseNotificationSchema);
|
||||||
|
|
||||||
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
||||||
export type Notification = z.infer<typeof NotificationSchema>;
|
export type Notification = z.infer<typeof NotificationSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
@@ -36,16 +36,14 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
|
|||||||
};
|
};
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return [
|
const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
|
||||||
theme,
|
setTheme(newTheme);
|
||||||
useCallback((newTheme: Theme) => {
|
localStorage.setItem("theme", newTheme);
|
||||||
setTheme(newTheme);
|
if (newTheme !== "system") {
|
||||||
localStorage.setItem("theme", newTheme);
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
if (newTheme !== "system") {
|
}
|
||||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
}, []);
|
||||||
}
|
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
|
||||||
}, []),
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTheme;
|
export default useTheme;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
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 { Toaster } from "@/components/ui/toaster.tsx";
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { TooltipProvider } from "./components/ui/tooltip.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<TooltipProvider>
|
||||||
<ToastContainer />
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
14
client/src/utils/configUtils.ts
Normal file
14
client/src/utils/configUtils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||||
|
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
||||||
|
if (proxyFullAddress) {
|
||||||
|
return proxyFullAddress;
|
||||||
|
}
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||||
|
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
|
||||||
|
};
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"types": ["jest", "@testing-library/jest-dom", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 385 KiB |
3221
package-lock.json
generated
3221
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"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)",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||||
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
||||||
|
"test": "npm run prettier-check && cd client && npm test",
|
||||||
"build-server": "cd server && npm run build",
|
"build-server": "cd server && npm run build",
|
||||||
"build-client": "cd client && npm run build",
|
"build-client": "cd client && npm run build",
|
||||||
"build": "npm run build-server && npm run build-client",
|
"build": "npm run build-server && npm run build-client",
|
||||||
@@ -31,11 +32,12 @@
|
|||||||
"start": "node ./bin/cli.js",
|
"start": "node ./bin/cli.js",
|
||||||
"prepare": "npm run build",
|
"prepare": "npm run build",
|
||||||
"prettier-fix": "prettier --write .",
|
"prettier-fix": "prettier --write .",
|
||||||
|
"prettier-check": "prettier --check .",
|
||||||
"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.7.0",
|
"@modelcontextprotocol/inspector-client": "^0.8.1",
|
||||||
"@modelcontextprotocol/inspector-server": "^0.7.0",
|
"@modelcontextprotocol/inspector-server": "^0.8.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"shell-quote": "^1.8.2",
|
"shell-quote": "^1.8.2",
|
||||||
"spawn-rx": "^5.1.2",
|
"spawn-rx": "^5.1.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
StdioClientTransport,
|
StdioClientTransport,
|
||||||
getDefaultEnvironment,
|
getDefaultEnvironment,
|
||||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
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 { findActualExecutable } from "spawn-rx";
|
import { findActualExecutable } from "spawn-rx";
|
||||||
@@ -37,7 +38,7 @@ app.use(cors());
|
|||||||
|
|
||||||
let webAppTransports: SSEServerTransport[] = [];
|
let webAppTransports: SSEServerTransport[] = [];
|
||||||
|
|
||||||
const createTransport = async (req: express.Request) => {
|
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
console.log("Query parameters:", query);
|
console.log("Query parameters:", query);
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const createTransport = async (req: express.Request) => {
|
|||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||||
if (req.headers[key] === undefined) {
|
if (req.headers[key] === undefined) {
|
||||||
continue;
|
continue;
|
||||||
@@ -98,12 +100,14 @@ const createTransport = async (req: express.Request) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let backingServerTransport: Transport | undefined;
|
||||||
|
|
||||||
app.get("/sse", async (req, res) => {
|
app.get("/sse", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log("New SSE connection");
|
console.log("New SSE connection");
|
||||||
|
|
||||||
let backingServerTransport;
|
|
||||||
try {
|
try {
|
||||||
|
await backingServerTransport?.close();
|
||||||
backingServerTransport = await createTransport(req);
|
backingServerTransport = await createTransport(req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
@@ -169,6 +173,12 @@ app.post("/message", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/health", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/config", (req, res) => {
|
app.get("/config", (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -182,17 +192,17 @@ app.get("/config", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 6277;
|
||||||
|
|
||||||
try {
|
const server = app.listen(PORT);
|
||||||
const server = app.listen(PORT);
|
server.on("listening", () => {
|
||||||
|
console.log(`⚙️ Proxy server listening on port ${PORT}`);
|
||||||
server.on("listening", () => {
|
});
|
||||||
const addr = server.address();
|
server.on("error", (err) => {
|
||||||
const port = typeof addr === "string" ? addr : addr?.port;
|
if (err.message.includes(`EADDRINUSE`)) {
|
||||||
console.log(`Proxy server listening on port ${port}`);
|
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
|
||||||
});
|
} else {
|
||||||
} catch (error) {
|
console.error(err.message);
|
||||||
console.error("Failed to start server:", error);
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user