Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7680d72b | ||
|
|
e20c4fb4ff | ||
|
|
645a256994 | ||
|
|
98ea4a12d6 | ||
|
|
70dc1b766e | ||
|
|
eab6b42ac6 | ||
|
|
4a2ba7ce6e | ||
|
|
4f6c30904a | ||
|
|
a3a1ad47fa | ||
|
|
1596973b05 | ||
|
|
6c07559573 | ||
|
|
727e0753e4 | ||
|
|
aad3262940 | ||
|
|
7bc1088159 | ||
|
|
256972a366 | ||
|
|
068d43226f | ||
|
|
fe74dbea74 | ||
|
|
da4e2fa844 | ||
|
|
9c6663c4c2 | ||
|
|
cf20fe9142 | ||
|
|
6659d549ea | ||
|
|
48917ca4e5 | ||
|
|
779ca20568 | ||
|
|
f71613227f | ||
|
|
4053aa122d | ||
|
|
ad7865a6ab | ||
|
|
d69934afcb | ||
|
|
c5dc4ded5c | ||
|
|
4a3f032f59 | ||
|
|
6bcf1531c3 | ||
|
|
a506e4c419 | ||
|
|
ca1854b071 | ||
|
|
a3be8f6376 | ||
|
|
8d20044b33 | ||
|
|
6be05c5278 | ||
|
|
f36061e4b9 | ||
|
|
e35343537c | ||
|
|
402cde025b | ||
|
|
0fd2e12c7b | ||
|
|
b8ab30fdf3 | ||
|
|
3032a67d4e | ||
|
|
f0651baf4a | ||
|
|
3f73ec83a2 | ||
|
|
06f237b1de | ||
|
|
a8ffc704f0 | ||
|
|
bdab26dbeb | ||
|
|
0f075af42c | ||
|
|
06fcc74638 | ||
|
|
9092c780f7 | ||
|
|
a75dd7ba1f | ||
|
|
a414033354 | ||
|
|
ce1a9d3905 | ||
|
|
0bd51fa84a | ||
|
|
2a544294ba | ||
|
|
897e637db4 | ||
|
|
5db5fc26c7 | ||
|
|
8b31f495ba | ||
|
|
c964ff5cfe | ||
|
|
e69bfc58bc | ||
|
|
debb00344a | ||
|
|
c9ee22b781 | ||
|
|
cc70fbd0f5 | ||
|
|
8586d63e6d | ||
|
|
539de0fd85 | ||
|
|
0dcd10c1dd | ||
|
|
51c7eda6a6 |
15
README.md
15
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Running the Inspector
|
## Running the Inspector
|
||||||
|
|
||||||
@@ -48,11 +48,16 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :
|
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 |
|
| Setting | Description | Default |
|
||||||
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
|
||||||
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |
|
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
|
||||||
|
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||||
|
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||||
|
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||||
|
|
||||||
|
These settings can be adjusted in real-time through the UI and will persist across sessions.
|
||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"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.9.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"@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",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"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",
|
||||||
@@ -42,7 +43,6 @@
|
|||||||
"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",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@testing-library/react": "^16.2.0",
|
"@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/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/serve-handler": "^6.1.4",
|
"@types/serve-handler": "^6.1.4",
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -47,13 +45,14 @@ import Sidebar from "./components/Sidebar";
|
|||||||
import ToolsTab from "./components/ToolsTab";
|
import ToolsTab from "./components/ToolsTab";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
||||||
import { InspectorConfig } from "./lib/configurationTypes";
|
import { InspectorConfig } from "./lib/configurationTypes";
|
||||||
|
import { getMCPProxyAddress } 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") ?? "6277";
|
|
||||||
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
// Handle OAuth callback route
|
// Handle OAuth callback route
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
@@ -95,7 +94,24 @@ const App = () => {
|
|||||||
|
|
||||||
const [config, setConfig] = useState<InspectorConfig>(() => {
|
const [config, setConfig] = useState<InspectorConfig>(() => {
|
||||||
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
|
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
|
||||||
return savedConfig ? JSON.parse(savedConfig) : DEFAULT_INSPECTOR_CONFIG;
|
if (savedConfig) {
|
||||||
|
// merge default config with saved config
|
||||||
|
const mergedConfig = {
|
||||||
|
...DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
...JSON.parse(savedConfig),
|
||||||
|
} as InspectorConfig;
|
||||||
|
|
||||||
|
// update description of keys to match the new description (in case of any updates to the default config description)
|
||||||
|
Object.entries(mergedConfig).forEach(([key, value]) => {
|
||||||
|
mergedConfig[key as keyof InspectorConfig] = {
|
||||||
|
...value,
|
||||||
|
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedConfig;
|
||||||
|
}
|
||||||
|
return DEFAULT_INSPECTOR_CONFIG;
|
||||||
});
|
});
|
||||||
const [bearerToken, setBearerToken] = useState<string>(() => {
|
const [bearerToken, setBearerToken] = useState<string>(() => {
|
||||||
return localStorage.getItem("lastBearerToken") || "";
|
return localStorage.getItem("lastBearerToken") || "";
|
||||||
@@ -140,7 +156,7 @@ const App = () => {
|
|||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
mcpClient,
|
mcpClient,
|
||||||
requestHistory,
|
requestHistory,
|
||||||
makeRequest: makeConnectionRequest,
|
makeRequest,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
@@ -153,8 +169,7 @@ const App = () => {
|
|||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
proxyServerUrl: PROXY_SERVER_URL,
|
config,
|
||||||
requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number,
|
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
@@ -197,8 +212,13 @@ const App = () => {
|
|||||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||||
}, [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);
|
||||||
@@ -208,14 +228,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]);
|
}, [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);
|
||||||
@@ -229,6 +253,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(() => {
|
||||||
@@ -261,13 +286,13 @@ const App = () => {
|
|||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeRequest = async <T extends z.ZodType>(
|
const sendMCPRequest = async <T extends z.ZodType>(
|
||||||
request: ClientRequest,
|
request: ClientRequest,
|
||||||
schema: T,
|
schema: T,
|
||||||
tabKey?: keyof typeof errors,
|
tabKey?: keyof typeof errors,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await makeConnectionRequest(request, schema);
|
const response = await makeRequest(request, schema);
|
||||||
if (tabKey !== undefined) {
|
if (tabKey !== undefined) {
|
||||||
clearError(tabKey);
|
clearError(tabKey);
|
||||||
}
|
}
|
||||||
@@ -285,7 +310,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listResources = async () => {
|
const listResources = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/list" as const,
|
method: "resources/list" as const,
|
||||||
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
||||||
@@ -298,7 +323,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listResourceTemplates = async () => {
|
const listResourceTemplates = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/templates/list" as const,
|
method: "resources/templates/list" as const,
|
||||||
params: nextResourceTemplateCursor
|
params: nextResourceTemplateCursor
|
||||||
@@ -315,7 +340,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readResource = async (uri: string) => {
|
const readResource = async (uri: string) => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/read" as const,
|
method: "resources/read" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -328,7 +353,7 @@ const App = () => {
|
|||||||
|
|
||||||
const subscribeToResource = async (uri: string) => {
|
const subscribeToResource = async (uri: string) => {
|
||||||
if (!resourceSubscriptions.has(uri)) {
|
if (!resourceSubscriptions.has(uri)) {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/subscribe" as const,
|
method: "resources/subscribe" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -344,7 +369,7 @@ const App = () => {
|
|||||||
|
|
||||||
const unsubscribeFromResource = async (uri: string) => {
|
const unsubscribeFromResource = async (uri: string) => {
|
||||||
if (resourceSubscriptions.has(uri)) {
|
if (resourceSubscriptions.has(uri)) {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/unsubscribe" as const,
|
method: "resources/unsubscribe" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -359,7 +384,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listPrompts = async () => {
|
const listPrompts = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "prompts/list" as const,
|
method: "prompts/list" as const,
|
||||||
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
||||||
@@ -372,7 +397,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "prompts/get" as const,
|
method: "prompts/get" as const,
|
||||||
params: { name, arguments: args },
|
params: { name, arguments: args },
|
||||||
@@ -384,7 +409,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listTools = async () => {
|
const listTools = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/list" as const,
|
method: "tools/list" as const,
|
||||||
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
||||||
@@ -397,21 +422,34 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||||
const response = await makeRequest(
|
try {
|
||||||
{
|
const response = await sendMCPRequest(
|
||||||
method: "tools/call" as const,
|
{
|
||||||
params: {
|
method: "tools/call" as const,
|
||||||
name,
|
params: {
|
||||||
arguments: params,
|
name,
|
||||||
_meta: {
|
arguments: params,
|
||||||
progressToken: progressTokenRef.current++,
|
_meta: {
|
||||||
|
progressToken: progressTokenRef.current++,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
CompatibilityCallToolResultSchema,
|
||||||
CompatibilityCallToolResultSchema,
|
"tools",
|
||||||
"tools",
|
);
|
||||||
);
|
setToolResult(response);
|
||||||
setToolResult(response);
|
} catch (e) {
|
||||||
|
const toolResult: CompatibilityCallToolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: (e as Error).message ?? String(e),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
setToolResult(toolResult);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRootsChange = async () => {
|
const handleRootsChange = async () => {
|
||||||
@@ -419,7 +457,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "logging/setLevel" as const,
|
method: "logging/setLevel" as const,
|
||||||
params: { level },
|
params: { level },
|
||||||
@@ -619,9 +657,10 @@ const App = () => {
|
|||||||
setTools([]);
|
setTools([]);
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
}}
|
}}
|
||||||
callTool={(name, params) => {
|
callTool={async (name, params) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
callTool(name, params);
|
setToolResult(null);
|
||||||
|
await callTool(name, params);
|
||||||
}}
|
}}
|
||||||
selectedTool={selectedTool}
|
selectedTool={selectedTool}
|
||||||
setSelectedTool={(tool) => {
|
setSelectedTool={(tool) => {
|
||||||
@@ -636,7 +675,7 @@ const App = () => {
|
|||||||
<ConsoleTab />
|
<ConsoleTab />
|
||||||
<PingTab
|
<PingTab
|
||||||
onPingClick={() => {
|
onPingClick={() => {
|
||||||
void makeRequest(
|
void sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "ping" as const,
|
method: "ping" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,33 +3,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
import { updateValueAtPath } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||||
|
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
|
||||||
export type JsonValue =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
| JsonValue[]
|
|
||||||
| { [key: string]: JsonValue };
|
|
||||||
|
|
||||||
export type JsonSchemaType = {
|
|
||||||
type:
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "integer"
|
|
||||||
| "boolean"
|
|
||||||
| "array"
|
|
||||||
| "object"
|
|
||||||
| "null";
|
|
||||||
description?: string;
|
|
||||||
required?: boolean;
|
|
||||||
default?: JsonValue;
|
|
||||||
properties?: Record<string, JsonSchemaType>;
|
|
||||||
items?: JsonSchemaType;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DynamicJsonFormProps {
|
interface DynamicJsonFormProps {
|
||||||
schema: JsonSchemaType;
|
schema: JsonSchemaType;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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";
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
@@ -25,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">
|
||||||
@@ -68,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 className="bg-background p-2 rounded">
|
|
||||||
<JsonView data={request.request} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<JsonView
|
||||||
|
data={request.request}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{request.response && (
|
{request.response && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -85,16 +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 className="bg-background p-2 rounded">
|
|
||||||
<JsonView data={request.response} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<JsonView
|
||||||
|
data={request.response}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -134,20 +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 className="bg-background p-2 rounded">
|
|
||||||
<JsonView
|
|
||||||
data={JSON.stringify(notification, null, 2)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<JsonView
|
||||||
|
data={JSON.stringify(notification, null, 2)}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,45 +1,96 @@
|
|||||||
import { useState, memo } from "react";
|
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||||
import { JsonValue } from "./DynamicJsonForm";
|
import type { JsonValue } from "@/utils/jsonUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Copy, CheckCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
interface JsonViewProps {
|
interface JsonViewProps {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
name?: string;
|
name?: string;
|
||||||
initialExpandDepth?: number;
|
initialExpandDepth?: number;
|
||||||
}
|
className?: string;
|
||||||
|
withCopyButton?: boolean;
|
||||||
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
isError?: boolean;
|
||||||
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(
|
const JsonView = memo(
|
||||||
({ data, name, initialExpandDepth = 3 }: JsonViewProps) => {
|
({
|
||||||
const normalizedData =
|
data,
|
||||||
typeof data === "string"
|
name,
|
||||||
|
initialExpandDepth = 3,
|
||||||
|
className,
|
||||||
|
withCopyButton = true,
|
||||||
|
isError = false,
|
||||||
|
}: 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).success
|
||||||
? tryParseJson(data).data
|
? tryParseJson(data).data
|
||||||
: 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 (
|
return (
|
||||||
<div className="font-mono text-sm transition-all duration-300 ">
|
<div className={clsx("p-4 border rounded relative", className)}>
|
||||||
<JsonNode
|
{withCopyButton && (
|
||||||
data={normalizedData as JsonValue}
|
<Button
|
||||||
name={name}
|
size="icon"
|
||||||
depth={0}
|
variant="ghost"
|
||||||
initialExpandDepth={initialExpandDepth}
|
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}
|
||||||
|
isError={isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -52,28 +103,28 @@ interface JsonNodeProps {
|
|||||||
name?: string;
|
name?: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
initialExpandDepth: number;
|
initialExpandDepth: number;
|
||||||
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonNode = memo(
|
const JsonNode = memo(
|
||||||
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
({
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
depth = 0,
|
||||||
|
initialExpandDepth,
|
||||||
|
isError = false,
|
||||||
|
}: JsonNodeProps) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||||
|
const [typeStyleMap] = useState<Record<string, string>>({
|
||||||
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",
|
number: "text-blue-600",
|
||||||
boolean: "text-amber-600",
|
boolean: "text-amber-600",
|
||||||
null: "text-purple-600",
|
null: "text-purple-600",
|
||||||
undefined: "text-gray-600",
|
undefined: "text-gray-600",
|
||||||
string: "text-green-600 break-all whitespace-pre-wrap",
|
string: "text-green-600 group-hover:text-green-500",
|
||||||
|
error: "text-red-600 group-hover:text-red-500",
|
||||||
default: "text-gray-700",
|
default: "text-gray-700",
|
||||||
};
|
});
|
||||||
|
const dataType = getDataType(data);
|
||||||
|
|
||||||
const renderCollapsible = (isArray: boolean) => {
|
const renderCollapsible = (isArray: boolean) => {
|
||||||
const items = isArray
|
const items = isArray
|
||||||
@@ -174,7 +225,14 @@ const JsonNode = memo(
|
|||||||
{name}:
|
{name}:
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<pre className={typeStyleMap.string}>"{value}"</pre>
|
<pre
|
||||||
|
className={clsx(
|
||||||
|
typeStyleMap.string,
|
||||||
|
"break-all whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
"{value}"
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,8 +246,8 @@ const JsonNode = memo(
|
|||||||
)}
|
)}
|
||||||
<pre
|
<pre
|
||||||
className={clsx(
|
className={clsx(
|
||||||
typeStyleMap.string,
|
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||||
"cursor-pointer group-hover:text-green-500",
|
"cursor-pointer break-all whitespace-pre-wrap",
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
|
|||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => (
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
||||||
return (
|
return (
|
||||||
<TabsContent value="ping" className="grid grid-cols-2 gap-4">
|
<TabsContent value="ping">
|
||||||
<div className="col-span-2 flex justify-center items-center">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Button
|
<div className="col-span-2 flex justify-center items-center">
|
||||||
onClick={onPingClick}
|
<Button
|
||||||
className="font-bold py-6 px-12 rounded-full"
|
onClick={onPingClick}
|
||||||
>
|
className="font-bold py-6 px-12 rounded-full"
|
||||||
Ping Server
|
>
|
||||||
</Button>
|
Ping Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,86 +84,88 @@ const PromptsTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
|
<TabsContent value="prompts">
|
||||||
<ListPane
|
<div className="grid grid-cols-2 gap-4">
|
||||||
items={prompts}
|
<ListPane
|
||||||
listItems={listPrompts}
|
items={prompts}
|
||||||
clearItems={clearPrompts}
|
listItems={listPrompts}
|
||||||
setSelectedItem={(prompt) => {
|
clearItems={clearPrompts}
|
||||||
setSelectedPrompt(prompt);
|
setSelectedItem={(prompt) => {
|
||||||
setPromptArgs({});
|
setSelectedPrompt(prompt);
|
||||||
}}
|
setPromptArgs({});
|
||||||
renderItem={(prompt) => (
|
}}
|
||||||
<>
|
renderItem={(prompt) => (
|
||||||
<span className="flex-1">{prompt.name}</span>
|
<>
|
||||||
<span className="text-sm text-gray-500">{prompt.description}</span>
|
<span className="flex-1">{prompt.name}</span>
|
||||||
</>
|
<span className="text-sm text-gray-500">
|
||||||
)}
|
{prompt.description}
|
||||||
title="Prompts"
|
</span>
|
||||||
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
|
</>
|
||||||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
|
||||||
<div className="p-4 border-b border-gray-200">
|
|
||||||
<h3 className="font-semibold">
|
|
||||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
{error ? (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : selectedPrompt ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedPrompt.description && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{selectedPrompt.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{selectedPrompt.arguments?.map((arg) => (
|
|
||||||
<div key={arg.name}>
|
|
||||||
<Label htmlFor={arg.name}>{arg.name}</Label>
|
|
||||||
<Combobox
|
|
||||||
id={arg.name}
|
|
||||||
placeholder={`Enter ${arg.name}`}
|
|
||||||
value={promptArgs[arg.name] || ""}
|
|
||||||
onChange={(value) => handleInputChange(arg.name, value)}
|
|
||||||
onInputChange={(value) =>
|
|
||||||
handleInputChange(arg.name, value)
|
|
||||||
}
|
|
||||||
options={completions[arg.name] || []}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{arg.description && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{arg.description}
|
|
||||||
{arg.required && (
|
|
||||||
<span className="text-xs mt-1 ml-1">(Required)</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button onClick={handleGetPrompt} className="w-full">
|
|
||||||
Get Prompt
|
|
||||||
</Button>
|
|
||||||
{promptContent && (
|
|
||||||
<div className="p-4 border rounded">
|
|
||||||
<JsonView data={promptContent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Select a prompt from the list to view and use it
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
title="Prompts"
|
||||||
|
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
|
||||||
|
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : selectedPrompt ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedPrompt.description && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedPrompt.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedPrompt.arguments?.map((arg) => (
|
||||||
|
<div key={arg.name}>
|
||||||
|
<Label htmlFor={arg.name}>{arg.name}</Label>
|
||||||
|
<Combobox
|
||||||
|
id={arg.name}
|
||||||
|
placeholder={`Enter ${arg.name}`}
|
||||||
|
value={promptArgs[arg.name] || ""}
|
||||||
|
onChange={(value) => handleInputChange(arg.name, value)}
|
||||||
|
onInputChange={(value) =>
|
||||||
|
handleInputChange(arg.name, value)
|
||||||
|
}
|
||||||
|
options={completions[arg.name] || []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{arg.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{arg.description}
|
||||||
|
{arg.required && (
|
||||||
|
<span className="text-xs mt-1 ml-1">(Required)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button onClick={handleGetPrompt} className="w-full">
|
||||||
|
Get Prompt
|
||||||
|
</Button>
|
||||||
|
{promptContent && (
|
||||||
|
<JsonView data={promptContent} withCopyButton={false} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Select a prompt from the list to view and use it
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -111,154 +111,158 @@ const ResourcesTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
|
<TabsContent value="resources">
|
||||||
<ListPane
|
<div className="grid grid-cols-3 gap-4">
|
||||||
items={resources}
|
<ListPane
|
||||||
listItems={listResources}
|
items={resources}
|
||||||
clearItems={clearResources}
|
listItems={listResources}
|
||||||
setSelectedItem={(resource) => {
|
clearItems={clearResources}
|
||||||
setSelectedResource(resource);
|
setSelectedItem={(resource) => {
|
||||||
readResource(resource.uri);
|
setSelectedResource(resource);
|
||||||
setSelectedTemplate(null);
|
readResource(resource.uri);
|
||||||
}}
|
setSelectedTemplate(null);
|
||||||
renderItem={(resource) => (
|
}}
|
||||||
<div className="flex items-center w-full">
|
renderItem={(resource) => (
|
||||||
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
<div className="flex items-center w-full">
|
||||||
<span className="flex-1 truncate" title={resource.uri.toString()}>
|
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
||||||
{resource.name}
|
<span className="flex-1 truncate" title={resource.uri.toString()}>
|
||||||
</span>
|
{resource.name}
|
||||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
</span>
|
||||||
</div>
|
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||||
)}
|
|
||||||
title="Resources"
|
|
||||||
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
|
||||||
isButtonDisabled={!nextCursor && resources.length > 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListPane
|
|
||||||
items={resourceTemplates}
|
|
||||||
listItems={listResourceTemplates}
|
|
||||||
clearItems={clearResourceTemplates}
|
|
||||||
setSelectedItem={(template) => {
|
|
||||||
setSelectedTemplate(template);
|
|
||||||
setSelectedResource(null);
|
|
||||||
setTemplateValues({});
|
|
||||||
}}
|
|
||||||
renderItem={(template) => (
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
|
||||||
<span className="flex-1 truncate" title={template.uriTemplate}>
|
|
||||||
{template.name}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
title="Resource Templates"
|
|
||||||
buttonText={
|
|
||||||
nextTemplateCursor ? "List More Templates" : "List Templates"
|
|
||||||
}
|
|
||||||
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
||||||
<h3
|
|
||||||
className="font-semibold truncate"
|
|
||||||
title={selectedResource?.name || selectedTemplate?.name}
|
|
||||||
>
|
|
||||||
{selectedResource
|
|
||||||
? selectedResource.name
|
|
||||||
: selectedTemplate
|
|
||||||
? selectedTemplate.name
|
|
||||||
: "Select a resource or template"}
|
|
||||||
</h3>
|
|
||||||
{selectedResource && (
|
|
||||||
<div className="flex row-auto gap-1 justify-end w-2/5">
|
|
||||||
{resourceSubscriptionsSupported &&
|
|
||||||
!resourceSubscriptions.has(selectedResource.uri) && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => subscribeToResource(selectedResource.uri)}
|
|
||||||
>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{resourceSubscriptionsSupported &&
|
|
||||||
resourceSubscriptions.has(selectedResource.uri) && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
unsubscribeFromResource(selectedResource.uri)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Unsubscribe
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => readResource(selectedResource.uri)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
title="Resources"
|
||||||
<div className="p-4">
|
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
||||||
{error ? (
|
isButtonDisabled={!nextCursor && resources.length > 0}
|
||||||
<Alert variant="destructive">
|
/>
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
<ListPane
|
||||||
<AlertDescription>{error}</AlertDescription>
|
items={resourceTemplates}
|
||||||
</Alert>
|
listItems={listResourceTemplates}
|
||||||
) : selectedResource ? (
|
clearItems={clearResourceTemplates}
|
||||||
<div 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">
|
setSelectedItem={(template) => {
|
||||||
<JsonView data={resourceContent} />
|
setSelectedTemplate(template);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setTemplateValues({});
|
||||||
|
}}
|
||||||
|
renderItem={(template) => (
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate" title={template.uriTemplate}>
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : selectedTemplate ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{selectedTemplate.description}
|
|
||||||
</p>
|
|
||||||
{selectedTemplate.uriTemplate
|
|
||||||
.match(/{([^}]+)}/g)
|
|
||||||
?.map((param) => {
|
|
||||||
const key = param.slice(1, -1);
|
|
||||||
return (
|
|
||||||
<div key={key}>
|
|
||||||
<Label htmlFor={key}>{key}</Label>
|
|
||||||
<Combobox
|
|
||||||
id={key}
|
|
||||||
placeholder={`Enter ${key}`}
|
|
||||||
value={templateValues[key] || ""}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleTemplateValueChange(key, value)
|
|
||||||
}
|
|
||||||
onInputChange={(value) =>
|
|
||||||
handleTemplateValueChange(key, value)
|
|
||||||
}
|
|
||||||
options={completions[key] || []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Button
|
|
||||||
onClick={handleReadTemplateResource}
|
|
||||||
disabled={Object.keys(templateValues).length === 0}
|
|
||||||
>
|
|
||||||
Read Resource
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Select a resource or template from the list to view its contents
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
title="Resource Templates"
|
||||||
|
buttonText={
|
||||||
|
nextTemplateCursor ? "List More Templates" : "List Templates"
|
||||||
|
}
|
||||||
|
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
|
||||||
|
<h3
|
||||||
|
className="font-semibold truncate"
|
||||||
|
title={selectedResource?.name || selectedTemplate?.name}
|
||||||
|
>
|
||||||
|
{selectedResource
|
||||||
|
? selectedResource.name
|
||||||
|
: selectedTemplate
|
||||||
|
? selectedTemplate.name
|
||||||
|
: "Select a resource or template"}
|
||||||
|
</h3>
|
||||||
|
{selectedResource && (
|
||||||
|
<div className="flex row-auto gap-1 justify-end w-2/5">
|
||||||
|
{resourceSubscriptionsSupported &&
|
||||||
|
!resourceSubscriptions.has(selectedResource.uri) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => subscribeToResource(selectedResource.uri)}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{resourceSubscriptionsSupported &&
|
||||||
|
resourceSubscriptions.has(selectedResource.uri) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
unsubscribeFromResource(selectedResource.uri)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => readResource(selectedResource.uri)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : selectedResource ? (
|
||||||
|
<JsonView
|
||||||
|
data={resourceContent}
|
||||||
|
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 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedTemplate.description}
|
||||||
|
</p>
|
||||||
|
{selectedTemplate.uriTemplate
|
||||||
|
.match(/{([^}]+)}/g)
|
||||||
|
?.map((param) => {
|
||||||
|
const key = param.slice(1, -1);
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<Label htmlFor={key}>{key}</Label>
|
||||||
|
<Combobox
|
||||||
|
id={key}
|
||||||
|
placeholder={`Enter ${key}`}
|
||||||
|
value={templateValues[key] || ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleTemplateValueChange(key, value)
|
||||||
|
}
|
||||||
|
onInputChange={(value) =>
|
||||||
|
handleTemplateValueChange(key, value)
|
||||||
|
}
|
||||||
|
options={completions[key] || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
onClick={handleReadTemplateResource}
|
||||||
|
disabled={Object.keys(templateValues).length === 0}
|
||||||
|
>
|
||||||
|
Read Resource
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Select a resource or template from the list to view its
|
||||||
|
contents
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -35,40 +35,42 @@ const RootsTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="roots" className="space-y-4">
|
<TabsContent value="roots">
|
||||||
<Alert>
|
<div className="space-y-4">
|
||||||
<AlertDescription>
|
<Alert>
|
||||||
Configure the root directories that the server can access
|
<AlertDescription>
|
||||||
</AlertDescription>
|
Configure the root directories that the server can access
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
{roots.map((root, index) => (
|
{roots.map((root, index) => (
|
||||||
<div key={index} className="flex gap-2 items-center">
|
<div key={index} className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="file:// URI"
|
placeholder="file:// URI"
|
||||||
value={root.uri}
|
value={root.uri}
|
||||||
onChange={(e) => updateRoot(index, "uri", e.target.value)}
|
onChange={(e) => updateRoot(index, "uri", e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeRoot(index)}
|
onClick={() => removeRoot(index)}
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={addRoot}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Root
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={addRoot}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Root
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,31 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="sampling" className="h-96">
|
<TabsContent value="sampling">
|
||||||
<Alert>
|
<div className="h-96">
|
||||||
<AlertDescription>
|
<Alert>
|
||||||
When the server requests LLM sampling, requests will appear here for
|
<AlertDescription>
|
||||||
approval.
|
When the server requests LLM sampling, requests will appear here for
|
||||||
</AlertDescription>
|
approval.
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
<div className="mt-4 space-y-4">
|
</Alert>
|
||||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
<div className="mt-4 space-y-4">
|
||||||
{pendingRequests.map((request) => (
|
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
{pendingRequests.map((request) => (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||||
<JsonView data={JSON.stringify(request.request)} />
|
<JsonView
|
||||||
|
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||||
|
data={JSON.stringify(request.request)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button onClick={() => handleApprove(request.id)}>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
))}
|
||||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
{pendingRequests.length === 0 && (
|
||||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
<p className="text-gray-500">No pending requests</p>
|
||||||
Reject
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{pendingRequests.length === 0 && (
|
|
||||||
<p className="text-gray-500">No pending requests</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Settings,
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
RefreshCwOff,
|
RefreshCwOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,12 +28,17 @@ import {
|
|||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { InspectorConfig } from "@/lib/configurationTypes";
|
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;
|
||||||
@@ -86,7 +92,7 @@ const Sidebar = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h1 className="ml-2 text-lg font-semibold">
|
<h1 className="ml-2 text-lg font-semibold">
|
||||||
MCP Inspector v{version}
|
MCP Inspector v{version}
|
||||||
@@ -97,14 +103,19 @@ const Sidebar = ({
|
|||||||
<div className="p-4 flex-1 overflow-auto">
|
<div className="p-4 flex-1 overflow-auto">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Transport Type</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="transport-type-select"
|
||||||
|
>
|
||||||
|
Transport Type
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={transportType}
|
value={transportType}
|
||||||
onValueChange={(value: "stdio" | "sse") =>
|
onValueChange={(value: "stdio" | "sse") =>
|
||||||
setTransportType(value)
|
setTransportType(value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="transport-type-select">
|
||||||
<SelectValue placeholder="Select transport type" />
|
<SelectValue placeholder="Select transport type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -117,8 +128,11 @@ const Sidebar = ({
|
|||||||
{transportType === "stdio" ? (
|
{transportType === "stdio" ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Command</label>
|
<label className="text-sm font-medium" htmlFor="command-input">
|
||||||
|
Command
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="command-input"
|
||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
@@ -126,8 +140,14 @@ const Sidebar = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Arguments</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="arguments-input"
|
||||||
|
>
|
||||||
|
Arguments
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="arguments-input"
|
||||||
placeholder="Arguments (space-separated)"
|
placeholder="Arguments (space-separated)"
|
||||||
value={args}
|
value={args}
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
onChange={(e) => setArgs(e.target.value)}
|
||||||
@@ -138,8 +158,11 @@ const Sidebar = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">URL</label>
|
<label className="text-sm font-medium" htmlFor="sse-url-input">
|
||||||
|
URL
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="sse-url-input"
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={sseUrl}
|
value={sseUrl}
|
||||||
onChange={(e) => setSseUrl(e.target.value)}
|
onChange={(e) => setSseUrl(e.target.value)}
|
||||||
@@ -151,6 +174,7 @@ const Sidebar = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
|
aria-expanded={showBearerToken}
|
||||||
>
|
>
|
||||||
{showBearerToken ? (
|
{showBearerToken ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -161,8 +185,14 @@ const Sidebar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
{showBearerToken && (
|
{showBearerToken && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Bearer Token</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="bearer-token-input"
|
||||||
|
>
|
||||||
|
Bearer Token
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="bearer-token-input"
|
||||||
placeholder="Bearer Token"
|
placeholder="Bearer Token"
|
||||||
value={bearerToken}
|
value={bearerToken}
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
onChange={(e) => setBearerToken(e.target.value)}
|
||||||
@@ -180,6 +210,8 @@ 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"
|
||||||
|
aria-expanded={showEnvVars}
|
||||||
>
|
>
|
||||||
{showEnvVars ? (
|
{showEnvVars ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -194,6 +226,7 @@ const Sidebar = ({
|
|||||||
<div key={idx} className="space-y-2 pb-4">
|
<div key={idx} className="space-y-2 pb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label={`Environment variable key ${idx + 1}`}
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -236,6 +269,7 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label={`Environment variable value ${idx + 1}`}
|
||||||
type={shownEnvVars.has(key) ? "text" : "password"}
|
type={shownEnvVars.has(key) ? "text" : "password"}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={value}
|
value={value}
|
||||||
@@ -301,6 +335,8 @@ const Sidebar = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowConfig(!showConfig)}
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
|
data-testid="config-button"
|
||||||
|
aria-expanded={showConfig}
|
||||||
>
|
>
|
||||||
{showConfig ? (
|
{showConfig ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -316,11 +352,25 @@ const Sidebar = ({
|
|||||||
const configKey = key as keyof InspectorConfig;
|
const configKey = key as keyof InspectorConfig;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-2">
|
<div key={key} className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<div className="flex items-center gap-1">
|
||||||
{configItem.description}
|
<label
|
||||||
</label>
|
className="text-sm font-medium text-green-600 break-all"
|
||||||
|
htmlFor={`${configKey}-input`}
|
||||||
|
>
|
||||||
|
{configItem.label}
|
||||||
|
</label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{configItem.description}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
{typeof configItem.value === "number" ? (
|
{typeof configItem.value === "number" ? (
|
||||||
<Input
|
<Input
|
||||||
|
id={`${configKey}-input`}
|
||||||
type="number"
|
type="number"
|
||||||
data-testid={`${configKey}-input`}
|
data-testid={`${configKey}-input`}
|
||||||
value={configItem.value}
|
value={configItem.value}
|
||||||
@@ -347,7 +397,7 @@ const Sidebar = ({
|
|||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={`${configKey}-input`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -357,6 +407,7 @@ const Sidebar = ({
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
id={`${configKey}-input`}
|
||||||
data-testid={`${configKey}-input`}
|
data-testid={`${configKey}-input`}
|
||||||
value={configItem.value}
|
value={configItem.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -380,7 +431,13 @@ const Sidebar = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{connectionStatus === "connected" && (
|
{connectionStatus === "connected" && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Button onClick={onConnect}>
|
<Button
|
||||||
|
data-testid="connect-button"
|
||||||
|
onClick={() => {
|
||||||
|
onDisconnect();
|
||||||
|
onConnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -399,33 +456,50 @@ const Sidebar = ({
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
{loggingSupported && connectionStatus === "connected" && (
|
{loggingSupported && connectionStatus === "connected" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Logging Level</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="logging-level-select"
|
||||||
|
>
|
||||||
|
Logging Level
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={logLevel}
|
value={logLevel}
|
||||||
onValueChange={(value: LoggingLevel) =>
|
onValueChange={(value: LoggingLevel) =>
|
||||||
sendLogLevelRequest(value)
|
sendLogLevelRequest(value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="logging-level-select">
|
||||||
<SelectValue placeholder="Select logging level" />
|
<SelectValue placeholder="Select logging level" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Send } from "lucide-react";
|
import { Loader2, Send } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
@@ -31,7 +32,7 @@ const ToolsTab = ({
|
|||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
clearTools: () => void;
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool | null) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: CompatibilityCallToolResult | null;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
@@ -39,6 +40,8 @@ const ToolsTab = ({
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setParams({});
|
setParams({});
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
@@ -52,14 +55,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>
|
||||||
<div className="p-4 border rounded">
|
<JsonView data={toolResult} />
|
||||||
<JsonView data={toolResult} />
|
|
||||||
</div>
|
|
||||||
<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) => (
|
||||||
<div key={idx} className="p-4 border rounded">
|
<JsonView data={error} key={idx} />
|
||||||
<JsonView data={error} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -70,14 +69,17 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">
|
<h4 className="font-semibold mb-2">
|
||||||
Tool Result: {isError ? "Error" : "Success"}
|
Tool Result:{" "}
|
||||||
|
{isError ? (
|
||||||
|
<span className="text-red-600 font-semibold">Error</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 font-semibold">Success</span>
|
||||||
|
)}
|
||||||
</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" && (
|
||||||
<div className="p-4 border rounded">
|
<JsonView data={item.text} isError={isError} />
|
||||||
<JsonView data={item.text} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{item.type === "image" && (
|
{item.type === "image" && (
|
||||||
<img
|
<img
|
||||||
@@ -96,9 +98,7 @@ const ToolsTab = ({
|
|||||||
<p>Your browser does not support audio playback</p>
|
<p>Your browser does not support audio playback</p>
|
||||||
</audio>
|
</audio>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 border rounded">
|
<JsonView data={item.resource} />
|
||||||
<JsonView data={item.resource} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -108,156 +108,176 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||||
<div className="p-4 border rounded">
|
|
||||||
<JsonView data={toolResult.toolResult} />
|
<JsonView data={toolResult.toolResult} />
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
|
<TabsContent value="tools">
|
||||||
<ListPane
|
<div className="grid grid-cols-2 gap-4">
|
||||||
items={tools}
|
<ListPane
|
||||||
listItems={listTools}
|
items={tools}
|
||||||
clearItems={() => {
|
listItems={listTools}
|
||||||
clearTools();
|
clearItems={() => {
|
||||||
setSelectedTool(null);
|
clearTools();
|
||||||
}}
|
setSelectedTool(null);
|
||||||
setSelectedItem={setSelectedTool}
|
}}
|
||||||
renderItem={(tool) => (
|
setSelectedItem={setSelectedTool}
|
||||||
<>
|
renderItem={(tool) => (
|
||||||
<span className="flex-1">{tool.name}</span>
|
<>
|
||||||
<span className="text-sm text-gray-500 text-right">
|
<span className="flex-1">{tool.name}</span>
|
||||||
{tool.description}
|
<span className="text-sm text-gray-500 text-right">
|
||||||
</span>
|
{tool.description}
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
title="Tools"
|
)}
|
||||||
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
title="Tools"
|
||||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
||||||
/>
|
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{selectedTool ? (
|
{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}
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = value as JsonSchemaType;
|
const prop = value as JsonSchemaType;
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={key}
|
htmlFor={key}
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-gray-700"
|
||||||
>
|
>
|
||||||
{key}
|
{key}
|
||||||
</Label>
|
</Label>
|
||||||
{prop.type === "boolean" ? (
|
{prop.type === "boolean" ? (
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
checked={!!params[key]}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={key}
|
||||||
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{prop.description || "Toggle this option"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : prop.type === "string" ? (
|
||||||
|
<Textarea
|
||||||
id={key}
|
id={key}
|
||||||
name={key}
|
name={key}
|
||||||
checked={!!params[key]}
|
placeholder={prop.description}
|
||||||
onCheckedChange={(checked: boolean) =>
|
value={(params[key] as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: checked,
|
[key]: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<label
|
) : prop.type === "object" || prop.type === "array" ? (
|
||||||
htmlFor={key}
|
<div className="mt-1">
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<DynamicJsonForm
|
||||||
>
|
schema={{
|
||||||
{prop.description || "Toggle this option"}
|
type: prop.type,
|
||||||
</label>
|
properties: prop.properties,
|
||||||
</div>
|
description: prop.description,
|
||||||
) : prop.type === "string" ? (
|
items: prop.items,
|
||||||
<Textarea
|
}}
|
||||||
id={key}
|
value={
|
||||||
name={key}
|
(params[key] as JsonValue) ??
|
||||||
placeholder={prop.description}
|
generateDefaultValue(prop)
|
||||||
value={(params[key] as string) ?? ""}
|
}
|
||||||
onChange={(e) =>
|
onChange={(newValue: JsonValue) => {
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: e.target.value,
|
[key]: newValue,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
className="mt-1"
|
/>
|
||||||
/>
|
</div>
|
||||||
) : prop.type === "object" || prop.type === "array" ? (
|
) : (
|
||||||
<div className="mt-1">
|
<Input
|
||||||
<DynamicJsonForm
|
type={
|
||||||
schema={{
|
prop.type === "number" || prop.type === "integer"
|
||||||
type: prop.type,
|
? "number"
|
||||||
properties: prop.properties,
|
: "text"
|
||||||
description: prop.description,
|
|
||||||
items: prop.items,
|
|
||||||
}}
|
|
||||||
value={
|
|
||||||
(params[key] as JsonValue) ??
|
|
||||||
generateDefaultValue(prop)
|
|
||||||
}
|
}
|
||||||
onChange={(newValue: JsonValue) => {
|
id={key}
|
||||||
|
name={key}
|
||||||
|
placeholder={prop.description}
|
||||||
|
value={(params[key] as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: newValue,
|
[key]:
|
||||||
});
|
prop.type === "number" ||
|
||||||
}}
|
prop.type === "integer"
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Input
|
);
|
||||||
type={
|
},
|
||||||
prop.type === "number" || prop.type === "integer"
|
)}
|
||||||
? "number"
|
<Button
|
||||||
: "text"
|
onClick={async () => {
|
||||||
}
|
try {
|
||||||
id={key}
|
setIsToolRunning(true);
|
||||||
name={key}
|
await callTool(selectedTool.name, params);
|
||||||
placeholder={prop.description}
|
} finally {
|
||||||
value={(params[key] as string) ?? ""}
|
setIsToolRunning(false);
|
||||||
onChange={(e) =>
|
}
|
||||||
setParams({
|
}}
|
||||||
...params,
|
disabled={isToolRunning}
|
||||||
[key]:
|
>
|
||||||
prop.type === "number" ||
|
{isToolRunning ? (
|
||||||
prop.type === "integer"
|
<>
|
||||||
? Number(e.target.value)
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
: e.target.value,
|
Running...
|
||||||
})
|
</>
|
||||||
}
|
) : (
|
||||||
className="mt-1"
|
<>
|
||||||
/>
|
<Send className="w-4 h-4 mr-2" />
|
||||||
)}
|
Run Tool
|
||||||
</div>
|
</>
|
||||||
);
|
)}
|
||||||
},
|
</Button>
|
||||||
)}
|
{toolResult && renderToolResult()}
|
||||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
</div>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
) : (
|
||||||
Run Tool
|
<Alert>
|
||||||
</Button>
|
<AlertDescription>
|
||||||
{toolResult && renderToolResult()}
|
Select a tool from the list to view its details and run it
|
||||||
</div>
|
</AlertDescription>
|
||||||
) : (
|
</Alert>
|
||||||
<Alert>
|
)}
|
||||||
<AlertDescription>
|
</div>
|
||||||
Select a tool from the list to view its details and run it
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import DynamicJsonForm from "../DynamicJsonForm";
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
import type { JsonSchemaType } from "../DynamicJsonForm";
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
describe("DynamicJsonForm String Fields", () => {
|
describe("DynamicJsonForm String Fields", () => {
|
||||||
const renderForm = (props = {}) => {
|
const renderForm = (props = {}) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||||
import { InspectorConfig } from "../../lib/configurationTypes";
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
// Mock theme hook
|
// Mock theme hook
|
||||||
jest.mock("../../lib/useTheme", () => ({
|
jest.mock("../../lib/useTheme", () => ({
|
||||||
@@ -36,11 +37,15 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSidebar = (props = {}) => {
|
const renderSidebar = (props = {}) => {
|
||||||
return render(<Sidebar {...defaultProps} {...props} />);
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} {...props} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEnvVarsSection = () => {
|
const openEnvVarsSection = () => {
|
||||||
const button = screen.getByText("Environment Variables");
|
const button = screen.getByTestId("env-vars-button");
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,7 +221,11 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
|
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
|
||||||
|
|
||||||
// Rerender with the updated env
|
// Rerender with the updated env
|
||||||
rerender(<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />);
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
// Second key edit
|
// Second key edit
|
||||||
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
@@ -247,7 +256,11 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
|
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
|
||||||
|
|
||||||
// Rerender with updated env
|
// Rerender with updated env
|
||||||
rerender(<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />);
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
// Value should still be visible
|
// Value should still be visible
|
||||||
const updatedValueInput = screen.getByDisplayValue("test_value");
|
const updatedValueInput = screen.getByDisplayValue("test_value");
|
||||||
@@ -312,7 +325,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
|
|
||||||
describe("Configuration Operations", () => {
|
describe("Configuration Operations", () => {
|
||||||
const openConfigSection = () => {
|
const openConfigSection = () => {
|
||||||
const button = screen.getByText("Configuration");
|
const button = screen.getByTestId("config-button");
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,12 +340,65 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith({
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
expect.objectContaining({
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
value: 5000,
|
label: "Request Timeout",
|
||||||
},
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 5000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update MCP server proxy address", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const proxyAddressInput = screen.getByTestId(
|
||||||
|
"MCP_PROXY_FULL_ADDRESS-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(proxyAddressInput, {
|
||||||
|
target: { value: "http://localhost:8080" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
label: "Inspector Proxy 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: "http://localhost:8080",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update max total timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const maxTotalTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(maxTotalTimeoutInput, {
|
||||||
|
target: { value: "10000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||||
|
label: "Maximum Total Timeout",
|
||||||
|
description:
|
||||||
|
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||||
|
value: 10000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle invalid timeout values entered by user", () => {
|
it("should handle invalid timeout values entered by user", () => {
|
||||||
@@ -346,12 +412,15 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith({
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
expect.objectContaining({
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
value: 0,
|
label: "Request Timeout",
|
||||||
},
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
});
|
value: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should maintain configuration state after multiple updates", () => {
|
it("should maintain configuration state after multiple updates", () => {
|
||||||
@@ -362,7 +431,6 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
openConfigSection();
|
openConfigSection();
|
||||||
|
|
||||||
// First update
|
// First update
|
||||||
const timeoutInput = screen.getByTestId(
|
const timeoutInput = screen.getByTestId(
|
||||||
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
@@ -374,11 +442,13 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
|
|
||||||
// Rerender with the updated config
|
// Rerender with the updated config
|
||||||
rerender(
|
rerender(
|
||||||
<Sidebar
|
<TooltipProvider>
|
||||||
{...defaultProps}
|
<Sidebar
|
||||||
config={updatedConfig}
|
{...defaultProps}
|
||||||
setConfig={setConfig}
|
config={updatedConfig}
|
||||||
/>,
|
setConfig={setConfig}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Second update
|
// Second update
|
||||||
@@ -388,12 +458,15 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
||||||
|
|
||||||
// Verify the final state matches what we expect
|
// Verify the final state matches what we expect
|
||||||
expect(setConfig).toHaveBeenLastCalledWith({
|
expect(setConfig).toHaveBeenLastCalledWith(
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
expect.objectContaining({
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
value: 3000,
|
label: "Request Timeout",
|
||||||
},
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
});
|
value: 3000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import ToolsTab from "../ToolsTab";
|
import ToolsTab from "../ToolsTab";
|
||||||
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
|
|||||||
tools: mockTools,
|
tools: mockTools,
|
||||||
listTools: jest.fn(),
|
listTools: jest.fn(),
|
||||||
clearTools: jest.fn(),
|
clearTools: jest.fn(),
|
||||||
callTool: jest.fn(),
|
callTool: jest.fn(async () => {}),
|
||||||
selectedTool: null,
|
selectedTool: null,
|
||||||
setSelectedTool: jest.fn(),
|
setSelectedTool: jest.fn(),
|
||||||
toolResult: null,
|
toolResult: null,
|
||||||
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should reset input values when switching tools", () => {
|
it("should reset input values when switching tools", async () => {
|
||||||
const { rerender } = renderToolsTab({
|
const { rerender } = renderToolsTab({
|
||||||
selectedTool: mockTools[0],
|
selectedTool: mockTools[0],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter a value in the first tool's input
|
// Enter a value in the first tool's input
|
||||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: "42" } });
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
});
|
||||||
expect(input.value).toBe("42");
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
// Switch to second tool
|
// Switch to second tool
|
||||||
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
|
|||||||
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
expect(newInput.value).toBe("");
|
expect(newInput.value).toBe("");
|
||||||
});
|
});
|
||||||
it("should handle integer type inputs", () => {
|
|
||||||
|
it("should handle integer type inputs", async () => {
|
||||||
renderToolsTab({
|
renderToolsTab({
|
||||||
selectedTool: mockTools[1], // Use the tool with integer type
|
selectedTool: mockTools[1], // Use the tool with integer type
|
||||||
});
|
});
|
||||||
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
|
|||||||
expect(input.value).toBe("42");
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||||
count: 42,
|
count: 42,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should disable button and change text while tool is running", async () => {
|
||||||
|
// Create a promise that we can resolve later
|
||||||
|
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||||
|
const mockPromise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock callTool to return our promise
|
||||||
|
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0],
|
||||||
|
callTool: mockCallTool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||||
|
|
||||||
|
// Click the button and verify immediate state changes
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify button is disabled and text changed
|
||||||
|
expect(submitButton.getAttribute("disabled")).not.toBeNull();
|
||||||
|
expect(submitButton.textContent).toBe("Running...");
|
||||||
|
|
||||||
|
// Resolve the promise to simulate tool completion
|
||||||
|
await act(async () => {
|
||||||
|
if (resolvePromise) {
|
||||||
|
await resolvePromise({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const enum ActionType {
|
||||||
|
ADD_TOAST = "ADD_TOAST",
|
||||||
|
UPDATE_TOAST = "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST = "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST = "REMOVE_TOAST",
|
||||||
|
}
|
||||||
|
|
||||||
|
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: ActionType.REMOVE_TOAST,
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionType.ADD_TOAST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case ActionType.UPDATE_TOAST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case ActionType.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 ActionType.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: ActionType.UPDATE_TOAST,
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () =>
|
||||||
|
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: ActionType.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: ActionType.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 {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ConfigItem = {
|
export type ConfigItem = {
|
||||||
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
};
|
};
|
||||||
@@ -15,4 +16,21 @@ export type InspectorConfig = {
|
|||||||
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
||||||
*/
|
*/
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
|
||||||
|
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
|
||||||
|
*/
|
||||||
|
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
|
||||||
|
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
|
||||||
|
*/
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
|
||||||
|
*/
|
||||||
|
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,39 @@ export const SESSION_KEYS = {
|
|||||||
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 = {
|
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
value: 10000,
|
value: 10000,
|
||||||
},
|
},
|
||||||
|
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
|
||||||
|
label: "Reset Timeout on Progress",
|
||||||
|
description: "Reset timeout on progress notifications",
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||||
|
label: "Maximum Total Timeout",
|
||||||
|
description:
|
||||||
|
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||||
|
value: 60000,
|
||||||
|
},
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
label: "Inspector Proxy 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;
|
} as const;
|
||||||
|
|||||||
164
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
164
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useConnection } from "../useConnection";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ status: "ok" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the SDK dependencies
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ test: "response" });
|
||||||
|
const mockClient = {
|
||||||
|
request: mockRequest,
|
||||||
|
notification: jest.fn(),
|
||||||
|
connect: jest.fn().mockResolvedValue(undefined),
|
||||||
|
close: jest.fn(),
|
||||||
|
getServerCapabilities: jest.fn(),
|
||||||
|
setNotificationHandler: jest.fn(),
|
||||||
|
setRequestHandler: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
|
Client: jest.fn().mockImplementation(() => mockClient),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||||
|
SSEClientTransport: jest.fn(),
|
||||||
|
SseError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the toast hook
|
||||||
|
jest.mock("@/hooks/use-toast", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the auth provider
|
||||||
|
jest.mock("../../auth", () => ({
|
||||||
|
authProvider: {
|
||||||
|
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useConnection", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
transportType: "sse" as const,
|
||||||
|
command: "",
|
||||||
|
args: "",
|
||||||
|
sseUrl: "http://localhost:8080",
|
||||||
|
env: {},
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Request Configuration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the default config values in makeRequest", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
// Connect the client
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for state update
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.makeRequest(mockRequest, mockSchema);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith(
|
||||||
|
mockRequest,
|
||||||
|
mockSchema,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
|
||||||
|
maxTotalTimeout:
|
||||||
|
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
|
||||||
|
resetTimeoutOnProgress:
|
||||||
|
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
|
||||||
|
.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides the default config values when passed in options in makeRequest", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
// Connect the client
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for state update
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.makeRequest(mockRequest, mockSchema, {
|
||||||
|
timeout: 1000,
|
||||||
|
maxTotalTimeout: 2000,
|
||||||
|
resetTimeoutOnProgress: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith(
|
||||||
|
mockRequest,
|
||||||
|
mockSchema,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 1000,
|
||||||
|
maxTotalTimeout: 2000,
|
||||||
|
resetTimeoutOnProgress: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error when mcpClient is not connected", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.makeRequest(mockRequest, mockSchema),
|
||||||
|
).rejects.toThrow("MCP client not connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ResourceReference,
|
ResourceReference,
|
||||||
PromptReference,
|
PromptReference,
|
||||||
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
|
|||||||
wait: number,
|
wait: number,
|
||||||
): (...args: Parameters<T>) => void {
|
): (...args: Parameters<T>) => void {
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
return function (...args: Parameters<T>) {
|
return (...args: Parameters<T>) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => func(...args), wait);
|
timeout = setTimeout(() => {
|
||||||
|
void func(...args);
|
||||||
|
}, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +60,8 @@ export function useCompletionState(
|
|||||||
});
|
});
|
||||||
}, [cleanup]);
|
}, [cleanup]);
|
||||||
|
|
||||||
const requestCompletions = useCallback(
|
const requestCompletions = useMemo(() => {
|
||||||
debounce(
|
return debounce(
|
||||||
async (
|
async (
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
@@ -94,7 +96,7 @@ export function useCompletionState(
|
|||||||
loading: { ...prev.loading, [argName]: false },
|
loading: { ...prev.loading, [argName]: false },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -108,9 +110,8 @@ export function useCompletionState(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
debounceMs,
|
debounceMs,
|
||||||
),
|
);
|
||||||
[handleCompletion, completionsSupported, cleanup, debounceMs],
|
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
|
||||||
);
|
|
||||||
|
|
||||||
// Clear completions when support status changes
|
// Clear completions when support status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ClientRequest,
|
ClientRequest,
|
||||||
CreateMessageRequestSchema,
|
CreateMessageRequestSchema,
|
||||||
ListRootsRequestSchema,
|
ListRootsRequestSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
ResourceUpdatedNotificationSchema,
|
ResourceUpdatedNotificationSchema,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
Request,
|
Request,
|
||||||
@@ -23,15 +22,24 @@ import {
|
|||||||
ResourceListChangedNotificationSchema,
|
ResourceListChangedNotificationSchema,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
PromptListChangedNotificationSchema,
|
PromptListChangedNotificationSchema,
|
||||||
|
Progress,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.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";
|
||||||
|
import {
|
||||||
|
getMCPProxyAddress,
|
||||||
|
getMCPServerRequestMaxTotalTimeout,
|
||||||
|
resetRequestTimeoutOnProgress,
|
||||||
|
} from "@/utils/configUtils";
|
||||||
|
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||||
|
import { InspectorConfig } from "../configurationTypes";
|
||||||
|
|
||||||
interface UseConnectionOptions {
|
interface UseConnectionOptions {
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse";
|
||||||
@@ -39,9 +47,8 @@ interface UseConnectionOptions {
|
|||||||
args: string;
|
args: string;
|
||||||
sseUrl: string;
|
sseUrl: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
proxyServerUrl: string;
|
|
||||||
bearerToken?: string;
|
bearerToken?: string;
|
||||||
requestTimeout?: number;
|
config: InspectorConfig;
|
||||||
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -50,29 +57,22 @@ interface UseConnectionOptions {
|
|||||||
getRoots?: () => any[];
|
getRoots?: () => any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestOptions {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
timeout?: number;
|
|
||||||
suppressToast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConnection({
|
export function useConnection({
|
||||||
transportType,
|
transportType,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
proxyServerUrl,
|
|
||||||
bearerToken,
|
bearerToken,
|
||||||
requestTimeout,
|
config,
|
||||||
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);
|
||||||
@@ -94,38 +94,61 @@ export function useConnection({
|
|||||||
const makeRequest = async <T extends z.ZodType>(
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
request: ClientRequest,
|
request: ClientRequest,
|
||||||
schema: T,
|
schema: T,
|
||||||
options?: RequestOptions,
|
options?: RequestOptions & { suppressToast?: boolean },
|
||||||
): Promise<z.output<T>> => {
|
): Promise<z.output<T>> => {
|
||||||
if (!mcpClient) {
|
if (!mcpClient) {
|
||||||
throw new Error("MCP client not connected");
|
throw new Error("MCP client not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
abortController.abort("Request timed out");
|
// prepare MCP Client request options
|
||||||
}, options?.timeout ?? requestTimeout);
|
const mcpRequestOptions: RequestOptions = {
|
||||||
|
signal: options?.signal ?? abortController.signal,
|
||||||
|
resetTimeoutOnProgress:
|
||||||
|
options?.resetTimeoutOnProgress ??
|
||||||
|
resetRequestTimeoutOnProgress(config),
|
||||||
|
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
|
||||||
|
maxTotalTimeout:
|
||||||
|
options?.maxTotalTimeout ??
|
||||||
|
getMCPServerRequestMaxTotalTimeout(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
|
||||||
|
// This is required by SDK to reset the timeout on progress notifications
|
||||||
|
if (mcpRequestOptions.resetTimeoutOnProgress) {
|
||||||
|
mcpRequestOptions.onprogress = (params: Progress) => {
|
||||||
|
// Add progress notification to `Server Notification` window in the UI
|
||||||
|
if (onNotification) {
|
||||||
|
onNotification({
|
||||||
|
method: "notification/progress",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await mcpClient.request(request, schema, {
|
response = await mcpClient.request(request, schema, mcpRequestOptions);
|
||||||
signal: options?.signal ?? abortController.signal,
|
|
||||||
});
|
|
||||||
pushHistory(request, response);
|
pushHistory(request, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
pushHistory(request, { error: errorMessage });
|
pushHistory(request, { error: errorMessage });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
@@ -167,7 +190,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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -175,7 +202,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +219,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(`${getMCPProxyAddress(config)}/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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -205,33 +254,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(`${getMCPProxyAddress(config)}/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 = {};
|
||||||
@@ -242,7 +296,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 }),
|
||||||
},
|
},
|
||||||
@@ -254,7 +308,6 @@ export function useConnection({
|
|||||||
if (onNotification) {
|
if (onNotification) {
|
||||||
[
|
[
|
||||||
CancelledNotificationSchema,
|
CancelledNotificationSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
ResourceUpdatedNotificationSchema,
|
ResourceUpdatedNotificationSchema,
|
||||||
ResourceListChangedNotificationSchema,
|
ResourceListChangedNotificationSchema,
|
||||||
@@ -282,7 +335,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);
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
|
|||||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
|
return useMemo(
|
||||||
|
() => [theme, setThemeWithSideEffect],
|
||||||
|
[theme, setThemeWithSideEffect],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,146 @@
|
|||||||
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
|
import {
|
||||||
import { JsonValue } from "../../components/DynamicJsonForm";
|
getDataType,
|
||||||
|
tryParseJson,
|
||||||
|
updateValueAtPath,
|
||||||
|
getValueAtPath,
|
||||||
|
} from "../jsonUtils";
|
||||||
|
import type { JsonValue } from "../jsonUtils";
|
||||||
|
|
||||||
|
describe("getDataType", () => {
|
||||||
|
test("should return 'string' for string values", () => {
|
||||||
|
expect(getDataType("hello")).toBe("string");
|
||||||
|
expect(getDataType("")).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'number' for number values", () => {
|
||||||
|
expect(getDataType(123)).toBe("number");
|
||||||
|
expect(getDataType(0)).toBe("number");
|
||||||
|
expect(getDataType(-10)).toBe("number");
|
||||||
|
expect(getDataType(1.5)).toBe("number");
|
||||||
|
expect(getDataType(NaN)).toBe("number");
|
||||||
|
expect(getDataType(Infinity)).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'boolean' for boolean values", () => {
|
||||||
|
expect(getDataType(true)).toBe("boolean");
|
||||||
|
expect(getDataType(false)).toBe("boolean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'undefined' for undefined value", () => {
|
||||||
|
expect(getDataType(undefined)).toBe("undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'object' for object values", () => {
|
||||||
|
expect(getDataType({})).toBe("object");
|
||||||
|
expect(getDataType({ key: "value" })).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'array' for array values", () => {
|
||||||
|
expect(getDataType([])).toBe("array");
|
||||||
|
expect(getDataType([1, 2, 3])).toBe("array");
|
||||||
|
expect(getDataType(["a", "b", "c"])).toBe("array");
|
||||||
|
expect(getDataType([{}, { nested: true }])).toBe("array");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'null' for null value", () => {
|
||||||
|
expect(getDataType(null)).toBe("null");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryParseJson", () => {
|
||||||
|
test("should correctly parse valid JSON object", () => {
|
||||||
|
const jsonString = '{"name":"test","value":123}';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ name: "test", value: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse valid JSON array", () => {
|
||||||
|
const jsonString = '[1,2,3,"test"]';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual([1, 2, 3, "test"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse JSON with whitespace", () => {
|
||||||
|
const jsonString = ' { "name" : "test" } ';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ name: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse nested JSON structures", () => {
|
||||||
|
const jsonString =
|
||||||
|
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
user: {
|
||||||
|
name: "test",
|
||||||
|
details: {
|
||||||
|
age: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: [1, 2, 3],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse empty objects and arrays", () => {
|
||||||
|
expect(tryParseJson("{}").success).toBe(true);
|
||||||
|
expect(tryParseJson("{}").data).toEqual({});
|
||||||
|
|
||||||
|
expect(tryParseJson("[]").success).toBe(true);
|
||||||
|
expect(tryParseJson("[]").data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for non-JSON strings", () => {
|
||||||
|
const nonJsonString = "this is not json";
|
||||||
|
const result = tryParseJson(nonJsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(nonJsonString);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for malformed JSON", () => {
|
||||||
|
const malformedJson = '{"name":"test",}';
|
||||||
|
const result = tryParseJson(malformedJson);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(malformedJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for strings with correct delimiters but invalid JSON", () => {
|
||||||
|
const invalidJson = "{name:test}";
|
||||||
|
const result = tryParseJson(invalidJson);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(invalidJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle edge cases", () => {
|
||||||
|
expect(tryParseJson("").success).toBe(false);
|
||||||
|
expect(tryParseJson("").data).toBe("");
|
||||||
|
|
||||||
|
expect(tryParseJson(" ").success).toBe(false);
|
||||||
|
expect(tryParseJson(" ").data).toBe(" ");
|
||||||
|
|
||||||
|
expect(tryParseJson("null").success).toBe(false);
|
||||||
|
expect(tryParseJson("null").data).toBe("null");
|
||||||
|
|
||||||
|
expect(tryParseJson('"string"').success).toBe(false);
|
||||||
|
expect(tryParseJson('"string"').data).toBe('"string"');
|
||||||
|
|
||||||
|
expect(tryParseJson("123").success).toBe(false);
|
||||||
|
expect(tryParseJson("123").data).toBe("123");
|
||||||
|
|
||||||
|
expect(tryParseJson("true").success).toBe(false);
|
||||||
|
expect(tryParseJson("true").data).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("updateValueAtPath", () => {
|
describe("updateValueAtPath", () => {
|
||||||
// Basic functionality tests
|
// Basic functionality tests
|
||||||
@@ -8,17 +149,17 @@ describe("updateValueAtPath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
||||||
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
|
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
|
||||||
foo: "bar",
|
foo: "bar",
|
||||||
});
|
});
|
||||||
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
|
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
|
||||||
foo: "bar",
|
foo: "bar",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
||||||
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
|
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
|
||||||
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
|
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Object update tests
|
// Object update tests
|
||||||
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns default value when input is null/undefined", () => {
|
test("returns default value when input is null/undefined", () => {
|
||||||
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
|
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
|
||||||
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
|
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
|
||||||
"default",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles array indices correctly", () => {
|
test("handles array indices correctly", () => {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||||
import { JsonSchemaType } from "../../components/DynamicJsonForm";
|
import type { JsonSchemaType } from "../jsonUtils";
|
||||||
|
|
||||||
describe("generateDefaultValue", () => {
|
describe("generateDefaultValue", () => {
|
||||||
test("generates default string", () => {
|
test("generates default string", () => {
|
||||||
|
|||||||
26
client/src/utils/configUtils.ts
Normal file
26
client/src/utils/configUtils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetRequestTimeoutOnProgress = (
|
||||||
|
config: InspectorConfig,
|
||||||
|
): boolean => {
|
||||||
|
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMCPServerRequestMaxTotalTimeout = (
|
||||||
|
config: InspectorConfig,
|
||||||
|
): number => {
|
||||||
|
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
||||||
|
};
|
||||||
@@ -1,7 +1,66 @@
|
|||||||
import { JsonValue } from "../components/DynamicJsonForm";
|
export type JsonValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type JsonSchemaType = {
|
||||||
|
type:
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null";
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: JsonValue;
|
||||||
|
properties?: Record<string, JsonSchemaType>;
|
||||||
|
items?: JsonSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
export type JsonObject = { [key: string]: JsonValue };
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type DataType =
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "bigint"
|
||||||
|
| "boolean"
|
||||||
|
| "symbol"
|
||||||
|
| "undefined"
|
||||||
|
| "object"
|
||||||
|
| "function"
|
||||||
|
| "array"
|
||||||
|
| "null";
|
||||||
|
|
||||||
|
export function getDataType(value: JsonValue): DataType {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === null) return "null";
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a value at a specific path in a nested JSON structure
|
* Updates a value at a specific path in a nested JSON structure
|
||||||
* @param obj The original JSON value
|
* @param obj The original JSON value
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
|
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||||
import { JsonObject } from "./jsonPathUtils";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a default value based on a JSON schema type
|
* Generates a default value based on a JSON schema type
|
||||||
|
|||||||
3047
package-lock.json
generated
3047
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.8.0",
|
"version": "0.9.0",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
"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.8.0",
|
"@modelcontextprotocol/inspector-client": "^0.9.0",
|
||||||
"@modelcontextprotocol/inspector-server": "^0.8.0",
|
"@modelcontextprotocol/inspector-server": "^0.9.0",
|
||||||
"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.8.0",
|
"version": "0.9.0",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.9.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",
|
||||||
|
|||||||
@@ -38,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);
|
||||||
|
|
||||||
@@ -70,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;
|
||||||
@@ -172,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({
|
||||||
|
|||||||
Reference in New Issue
Block a user