Merge branch 'main' into cli-and-config-file-support
This commit is contained in:
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! This guide explains how to get involve
|
|||||||
1. Fork the repository and clone it locally
|
1. Fork the repository and clone it locally
|
||||||
2. Install dependencies with `npm install`
|
2. Install dependencies with `npm install`
|
||||||
3. Run `npm run dev` to start both client and server in development mode
|
3. Run `npm run dev` to start both client and server in development mode
|
||||||
4. Use the web UI at http://127.0.0.1:5173 to interact with the inspector
|
4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector
|
||||||
|
|
||||||
## Development Process & Pull Requests
|
## Development Process & Pull Requests
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/inde
|
|||||||
npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag
|
npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag
|
||||||
```
|
```
|
||||||
|
|
||||||
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
|
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
|
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
|
||||||
@@ -50,9 +50,14 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
|
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function handleError(error) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||||
}
|
}
|
||||||
async function runWebClient(args) {
|
async function runWebClient(args) {
|
||||||
const inspectorServerPath = resolve(
|
const inspectorServerPath = resolve(
|
||||||
|
|||||||
@@ -15,5 +15,19 @@ const server = http.createServer((request, response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.PORT || 5173;
|
const port = process.env.PORT || 6274;
|
||||||
server.listen(port, () => {});
|
server.on("listening", () => {
|
||||||
|
console.log(
|
||||||
|
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
server.on("error", (err) => {
|
||||||
|
if (err.message.includes(`EADDRINUSE`)) {
|
||||||
|
console.error(
|
||||||
|
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} ❌ `,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(port);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.7.0",
|
"version": "0.8.2",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --port 6274",
|
||||||
"test": "jest --config jest.config.cjs",
|
"test": "jest --config jest.config.cjs",
|
||||||
"test:watch": "jest --config jest.config.cjs --watch"
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.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") ?? "3000";
|
|
||||||
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,11 +156,12 @@ const App = () => {
|
|||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
mcpClient,
|
mcpClient,
|
||||||
requestHistory,
|
requestHistory,
|
||||||
makeRequest: makeConnectionRequest,
|
makeRequest,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
connect: connectMcpServer,
|
connect: connectMcpServer,
|
||||||
|
disconnect: disconnectMcpServer,
|
||||||
} = useConnection({
|
} = useConnection({
|
||||||
transportType,
|
transportType,
|
||||||
command,
|
command,
|
||||||
@@ -152,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]);
|
||||||
},
|
},
|
||||||
@@ -196,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);
|
||||||
@@ -207,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);
|
||||||
@@ -228,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(() => {
|
||||||
@@ -260,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);
|
||||||
}
|
}
|
||||||
@@ -284,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 } : {},
|
||||||
@@ -297,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
|
||||||
@@ -314,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 },
|
||||||
@@ -327,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 },
|
||||||
@@ -343,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 },
|
||||||
@@ -358,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 } : {},
|
||||||
@@ -371,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 },
|
||||||
@@ -383,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 } : {},
|
||||||
@@ -396,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 () => {
|
||||||
@@ -418,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 },
|
||||||
@@ -458,6 +497,7 @@ const App = () => {
|
|||||||
bearerToken={bearerToken}
|
bearerToken={bearerToken}
|
||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
@@ -617,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) => {
|
||||||
@@ -634,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
RotateCcw,
|
||||||
Settings,
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
RefreshCwOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -25,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;
|
||||||
@@ -44,6 +52,7 @@ interface SidebarProps {
|
|||||||
bearerToken: string;
|
bearerToken: string;
|
||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
logLevel: LoggingLevel;
|
logLevel: LoggingLevel;
|
||||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
@@ -67,6 +76,7 @@ const Sidebar = ({
|
|||||||
bearerToken,
|
bearerToken,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
logLevel,
|
logLevel,
|
||||||
sendLogLevelRequest,
|
sendLogLevelRequest,
|
||||||
@@ -82,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}
|
||||||
@@ -93,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>
|
||||||
@@ -113,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)}
|
||||||
@@ -122,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)}
|
||||||
@@ -134,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)}
|
||||||
@@ -147,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" />
|
||||||
@@ -157,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)}
|
||||||
@@ -176,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" />
|
||||||
@@ -190,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) => {
|
||||||
@@ -232,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}
|
||||||
@@ -297,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" />
|
||||||
@@ -312,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}
|
||||||
@@ -343,7 +397,7 @@ const Sidebar = ({
|
|||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={`${configKey}-input`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -353,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) => {
|
||||||
@@ -374,40 +429,77 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button className="w-full" onClick={onConnect}>
|
{connectionStatus === "connected" && (
|
||||||
<Play className="w-4 h-4 mr-2" />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Connect
|
<Button
|
||||||
</Button>
|
data-testid="connect-button"
|
||||||
|
onClick={() => {
|
||||||
|
onDisconnect();
|
||||||
|
onConnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onDisconnect}>
|
||||||
|
<RefreshCwOff className="w-4 h-4 mr-2" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connectionStatus !== "connected" && (
|
||||||
|
<Button className="w-full" onClick={onConnect}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-2 mb-4">
|
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={`w-2 h-2 rounded-full ${(() => {
|
||||||
connectionStatus === "connected"
|
switch (connectionStatus) {
|
||||||
? "bg-green-500"
|
case "connected":
|
||||||
: connectionStatus === "error"
|
return "bg-green-500";
|
||||||
? "bg-red-500"
|
case "error":
|
||||||
: "bg-gray-500"
|
return "bg-red-500";
|
||||||
}`}
|
case "error-connecting-to-proxy":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
})()}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{connectionStatus === "connected"
|
{(() => {
|
||||||
? "Connected"
|
switch (connectionStatus) {
|
||||||
: connectionStatus === "error"
|
case "connected":
|
||||||
? "Connection Error"
|
return "Connected";
|
||||||
: "Disconnected"}
|
case "error":
|
||||||
|
return "Connection Error, is your MCP server running?";
|
||||||
|
case "error-connecting-to-proxy":
|
||||||
|
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
|
||||||
|
default:
|
||||||
|
return "Disconnected";
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
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 { AlertCircle, 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";
|
||||||
@@ -27,12 +28,11 @@ const ToolsTab = ({
|
|||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
toolResult,
|
toolResult,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
error,
|
|
||||||
}: {
|
}: {
|
||||||
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;
|
||||||
@@ -40,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]);
|
||||||
@@ -53,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>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -71,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
|
||||||
@@ -97,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>
|
||||||
))}
|
))}
|
||||||
@@ -109,157 +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">
|
||||||
{error ? (
|
{selectedTool ? (
|
||||||
<Alert variant="destructive">
|
<div className="space-y-4">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<p className="text-sm text-gray-600">
|
||||||
<AlertTitle>Error</AlertTitle>
|
{selectedTool.description}
|
||||||
<AlertDescription>{error}</AlertDescription>
|
</p>
|
||||||
</Alert>
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
) : selectedTool ? (
|
([key, value]) => {
|
||||||
<div className="space-y-4">
|
const prop = value as JsonSchemaType;
|
||||||
<p className="text-sm text-gray-600">
|
return (
|
||||||
{selectedTool.description}
|
<div key={key}>
|
||||||
</p>
|
<Label
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
htmlFor={key}
|
||||||
([key, value]) => {
|
className="block text-sm font-medium text-gray-700"
|
||||||
const prop = value as JsonSchemaType;
|
>
|
||||||
return (
|
{key}
|
||||||
<div key={key}>
|
</Label>
|
||||||
<Label
|
{prop.type === "boolean" ? (
|
||||||
htmlFor={key}
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
className="block text-sm font-medium text-gray-700"
|
<Checkbox
|
||||||
>
|
id={key}
|
||||||
{key}
|
name={key}
|
||||||
</Label>
|
checked={!!params[key]}
|
||||||
{prop.type === "boolean" ? (
|
onCheckedChange={(checked: boolean) =>
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
setParams({
|
||||||
<Checkbox
|
...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" ? "number" : "text"}
|
},
|
||||||
id={key}
|
)}
|
||||||
name={key}
|
<Button
|
||||||
placeholder={prop.description}
|
onClick={async () => {
|
||||||
value={(params[key] as string) ?? ""}
|
try {
|
||||||
onChange={(e) =>
|
setIsToolRunning(true);
|
||||||
setParams({
|
await callTool(selectedTool.name, params);
|
||||||
...params,
|
} finally {
|
||||||
[key]:
|
setIsToolRunning(false);
|
||||||
prop.type === "number"
|
}
|
||||||
? Number(e.target.value)
|
}}
|
||||||
: e.target.value,
|
disabled={isToolRunning}
|
||||||
})
|
>
|
||||||
}
|
{isToolRunning ? (
|
||||||
className="mt-1"
|
<>
|
||||||
/>
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
)}
|
Running...
|
||||||
</div>
|
</>
|
||||||
);
|
) : (
|
||||||
},
|
<>
|
||||||
)}
|
<Send className="w-4 h-4 mr-2" />
|
||||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
Run Tool
|
||||||
<Send className="w-4 h-4 mr-2" />
|
</>
|
||||||
Run Tool
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{toolResult && renderToolResult()}
|
{toolResult && renderToolResult()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Select a tool from the list to view its details and run it
|
Select a tool from the list to view its details and run it
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</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", () => ({
|
||||||
@@ -26,6 +27,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
bearerToken: "",
|
bearerToken: "",
|
||||||
setBearerToken: jest.fn(),
|
setBearerToken: jest.fn(),
|
||||||
onConnect: jest.fn(),
|
onConnect: jest.fn(),
|
||||||
|
onDisconnect: jest.fn(),
|
||||||
stdErrNotifications: [],
|
stdErrNotifications: [],
|
||||||
logLevel: "info" as const,
|
logLevel: "info" as const,
|
||||||
sendLogLevelRequest: jest.fn(),
|
sendLogLevelRequest: jest.fn(),
|
||||||
@@ -35,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,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");
|
||||||
@@ -246,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");
|
||||||
@@ -311,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,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", () => {
|
||||||
@@ -345,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", () => {
|
||||||
@@ -361,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",
|
||||||
@@ -373,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
|
||||||
@@ -387,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,5 +1,6 @@
|
|||||||
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 ToolsTab from "../ToolsTab";
|
import ToolsTab from "../ToolsTab";
|
||||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
@@ -16,6 +17,16 @@ describe("ToolsTab", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "tool3",
|
||||||
|
description: "Integer tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
count: { type: "integer" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "tool2",
|
name: "tool2",
|
||||||
description: "Second tool",
|
description: "Second tool",
|
||||||
@@ -32,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,
|
||||||
@@ -48,20 +59,22 @@ 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
|
||||||
rerender(
|
rerender(
|
||||||
<Tabs defaultValue="tools">
|
<Tabs defaultValue="tools">
|
||||||
<ToolsTab {...defaultProps} selectedTool={mockTools[1]} />
|
<ToolsTab {...defaultProps} selectedTool={mockTools[2]} />
|
||||||
</Tabs>,
|
</Tabs>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,4 +82,63 @@ 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", async () => {
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[1], // Use the tool with integer type
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton", {
|
||||||
|
name: /count/i,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(input).toHaveProperty("type", "number");
|
||||||
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||||
|
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);
|
||||||
@@ -321,6 +377,14 @@ export function useConnection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await mcpClient?.close();
|
||||||
|
setMcpClient(null);
|
||||||
|
setConnectionStatus("disconnected");
|
||||||
|
setCompletionsSupported(false);
|
||||||
|
setServerCapabilities(null);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
@@ -331,5 +395,6 @@ export function useConnection({
|
|||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
connect,
|
connect,
|
||||||
|
disconnect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.7.0",
|
"version": "0.8.2",
|
||||||
"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",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
StdioClientTransport,
|
StdioClientTransport,
|
||||||
getDefaultEnvironment,
|
getDefaultEnvironment,
|
||||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { findActualExecutable } from "spawn-rx";
|
import { findActualExecutable } from "spawn-rx";
|
||||||
@@ -37,7 +38,7 @@ app.use(cors());
|
|||||||
|
|
||||||
let webAppTransports: SSEServerTransport[] = [];
|
let webAppTransports: SSEServerTransport[] = [];
|
||||||
|
|
||||||
const createTransport = async (req: express.Request) => {
|
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
console.log("Query parameters:", query);
|
console.log("Query parameters:", query);
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const createTransport = async (req: express.Request) => {
|
|||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||||
if (req.headers[key] === undefined) {
|
if (req.headers[key] === undefined) {
|
||||||
continue;
|
continue;
|
||||||
@@ -98,12 +100,14 @@ const createTransport = async (req: express.Request) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let backingServerTransport: Transport | undefined;
|
||||||
|
|
||||||
app.get("/sse", async (req, res) => {
|
app.get("/sse", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log("New SSE connection");
|
console.log("New SSE connection");
|
||||||
|
|
||||||
let backingServerTransport;
|
|
||||||
try {
|
try {
|
||||||
|
await backingServerTransport?.close();
|
||||||
backingServerTransport = await createTransport(req);
|
backingServerTransport = await createTransport(req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
@@ -169,6 +173,12 @@ app.post("/message", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/health", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/config", (req, res) => {
|
app.get("/config", (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -182,17 +192,17 @@ app.get("/config", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 6277;
|
||||||
|
|
||||||
try {
|
const server = app.listen(PORT);
|
||||||
const server = app.listen(PORT);
|
server.on("listening", () => {
|
||||||
|
console.log(`⚙️ Proxy server listening on port ${PORT}`);
|
||||||
server.on("listening", () => {
|
});
|
||||||
const addr = server.address();
|
server.on("error", (err) => {
|
||||||
const port = typeof addr === "string" ? addr : addr?.port;
|
if (err.message.includes(`EADDRINUSE`)) {
|
||||||
console.log(`Proxy server listening on port ${port}`);
|
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
|
||||||
});
|
} else {
|
||||||
} catch (error) {
|
console.error(err.message);
|
||||||
console.error("Failed to start server:", error);
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user