Compare commits

..

66 Commits
0.8.0 ... 0.9.0

Author SHA1 Message Date
Cliff Hall
8f7680d72b Merge pull request #311 from olaservo/bump-version-0-9-0
Bump version to 0.9.0
2025-04-14 11:36:19 -04:00
olaservo
e20c4fb4ff Bump version to 0.9.0 2025-04-14 08:23:08 -07:00
Ola Hungerford
645a256994 Merge pull request #274 from nathanArseneau/dark-mode-fix
fix dark mode color and padding for the tab item
2025-04-12 06:04:28 -07:00
Nathan Arseneau
98ea4a12d6 Merge branch 'main' into dark-mode-fix 2025-04-11 20:13:31 -04:00
Cliff Hall
70dc1b766e Merge pull request #298 from Kavyapriya-1804/fix-lint-warnings-and-errors-in-client
Fix - Lint warnings and errors in client
2025-04-11 17:38:13 -04:00
KavyapriyaJG
eab6b42ac6 Fix - Lint warnings and errors in client
Lint issues fixed:
1. Removed unused error variable in catch block
2. Changed 'ActionTypes' type to enum
3. Removed unnecessary interface extensions
4. Removed unused export component
5. Added the missing dependencies to useMemo
6. Fixed the inline function issue by changing it to useMemo
2025-04-12 02:10:37 +05:30
Tadas Antanavicius
4a2ba7ce6e Merge pull request #290 from cliffhall/fix-reconnect
Fix Reconnect handler
2025-04-10 13:12:53 -07:00
Cliff Hall
4f6c30904a Merge branch 'main' into fix-reconnect 2025-04-09 14:07:11 -04:00
Cliff Hall
a3a1ad47fa Merge pull request #275 from kavinkumar807/highlight-tool-errors-in-red-text
feat: highlight tool errors in red text
2025-04-09 13:30:19 -04:00
kavinkumarbaskar
1596973b05 Merge branch 'main' into highlight-tool-errors-in-red-text 2025-04-09 22:47:12 +05:30
Cliff Hall
6c07559573 Merge pull request #266 from morinokami/patch-3
Enhance Sidebar component accessibility
2025-04-09 13:10:00 -04:00
cliffhall
727e0753e4 This fixes #273
* In Sidebar.tsx
  - In Reconnect button handler, call onDisconnect before onConnect

See: https://github.com/modelcontextprotocol/inspector/issues/273#issuecomment-2789895596
2025-04-09 11:52:31 -04:00
Cliff Hall
aad3262940 Merge pull request #264 from samuel871211/addUnitTest
refactor(json): Consolidate JSON utilities and type definitions
2025-04-09 10:28:33 -04:00
Shinya Fujino
7bc1088159 Merge remote-tracking branch 'origin/main' into patch-3 2025-04-09 22:16:28 +09:00
kavinkumarbaskar
256972a366 Merge branch 'main' into highlight-tool-errors-in-red-text 2025-04-09 17:40:30 +05:30
yushengchen
068d43226f refactor: remove as any of file jsonUtils.test.ts
refactor: extract `DataType` as the return type of function `getDataType`
2025-04-09 09:24:42 +08:00
yushengchen
fe74dbea74 refactor(json): Consolidate JSON utilities and type definitions
Modified files:
- client/src/components/DynamicJsonForm.tsx
- client/src/components/JsonView.tsx
- client/src/components/ToolsTab.tsx
- client/src/components/__tests__/DynamicJsonForm.test.tsx
- client/src/utils/__tests__/jsonPathUtils.test.ts → client/src/utils/__tests__/jsonUtils.test.ts
- client/src/utils/__tests__/schemaUtils.test.ts
- client/src/utils/jsonPathUtils.ts → client/src/utils/jsonUtils.ts
- client/src/utils/schemaUtils.ts

Descriptions:
- Move JSON type definitions from DynamicJsonForm to centralized jsonUtils
- Consolidate utility functions (getDataType, tryParseJson) into jsonUtils
- Rename jsonPathUtils to jsonUtils for better semantic clarity
- Add comprehensive test coverage for new utility functions
- Update imports across all affected components
- Improve type references consistency throughout the codebase
2025-04-09 09:20:02 +08:00
Cliff Hall
da4e2fa844 Merge pull request #271 from pulkitsharma07/progress_flow_support
feat: Progress Support for Long Running Tool Calls 
2025-04-08 16:05:55 -04:00
Pulkit Sharma
9c6663c4c2 add label to configItem, use that in UI 2025-04-09 01:24:41 +05:30
Pulkit Sharma
cf20fe9142 merge main 2025-04-09 01:23:42 +05:30
Cliff Hall
6659d549ea Merge pull request #265 from morinokami/patch-2
Fix inconsistent gap between TabsList and TabsContent
2025-04-08 15:48:02 -04:00
Pulkit Sharma
48917ca4e5 don't show full config keys 2025-04-09 01:12:02 +05:30
Pulkit Sharma
779ca20568 Merge branch 'main' into progress_flow_support 2025-04-09 00:42:59 +05:30
Pulkit Sharma
f71613227f rename 2025-04-09 00:40:42 +05:30
Cliff Hall
4053aa122d Merge pull request #278 from modelcontextprotocol/dependabot/npm_and_yarn/npm_and_yarn-13db4ee2ba
Bump vite from 5.4.16 to 5.4.17 in the npm_and_yarn group across 1 directory
2025-04-07 14:01:38 -04:00
Cliff Hall
ad7865a6ab Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-13db4ee2ba 2025-04-07 13:59:03 -04:00
Cliff Hall
d69934afcb Merge pull request #263 from samuel871211/moveTypesPackageToDevDependencies
chore: move `@types/prismjs` to `devDependencies`
2025-04-07 13:58:42 -04:00
dependabot[bot]
c5dc4ded5c Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.16 to 5.4.17
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.17
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 17:30:21 +00:00
Cliff Hall
4a3f032f59 Merge pull request #277 from cliffhall/use-sdk-1.9.0
Use sdk 1.9.0
2025-04-07 12:42:55 -04:00
cliffhall
6bcf1531c3 Bump version to 0.8.2
* In README
  - Point to inspector screenshot's rawcontent address so it doesn't appear as a broken image in the NPM package view.
2025-04-07 12:32:06 -04:00
cliffhall
a506e4c419 Bump version to 0.8.2
* In package.json
  - Use inspector-[client|server] version 0.8.2
* In [client|server]/package.json
  - New version to 0.8.2
  - Use typescript SDK version 1.9.0
* In package-lock.json
  - above changes
  - sdk dependency's version increased: node_modules/@modelcontextprotocol/sdk/node_modules/pkce-challenge
2025-04-07 12:19:34 -04:00
kavinkumarbaskar
ca1854b071 ran npm run prettier-fix command to fix the code styling 2025-04-05 23:32:10 +05:30
kavinkumarbaskar
a3be8f6376 feat: highlight tool errors in red text 2025-04-05 23:24:10 +05:30
Nathan Arseneau
8d20044b33 fix darkmode color and padding for the tab item 2025-04-05 11:47:44 -04:00
Pulkit Sharma
6be05c5278 update docs 2025-04-05 13:06:25 +05:30
Pulkit Sharma
f36061e4b9 merge main 2025-04-05 01:49:11 +05:30
Pulkit Sharma
e35343537c Add proper support for progress flow during tool calling 2025-04-05 01:46:57 +05:30
Shinya Fujino
402cde025b Enhance Sidebar component accessibility 2025-04-05 00:51:31 +09:00
Shinya Fujino
0fd2e12c7b Fix inconsistent gap between TabsList and TabsContent 2025-04-04 22:39:55 +09:00
yusheng chen
b8ab30fdf3 chore: move @types/prismjs to devDependencies 2025-04-04 19:26:11 +08:00
Cliff Hall
3032a67d4e Merge pull request #261 from cliffhall/bump-to-version-0.8.1
Bump to version 0.8.1 - package-lock.json
2025-04-03 18:07:31 -04:00
Cliff Hall
f0651baf4a Merge branch 'modelcontextprotocol:main' into bump-to-version-0.8.1 2025-04-03 16:33:52 -04:00
cliffhall
3f73ec83a2 Bump version to 0.8.1
* package-lock.json
2025-04-03 16:32:59 -04:00
Pulkit Sharma
06f237b1de Merge branch 'main' into offload_request_to_sdk 2025-04-04 01:45:11 +05:30
Pulkit Sharma
a8ffc704f0 add support for progress flow 2025-04-04 01:44:30 +05:30
Cliff Hall
bdab26dbeb Merge pull request #260 from cliffhall/bump-to-version-0.8.1
Bump version to 0.8.1
2025-04-03 15:36:00 -04:00
Cliff Hall
0f075af42c Merge branch 'main' into bump-to-version-0.8.1 2025-04-03 14:48:47 -04:00
Cliff Hall
06fcc74638 Merge pull request #247 from max-stytch/fix/use-https-protocol
fix: Use same protocol for proxy server URL
2025-04-03 14:48:02 -04:00
cliffhall
9092c780f7 Bump version to 0.8.1
* In client/package.json
  - version bumped to 0.8.1
  - typescript SDK version 0.8.0 (latest)
* In server/package.json
  - version bumped to 0.8.1
  - typescript SDK version 0.8.0 (latest)
* In client/package.json
  - version bumped to 0.8.1
  - inspector-client bumped to 0.8.1
  - inspector-server bumped to 0.8.1
2025-04-03 14:30:59 -04:00
Cliff Hall
a75dd7ba1f Merge pull request #253 from max-stytch/max/fix-reloads
fix: Do not reconnect on rerender
2025-04-03 13:38:11 -04:00
Maxwell Gerber
a414033354 fix: Use same protocol for proxy server URL 2025-04-03 09:18:11 -07:00
Cliff Hall
ce1a9d3905 Merge pull request #243 from pulkitsharma07/add_proxy_config
Add MCP proxy address config support, better error messages, redesigned Config panel
2025-04-02 18:40:57 -04:00
Maxwell Gerber
0bd51fa84a fix: Do not reconnect on rerender 2025-04-02 15:18:33 -07:00
Pulkit Sharma
2a544294ba merge main 2025-04-03 00:47:21 +05:30
Cliff Hall
897e637db4 Merge pull request #244 from NicolasMontone/feat/new-alert-and-copy-button
feat: add new toast and add copy button to JSON.
2025-04-02 13:04:54 -04:00
NicolasMontone
5db5fc26c7 fix prettier 2025-04-02 10:42:52 -03:00
NicolasMontone
8b31f495ba fix unkown type. 2025-04-02 10:41:33 -03:00
NicolasMontone
c964ff5cfe Use copy button insde JSON view component 2025-04-02 10:39:39 -03:00
Pulkit Sharma
e69bfc58bc prettier-fix 2025-04-02 13:45:09 +05:30
Pulkit Sharma
debb00344a Merge branch 'main' into add_proxy_config 2025-04-02 13:43:42 +05:30
NicolasMontone
c9ee22b781 fix(Select): add missing style. 2025-04-01 19:23:57 -03:00
NicolasMontone
cc70fbd0f5 add ring color 2025-04-01 19:14:32 -03:00
NicolasMontone
8586d63e6d Add shadcn alert to match project design 2025-04-01 17:38:25 -03:00
NicolasMontone
539de0fd85 add copy button 2025-04-01 17:18:40 -03:00
Pulkit Sharma
0dcd10c1dd Update readme 2025-04-01 19:48:48 +05:30
Pulkit Sharma
51c7eda6a6 Add MCP proxy address config support, better error messages 2025-04-01 19:21:19 +05:30
42 changed files with 2399 additions and 3333 deletions

View File

@@ -2,7 +2,7 @@
The MCP inspector is a developer tool for testing and debugging MCP servers.
![MCP Inspector Screenshot](mcp-inspector.png)
![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png)
## Running the Inspector
@@ -48,11 +48,16 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
### Configuration
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Name | Purpose | Default Value |
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
These settings can be adjusted in real-time through the UI and will persist across sessions.
### From this repository

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.8.0",
"version": "0.9.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.9.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@@ -32,7 +32,8 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@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",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@@ -42,7 +43,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1",
"react-toastify": "^10.0.6",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
@@ -54,6 +54,7 @@
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",

View File

@@ -20,7 +20,6 @@ import {
import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -33,7 +32,6 @@ import {
MessageSquare,
} from "lucide-react";
import { toast } from "react-toastify";
import { z } from "zod";
import "./App.css";
import ConsoleTab from "./components/ConsoleTab";
@@ -47,13 +45,14 @@ import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "6277";
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const App = () => {
const { toast } = useToast();
// Handle OAuth callback route
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
@@ -95,7 +94,24 @@ const App = () => {
const [config, setConfig] = useState<InspectorConfig>(() => {
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>(() => {
return localStorage.getItem("lastBearerToken") || "";
@@ -140,7 +156,7 @@ const App = () => {
serverCapabilities,
mcpClient,
requestHistory,
makeRequest: makeConnectionRequest,
makeRequest,
sendNotification,
handleCompletion,
completionsSupported,
@@ -153,8 +169,7 @@ const App = () => {
sseUrl,
env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL,
requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
@@ -197,8 +212,13 @@ const App = () => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
const hasProcessedRef = useRef(false);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
if (hasProcessedRef.current) {
// Only try to connect once
return;
}
const serverUrl = params.get("serverUrl");
if (serverUrl) {
setSseUrl(serverUrl);
@@ -208,14 +228,18 @@ const App = () => {
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// 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
connectMcpServer();
}
}, [connectMcpServer]);
}, [connectMcpServer, toast]);
useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`)
fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json())
.then((data) => {
setEnv(data.defaultEnvironment);
@@ -229,6 +253,7 @@ const App = () => {
.catch((error) =>
console.error("Error fetching default environment:", error),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -261,13 +286,13 @@ const App = () => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
const makeRequest = async <T extends z.ZodType>(
const sendMCPRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
try {
const response = await makeConnectionRequest(request, schema);
const response = await makeRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
@@ -285,7 +310,7 @@ const App = () => {
};
const listResources = async () => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "resources/list" as const,
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
@@ -298,7 +323,7 @@ const App = () => {
};
const listResourceTemplates = async () => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "resources/templates/list" as const,
params: nextResourceTemplateCursor
@@ -315,7 +340,7 @@ const App = () => {
};
const readResource = async (uri: string) => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "resources/read" as const,
params: { uri },
@@ -328,7 +353,7 @@ const App = () => {
const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) {
await makeRequest(
await sendMCPRequest(
{
method: "resources/subscribe" as const,
params: { uri },
@@ -344,7 +369,7 @@ const App = () => {
const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) {
await makeRequest(
await sendMCPRequest(
{
method: "resources/unsubscribe" as const,
params: { uri },
@@ -359,7 +384,7 @@ const App = () => {
};
const listPrompts = async () => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "prompts/list" as const,
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
@@ -372,7 +397,7 @@ const App = () => {
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "prompts/get" as const,
params: { name, arguments: args },
@@ -384,7 +409,7 @@ const App = () => {
};
const listTools = async () => {
const response = await makeRequest(
const response = await sendMCPRequest(
{
method: "tools/list" as const,
params: nextToolCursor ? { cursor: nextToolCursor } : {},
@@ -397,21 +422,34 @@ const App = () => {
};
const callTool = async (name: string, params: Record<string, unknown>) => {
const response = await makeRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: params,
_meta: {
progressToken: progressTokenRef.current++,
try {
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: params,
_meta: {
progressToken: progressTokenRef.current++,
},
},
},
},
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
}
};
const handleRootsChange = async () => {
@@ -419,7 +457,7 @@ const App = () => {
};
const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest(
await sendMCPRequest(
{
method: "logging/setLevel" as const,
params: { level },
@@ -619,9 +657,10 @@ const App = () => {
setTools([]);
setNextToolCursor(undefined);
}}
callTool={(name, params) => {
callTool={async (name, params) => {
clearError("tools");
callTool(name, params);
setToolResult(null);
await callTool(name, params);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@@ -636,7 +675,7 @@ const App = () => {
<ConsoleTab />
<PingTab
onPingClick={() => {
void makeRequest(
void sendMCPRequest(
{
method: "ping" as const,
},

View File

@@ -3,33 +3,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
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;
};
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
interface DynamicJsonFormProps {
schema: JsonSchemaType;

View File

@@ -1,5 +1,4 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { Copy } from "lucide-react";
import { useState } from "react";
import JsonView from "./JsonView";
@@ -25,10 +24,6 @@ const HistoryAndNotifications = ({
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
@@ -68,16 +63,12 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-blue-600">
Request:
</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>
<JsonView
data={request.request}
className="bg-background"
/>
</div>
{request.response && (
<div className="mt-2">
@@ -85,16 +76,11 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-green-600">
Response:
</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>
<JsonView
data={request.response}
className="bg-background"
/>
</div>
)}
</>
@@ -134,20 +120,11 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-purple-600">
Details:
</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>
<JsonView
data={JSON.stringify(notification, null, 2)}
className="bg-background"
/>
</div>
)}
</li>

View File

@@ -1,45 +1,96 @@
import { useState, memo } from "react";
import { JsonValue } from "./DynamicJsonForm";
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import type { JsonValue } from "@/utils/jsonUtils";
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 {
data: unknown;
name?: string;
initialExpandDepth?: number;
}
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 };
}
className?: string;
withCopyButton?: boolean;
isError?: boolean;
}
const JsonView = memo(
({ data, name, initialExpandDepth = 3 }: JsonViewProps) => {
const normalizedData =
typeof data === "string"
({
data,
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).data
: data
: data;
}, [data]);
const handleCopy = useCallback(() => {
try {
navigator.clipboard.writeText(
typeof normalizedData === "string"
? normalizedData
: JSON.stringify(normalizedData, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}, [toast, normalizedData]);
return (
<div className="font-mono text-sm transition-all duration-300 ">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
/>
<div className={clsx("p-4 border rounded relative", className)}>
{withCopyButton && (
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleCopy}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
)}
<div className="font-mono text-sm transition-all duration-300">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
isError={isError}
/>
</div>
</div>
);
},
@@ -52,28 +103,28 @@ interface JsonNodeProps {
name?: string;
depth: number;
initialExpandDepth: number;
isError?: boolean;
}
const JsonNode = memo(
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
({
data,
name,
depth = 0,
initialExpandDepth,
isError = false,
}: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const getDataType = (value: JsonValue): string => {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
};
const dataType = getDataType(data);
const typeStyleMap: Record<string, string> = {
const [typeStyleMap] = useState<Record<string, string>>({
number: "text-blue-600",
boolean: "text-amber-600",
null: "text-purple-600",
undefined: "text-gray-600",
string: "text-green-600 break-all whitespace-pre-wrap",
string: "text-green-600 group-hover:text-green-500",
error: "text-red-600 group-hover:text-red-500",
default: "text-gray-700",
};
});
const dataType = getDataType(data);
const renderCollapsible = (isArray: boolean) => {
const items = isArray
@@ -174,7 +225,14 @@ const JsonNode = memo(
{name}:
</span>
)}
<pre className={typeStyleMap.string}>"{value}"</pre>
<pre
className={clsx(
typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>
"{value}"
</pre>
</div>
);
}
@@ -188,8 +246,8 @@ const JsonNode = memo(
)}
<pre
className={clsx(
typeStyleMap.string,
"cursor-pointer group-hover:text-green-500",
isError ? typeStyleMap.error : typeStyleMap.string,
"cursor-pointer break-all whitespace-pre-wrap",
)}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}

View File

@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
isButtonDisabled,
}: ListPaneProps<T>) => (
<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>
</div>
<div className="p-4">

View File

@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
return (
<TabsContent value="ping" className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full"
>
Ping Server
</Button>
<TabsContent value="ping">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full"
>
Ping Server
</Button>
</div>
</div>
</TabsContent>
);

View File

@@ -84,86 +84,88 @@ const PromptsTab = ({
};
return (
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">{prompt.description}</span>
</>
)}
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">
<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>
<TabsContent value="prompts">
<div className="grid grid-cols-2 gap-4">
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">
{prompt.description}
</span>
</>
)}
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>
</TabsContent>

View File

@@ -111,154 +111,158 @@ const ResourcesTab = ({
};
return (
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
setSelectedTemplate(null);
}}
renderItem={(resource) => (
<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={resource.uri.toString()}>
{resource.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
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>
<TabsContent value="resources">
<div className="grid grid-cols-3 gap-4">
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
setSelectedTemplate(null);
}}
renderItem={(resource) => (
<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={resource.uri.toString()}>
{resource.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedResource ? (
<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">
<JsonView data={resourceContent} />
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>
) : 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>
</TabsContent>

View File

@@ -35,40 +35,42 @@ const RootsTab = ({
};
return (
<TabsContent value="roots" className="space-y-4">
<Alert>
<AlertDescription>
Configure the root directories that the server can access
</AlertDescription>
</Alert>
<TabsContent value="roots">
<div className="space-y-4">
<Alert>
<AlertDescription>
Configure the root directories that the server can access
</AlertDescription>
</Alert>
{roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="file:// URI"
value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
>
<Minus className="h-4 w-4" />
{roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="file:// URI"
value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
>
<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>
</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>
</TabsContent>
);

View File

@@ -33,31 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
};
return (
<TabsContent value="sampling" className="h-96">
<Alert>
<AlertDescription>
When the server requests LLM sampling, requests will appear here for
approval.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<JsonView data={JSON.stringify(request.request)} />
<TabsContent value="sampling">
<div className="h-96">
<Alert>
<AlertDescription>
When the server requests LLM sampling, requests will appear here for
approval.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<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 className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
</div>
</div>
</TabsContent>
);

View File

@@ -10,6 +10,7 @@ import {
EyeOff,
RotateCcw,
Settings,
HelpCircle,
RefreshCwOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -27,12 +28,17 @@ import {
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/useTheme";
import { version } from "../../../package.json";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
interface SidebarProps {
connectionStatus: "disconnected" | "connected" | "error";
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse";
setTransportType: (type: "stdio" | "sse") => void;
command: string;
@@ -86,7 +92,7 @@ const Sidebar = ({
return (
<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">
<h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version}
@@ -97,14 +103,19 @@ const Sidebar = ({
<div className="p-4 flex-1 overflow-auto">
<div className="space-y-4">
<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
value={transportType}
onValueChange={(value: "stdio" | "sse") =>
setTransportType(value)
}
>
<SelectTrigger>
<SelectTrigger id="transport-type-select">
<SelectValue placeholder="Select transport type" />
</SelectTrigger>
<SelectContent>
@@ -117,8 +128,11 @@ const Sidebar = ({
{transportType === "stdio" ? (
<>
<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
id="command-input"
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
@@ -126,8 +140,14 @@ const Sidebar = ({
/>
</div>
<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
id="arguments-input"
placeholder="Arguments (space-separated)"
value={args}
onChange={(e) => setArgs(e.target.value)}
@@ -138,8 +158,11 @@ const Sidebar = ({
) : (
<>
<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
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
@@ -151,6 +174,7 @@ const Sidebar = ({
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
aria-expanded={showBearerToken}
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -161,8 +185,14 @@ const Sidebar = ({
</Button>
{showBearerToken && (
<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
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
@@ -180,6 +210,8 @@ const Sidebar = ({
variant="outline"
onClick={() => setShowEnvVars(!showEnvVars)}
className="flex items-center w-full"
data-testid="env-vars-button"
aria-expanded={showEnvVars}
>
{showEnvVars ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -194,6 +226,7 @@ const Sidebar = ({
<div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2">
<Input
aria-label={`Environment variable key ${idx + 1}`}
placeholder="Key"
value={key}
onChange={(e) => {
@@ -236,6 +269,7 @@ const Sidebar = ({
</div>
<div className="flex gap-2">
<Input
aria-label={`Environment variable value ${idx + 1}`}
type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value"
value={value}
@@ -301,6 +335,8 @@ const Sidebar = ({
variant="outline"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
data-testid="config-button"
aria-expanded={showConfig}
>
{showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -316,11 +352,25 @@ const Sidebar = ({
const configKey = key as keyof InspectorConfig;
return (
<div key={key} className="space-y-2">
<label className="text-sm font-medium">
{configItem.description}
</label>
<div className="flex items-center gap-1">
<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" ? (
<Input
id={`${configKey}-input`}
type="number"
data-testid={`${configKey}-input`}
value={configItem.value}
@@ -347,7 +397,7 @@ const Sidebar = ({
setConfig(newConfig);
}}
>
<SelectTrigger>
<SelectTrigger id={`${configKey}-input`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -357,6 +407,7 @@ const Sidebar = ({
</Select>
) : (
<Input
id={`${configKey}-input`}
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
@@ -380,7 +431,13 @@ const Sidebar = ({
<div className="space-y-2">
{connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4">
<Button onClick={onConnect}>
<Button
data-testid="connect-button"
onClick={() => {
onDisconnect();
onConnect();
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"}
</Button>
@@ -399,33 +456,50 @@ const Sidebar = ({
<div className="flex items-center justify-center space-x-2 mb-4">
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "error"
? "bg-red-500"
: "bg-gray-500"
}`}
className={`w-2 h-2 rounded-full ${(() => {
switch (connectionStatus) {
case "connected":
return "bg-green-500";
case "error":
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">
{connectionStatus === "connected"
? "Connected"
: connectionStatus === "error"
? "Connection Error"
: "Disconnected"}
{(() => {
switch (connectionStatus) {
case "connected":
return "Connected";
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>
</div>
{loggingSupported && connectionStatus === "connected" && (
<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
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectTrigger id="logging-level-select">
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>

View File

@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
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 {
CallToolResultSchema,
@@ -13,7 +14,7 @@ import {
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Send } from "lucide-react";
import { Loader2, Send } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
@@ -31,7 +32,7 @@ const ToolsTab = ({
tools: Tool[];
listTools: () => void;
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => void;
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
@@ -39,6 +40,8 @@ const ToolsTab = ({
error: string | null;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
setParams({});
}, [selectedTool]);
@@ -52,14 +55,10 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<div className="p-4 border rounded">
<JsonView data={toolResult} />
</div>
<JsonView data={toolResult} />
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<div key={idx} className="p-4 border rounded">
<JsonView data={error} />
</div>
<JsonView data={error} key={idx} />
))}
</>
);
@@ -70,14 +69,17 @@ const ToolsTab = ({
return (
<>
<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>
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "text" && (
<div className="p-4 border rounded">
<JsonView data={item.text} />
</div>
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && (
<img
@@ -96,9 +98,7 @@ const ToolsTab = ({
<p>Your browser does not support audio playback</p>
</audio>
) : (
<div className="p-4 border rounded">
<JsonView data={item.resource} />
</div>
<JsonView data={item.resource} />
))}
</div>
))}
@@ -108,156 +108,176 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<div className="p-4 border rounded">
<JsonView data={toolResult.toolResult} />
</div>
<JsonView data={toolResult.toolResult} />
</>
);
}
};
return (
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-right">
{tool.description}
</span>
</>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
/>
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-right">
{tool.description}
</span>
</>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: checked,
[key]: e.target.value,
})
}
className="mt-1"
/>
<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}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: e.target.value,
})
}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
onChange={(newValue: JsonValue) => {
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: newValue,
});
}}
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
</div>
) : (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
)}
</div>
);
},
)}
<Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" />
Run Tool
</Button>
{toolResult && renderToolResult()}
</div>
) : (
<Alert>
<AlertDescription>
Select a tool from the list to view its details and run it
</AlertDescription>
</Alert>
)}
)}
</div>
);
},
)}
<Button
onClick={async () => {
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
{toolResult && renderToolResult()}
</div>
) : (
<Alert>
<AlertDescription>
Select a tool from the list to view its details and run it
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</TabsContent>

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => {

View File

@@ -1,8 +1,9 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";
import { InspectorConfig } from "../../lib/configurationTypes";
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook
jest.mock("../../lib/useTheme", () => ({
@@ -36,11 +37,15 @@ describe("Sidebar Environment Variables", () => {
};
const renderSidebar = (props = {}) => {
return render(<Sidebar {...defaultProps} {...props} />);
return render(
<TooltipProvider>
<Sidebar {...defaultProps} {...props} />
</TooltipProvider>,
);
};
const openEnvVarsSection = () => {
const button = screen.getByText("Environment Variables");
const button = screen.getByTestId("env-vars-button");
fireEvent.click(button);
};
@@ -216,7 +221,11 @@ describe("Sidebar Environment Variables", () => {
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
// Rerender with the updated env
rerender(<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />);
rerender(
<TooltipProvider>
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
</TooltipProvider>,
);
// Second key edit
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
@@ -247,7 +256,11 @@ describe("Sidebar Environment Variables", () => {
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
// 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
const updatedValueInput = screen.getByDisplayValue("test_value");
@@ -312,7 +325,7 @@ describe("Sidebar Environment Variables", () => {
describe("Configuration Operations", () => {
const openConfigSection = () => {
const button = screen.getByText("Configuration");
const button = screen.getByTestId("config-button");
fireEvent.click(button);
};
@@ -327,12 +340,65 @@ describe("Sidebar Environment Variables", () => {
);
fireEvent.change(timeoutInput, { target: { value: "5000" } });
expect(setConfig).toHaveBeenCalledWith({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
},
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
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", () => {
@@ -346,12 +412,15 @@ describe("Sidebar Environment Variables", () => {
);
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
expect(setConfig).toHaveBeenCalledWith({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
}),
);
});
it("should maintain configuration state after multiple updates", () => {
@@ -362,7 +431,6 @@ describe("Sidebar Environment Variables", () => {
});
openConfigSection();
// First update
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
@@ -374,11 +442,13 @@ describe("Sidebar Environment Variables", () => {
// Rerender with the updated config
rerender(
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>,
<TooltipProvider>
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>
</TooltipProvider>,
);
// Second update
@@ -388,12 +458,15 @@ describe("Sidebar Environment Variables", () => {
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
// Verify the final state matches what we expect
expect(setConfig).toHaveBeenLastCalledWith({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},
});
expect(setConfig).toHaveBeenLastCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},
}),
);
});
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import ToolsTab from "../ToolsTab";
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
tools: mockTools,
listTools: jest.fn(),
clearTools: jest.fn(),
callTool: jest.fn(),
callTool: jest.fn(async () => {}),
selectedTool: null,
setSelectedTool: jest.fn(),
toolResult: null,
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
);
};
it("should reset input values when switching tools", () => {
it("should reset input values when switching tools", async () => {
const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
});
// Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } });
await act(async () => {
fireEvent.change(input, { target: { value: "42" } });
});
expect(input.value).toBe("42");
// Switch to second tool
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe("");
});
it("should handle integer type inputs", () => {
it("should handle integer type inputs", async () => {
renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type
});
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
expect(input.value).toBe("42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
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();
});
});

View File

@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {

View File

@@ -22,7 +22,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
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,
)}
{...props}

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View 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,
};

View 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>
);
}

View 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 };

View 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 };

View File

@@ -38,29 +38,6 @@ h1 {
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) {
:root {
color: #213547;
@@ -69,9 +46,6 @@ button[role="checkbox"] {
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@layer base {

View File

@@ -1,4 +1,5 @@
export type ConfigItem = {
label: string;
description: string;
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.
*/
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;
};

View File

@@ -8,9 +8,39 @@ export const SESSION_KEYS = {
CLIENT_INFORMATION: "mcp_client_information",
} as const;
export type ConnectionStatus =
| "disconnected"
| "connected"
| "error"
| "error-connecting-to-proxy";
export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
/**
* Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser.
* Future plans: Provide json config file + Browser local_storage to override default values
**/
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
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;

View 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");
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
ResourceReference,
PromptReference,
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
timeout = setTimeout(() => {
void func(...args);
}, wait);
};
}
@@ -58,8 +60,8 @@ export function useCompletionState(
});
}, [cleanup]);
const requestCompletions = useCallback(
debounce(
const requestCompletions = useMemo(() => {
return debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
@@ -94,7 +96,7 @@ export function useCompletionState(
loading: { ...prev.loading, [argName]: false },
}));
}
} catch (err) {
} catch {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
@@ -108,9 +110,8 @@ export function useCompletionState(
}
},
debounceMs,
),
[handleCompletion, completionsSupported, cleanup, debounceMs],
);
);
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
// Clear completions when support status changes
useEffect(() => {

View File

@@ -8,7 +8,6 @@ import {
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request,
@@ -23,15 +22,24 @@ import {
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
Progress,
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { SESSION_KEYS } from "../constants";
import { ConnectionStatus, SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions {
transportType: "stdio" | "sse";
@@ -39,9 +47,8 @@ interface UseConnectionOptions {
args: string;
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number;
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -50,29 +57,22 @@ interface UseConnectionOptions {
getRoots?: () => any[];
}
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl,
bearerToken,
requestTimeout,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const { toast } = useToast();
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
@@ -94,38 +94,61 @@ export function useConnection({
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
options?: RequestOptions,
options?: RequestOptions & { suppressToast?: boolean },
): Promise<z.output<T>> => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, options?.timeout ?? requestTimeout);
// prepare MCP Client request options
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;
try {
response = await mcpClient.request(request, schema, {
signal: options?.signal ?? abortController.signal,
});
response = await mcpClient.request(request, schema, mcpRequestOptions);
pushHistory(request, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
return response;
} catch (e: unknown) {
if (!options?.suppressToast) {
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
toast({
title: "Error",
description: errorString,
variant: "destructive",
});
}
throw e;
}
@@ -167,7 +190,11 @@ export function useConnection({
}
// 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;
}
};
@@ -175,7 +202,11 @@ export function useConnection({
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
const error = new Error("MCP client not connected");
toast.error(error.message);
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
throw error;
}
@@ -188,7 +219,25 @@ export function useConnection({
// Log MCP protocol errors
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;
}
};
@@ -205,33 +254,38 @@ export function useConnection({
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: packageJson.version,
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: packageJson.version,
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
},
);
},
);
const backendUrl = new URL(`${proxyServerUrl}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
try {
await checkProxyHealth();
} catch {
setConnectionStatus("error-connecting-to-proxy");
return;
}
const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("transportType", transportType);
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
// proxying through the inspector server first.
const headers: HeadersInit = {};
@@ -242,7 +296,7 @@ export function useConnection({
headers["Authorization"] = `Bearer ${token}`;
}
const clientTransport = new SSEClientTransport(backendUrl, {
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
@@ -254,7 +308,6 @@ export function useConnection({
if (onNotification) {
[
CancelledNotificationSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema,
ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema,
@@ -282,7 +335,10 @@ export function useConnection({
try {
await client.connect(clientTransport);
} 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);
if (shouldRetry) {
return connect(undefined, retryCount + 1);

View File

@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []);
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
};
export default useTheme;

View File

@@ -1,13 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { Toaster } from "@/components/ui/toaster.tsx";
import App from "./App.tsx";
import "./index.css";
import { TooltipProvider } from "./components/ui/tooltip.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<ToastContainer />
<TooltipProvider>
<App />
</TooltipProvider>
<Toaster />
</StrictMode>,
);

View File

@@ -1,5 +1,146 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
import {
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", () => {
// 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", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
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(undefined as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
});
test("handles array indices correctly", () => {

View File

@@ -1,5 +1,5 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
import type { JsonSchemaType } from "../jsonUtils";
describe("generateDefaultValue", () => {
test("generates default string", () => {

View 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;
};

View File

@@ -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 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
* @param obj The original JSON value

View File

@@ -1,5 +1,4 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
/**
* Generates a default value based on a JSON schema type

3047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.8.0",
"version": "0.9.0",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -36,8 +36,8 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "^0.8.0",
"@modelcontextprotocol/inspector-server": "^0.8.0",
"@modelcontextprotocol/inspector-client": "^0.9.0",
"@modelcontextprotocol/inspector-server": "^0.9.0",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.8.0",
"version": "0.9.0",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.9.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"ws": "^8.18.0",

View File

@@ -38,7 +38,7 @@ app.use(cors());
let webAppTransports: SSEServerTransport[] = [];
const createTransport = async (req: express.Request) => {
const createTransport = async (req: express.Request): Promise<Transport> => {
const query = req.query;
console.log("Query parameters:", query);
@@ -70,6 +70,7 @@ const createTransport = async (req: express.Request) => {
const headers: HeadersInit = {
Accept: "text/event-stream",
};
for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
@@ -172,6 +173,12 @@ app.post("/message", async (req, res) => {
}
});
app.get("/health", (req, res) => {
res.json({
status: "ok",
});
});
app.get("/config", (req, res) => {
try {
res.json({