Merge remote-tracking branch 'theirs/main' into max/improved-oauth-callback
This commit is contained in:
120
client/bin/start.js
Executable file
120
client/bin/start.js
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { resolve, dirname } from "path";
|
||||
import { spawnPromise } from "spawn-rx";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const envVars = {};
|
||||
const mcpServerArgs = [];
|
||||
let command = null;
|
||||
let parsingFlags = true;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (parsingFlags && arg === "--") {
|
||||
parsingFlags = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||
const envVar = args[++i];
|
||||
const equalsIndex = envVar.indexOf("=");
|
||||
|
||||
if (equalsIndex !== -1) {
|
||||
const key = envVar.substring(0, equalsIndex);
|
||||
const value = envVar.substring(equalsIndex + 1);
|
||||
envVars[key] = value;
|
||||
} else {
|
||||
envVars[envVar] = "";
|
||||
}
|
||||
} else if (!command) {
|
||||
command = arg;
|
||||
} else {
|
||||
mcpServerArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
"../..",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
);
|
||||
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"../..",
|
||||
"client",
|
||||
"bin",
|
||||
"client.js",
|
||||
);
|
||||
|
||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
|
||||
const abort = new AbortController();
|
||||
|
||||
let cancelled = false;
|
||||
process.on("SIGINT", () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
});
|
||||
let server, serverOk;
|
||||
try {
|
||||
server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(command ? [`--env`, command] : []),
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure server started before starting client
|
||||
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||
} catch (error) {}
|
||||
|
||||
if (serverOk) {
|
||||
try {
|
||||
await spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((_) => process.exit(0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -8,14 +8,14 @@
|
||||
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-inspector-client": "./bin/cli.js"
|
||||
"mcp-inspector-client": "./bin/start.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --port 6274",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview --port 6274",
|
||||
@@ -72,6 +72,6 @@
|
||||
"ts-jest": "^29.2.6",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^5.4.8"
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,7 @@ import Sidebar from "./components/Sidebar";
|
||||
import ToolsTab from "./components/ToolsTab";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
||||
import { InspectorConfig } from "./lib/configurationTypes";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
getMCPServerRequestTimeout,
|
||||
} from "./utils/configUtils";
|
||||
import { getMCPProxyAddress } from "./utils/configUtils";
|
||||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
@@ -100,10 +97,21 @@ const App = () => {
|
||||
const [config, setConfig] = useState<InspectorConfig>(() => {
|
||||
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
return {
|
||||
// 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;
|
||||
});
|
||||
@@ -111,6 +119,10 @@ const App = () => {
|
||||
return localStorage.getItem("lastBearerToken") || "";
|
||||
});
|
||||
|
||||
const [headerName, setHeaderName] = useState<string>(() => {
|
||||
return localStorage.getItem("lastHeaderName") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
@@ -150,7 +162,7 @@ const App = () => {
|
||||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
makeRequest: makeConnectionRequest,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
@@ -163,8 +175,8 @@ const App = () => {
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
proxyServerUrl: getMCPProxyAddress(config),
|
||||
requestTimeout: getMCPServerRequestTimeout(config),
|
||||
headerName,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
},
|
||||
@@ -203,6 +215,10 @@ const App = () => {
|
||||
localStorage.setItem("lastBearerToken", bearerToken);
|
||||
}, [bearerToken]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastHeaderName", headerName);
|
||||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||
}, [config]);
|
||||
@@ -265,13 +281,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);
|
||||
}
|
||||
@@ -289,7 +305,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const listResources = async () => {
|
||||
const response = await makeRequest(
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/list" as const,
|
||||
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
||||
@@ -302,7 +318,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const listResourceTemplates = async () => {
|
||||
const response = await makeRequest(
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/templates/list" as const,
|
||||
params: nextResourceTemplateCursor
|
||||
@@ -319,7 +335,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const readResource = async (uri: string) => {
|
||||
const response = await makeRequest(
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/read" as const,
|
||||
params: { uri },
|
||||
@@ -332,7 +348,7 @@ const App = () => {
|
||||
|
||||
const subscribeToResource = async (uri: string) => {
|
||||
if (!resourceSubscriptions.has(uri)) {
|
||||
await makeRequest(
|
||||
await sendMCPRequest(
|
||||
{
|
||||
method: "resources/subscribe" as const,
|
||||
params: { uri },
|
||||
@@ -348,7 +364,7 @@ const App = () => {
|
||||
|
||||
const unsubscribeFromResource = async (uri: string) => {
|
||||
if (resourceSubscriptions.has(uri)) {
|
||||
await makeRequest(
|
||||
await sendMCPRequest(
|
||||
{
|
||||
method: "resources/unsubscribe" as const,
|
||||
params: { uri },
|
||||
@@ -363,7 +379,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const listPrompts = async () => {
|
||||
const response = await makeRequest(
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "prompts/list" as const,
|
||||
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
||||
@@ -376,7 +392,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 },
|
||||
@@ -388,7 +404,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const listTools = async () => {
|
||||
const response = await makeRequest(
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "tools/list" as const,
|
||||
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
||||
@@ -401,21 +417,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 () => {
|
||||
@@ -423,7 +452,7 @@ const App = () => {
|
||||
};
|
||||
|
||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||
await makeRequest(
|
||||
await sendMCPRequest(
|
||||
{
|
||||
method: "logging/setLevel" as const,
|
||||
params: { level },
|
||||
@@ -433,6 +462,10 @@ const App = () => {
|
||||
setLogLevel(level);
|
||||
};
|
||||
|
||||
const clearStdErrNotifications = () => {
|
||||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
@@ -462,12 +495,15 @@ const App = () => {
|
||||
setConfig={setConfig}
|
||||
bearerToken={bearerToken}
|
||||
setBearerToken={setBearerToken}
|
||||
headerName={headerName}
|
||||
setHeaderName={setHeaderName}
|
||||
onConnect={connectMcpServer}
|
||||
onDisconnect={disconnectMcpServer}
|
||||
stdErrNotifications={stdErrNotifications}
|
||||
logLevel={logLevel}
|
||||
sendLogLevelRequest={sendLogLevelRequest}
|
||||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
clearStdErrNotifications={clearStdErrNotifications}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -623,9 +659,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) => {
|
||||
@@ -640,7 +677,7 @@ const App = () => {
|
||||
<ConsoleTab />
|
||||
<PingTab
|
||||
onPingClick={() => {
|
||||
void makeRequest(
|
||||
void sendMCPRequest(
|
||||
{
|
||||
method: "ping" as const,
|
||||
},
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
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 { 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 { updateValueAtPath } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
@@ -38,13 +13,23 @@ interface DynamicJsonFormProps {
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type !== "object") return false;
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
maxDepth = 3,
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
const isOnlyJSON = !isSimpleObject(schema);
|
||||
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
// Store the raw JSON string to allow immediate feedback during typing
|
||||
// while deferring parsing until the user stops typing
|
||||
@@ -231,111 +216,6 @@ const DynamicJsonForm = ({
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "object": {
|
||||
// Handle case where we have a value but no schema properties
|
||||
const objectValue = (currentValue as JsonObject) || {};
|
||||
|
||||
// If we have schema properties, use them to render fields
|
||||
if (propSchema.properties) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
{renderFormFields(
|
||||
prop,
|
||||
objectValue[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have a value but no schema properties, render fields based on the value
|
||||
else if (Object.keys(objectValue).length > 0) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(objectValue).map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange([...path, key], e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have neither schema properties nor value, return null
|
||||
return null;
|
||||
}
|
||||
case "array": {
|
||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">{propSchema.description}</p>
|
||||
)}
|
||||
|
||||
{propSchema.items?.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {propSchema.items.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{renderFormFields(
|
||||
propSchema.items as JsonSchemaType,
|
||||
item,
|
||||
[...path, index.toString()],
|
||||
depth + 1,
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray.splice(index, 1);
|
||||
handleFieldChange(path, newArray);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const defaultValue = generateDefaultValue(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [
|
||||
...arrayValue,
|
||||
defaultValue ?? null,
|
||||
]);
|
||||
}}
|
||||
title={
|
||||
propSchema.items?.description
|
||||
? `Add new ${propSchema.items.description}`
|
||||
: "Add new item"
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -374,9 +254,11 @@ const DynamicJsonForm = ({
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
{!isOnlyJSON && (
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isJsonMode ? (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||
import { JsonValue } from "./DynamicJsonForm";
|
||||
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;
|
||||
@@ -11,21 +12,7 @@ interface JsonViewProps {
|
||||
initialExpandDepth?: number;
|
||||
className?: string;
|
||||
withCopyButton?: boolean;
|
||||
}
|
||||
|
||||
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
||||
const trimmed = str.trim();
|
||||
if (
|
||||
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
try {
|
||||
return { success: true, data: JSON.parse(str) };
|
||||
} catch {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const JsonView = memo(
|
||||
@@ -35,6 +22,7 @@ const JsonView = memo(
|
||||
initialExpandDepth = 3,
|
||||
className,
|
||||
withCopyButton = true,
|
||||
isError = false,
|
||||
}: JsonViewProps) => {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -100,6 +88,7 @@ const JsonView = memo(
|
||||
name={name}
|
||||
depth={0}
|
||||
initialExpandDepth={initialExpandDepth}
|
||||
isError={isError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,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
|
||||
@@ -236,7 +225,14 @@ const JsonNode = memo(
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<pre className={typeStyleMap.string}>"{value}"</pre>
|
||||
<pre
|
||||
className={clsx(
|
||||
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||
"break-all whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
"{value}"
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { authProvider } from "../lib/auth";
|
||||
import { InspectorOAuthClientProvider } from "../lib/auth";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { useToast } from "@/hooks/use-toast.ts";
|
||||
@@ -43,7 +43,10 @@ const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await auth(authProvider, {
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
|
||||
|
||||
result = await auth(serverAuthProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: params.code,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ const PromptsTab = ({
|
||||
clearPrompts: () => void;
|
||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||
selectedPrompt: Prompt | null;
|
||||
setSelectedPrompt: (prompt: Prompt) => void;
|
||||
setSelectedPrompt: (prompt: Prompt | null) => void;
|
||||
handleCompletion: (
|
||||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
@@ -84,84 +84,91 @@ 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 && (
|
||||
<JsonView data={promptContent} withCopyButton={false} />
|
||||
)}
|
||||
</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();
|
||||
setSelectedPrompt(null);
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -104,162 +104,177 @@ const ResourcesTab = ({
|
||||
if (selectedTemplate) {
|
||||
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
|
||||
readResource(uri);
|
||||
setSelectedTemplate(null);
|
||||
// We don't have the full Resource object here, so we create a partial one
|
||||
setSelectedResource({ uri, name: uri } as Resource);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
// Condition to check if selected resource is not resource template's resource
|
||||
if (!selectedTemplate) {
|
||||
setSelectedResource(null);
|
||||
}
|
||||
}}
|
||||
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 ? (
|
||||
<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>
|
||||
title="Resources"
|
||||
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
||||
isButtonDisabled={!nextCursor && resources.length > 0}
|
||||
/>
|
||||
|
||||
<ListPane
|
||||
items={resourceTemplates}
|
||||
listItems={listResourceTemplates}
|
||||
clearItems={() => {
|
||||
clearResourceTemplates();
|
||||
// Condition to check if selected resource is resource template's resource
|
||||
if (selectedTemplate) {
|
||||
setSelectedResource(null);
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -33,33 +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">
|
||||
<JsonView
|
||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||
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 className="flex space-x-2">
|
||||
<Button onClick={() => handleApprove(request.id)}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -51,9 +51,12 @@ interface SidebarProps {
|
||||
setEnv: (env: Record<string, string>) => void;
|
||||
bearerToken: string;
|
||||
setBearerToken: (token: string) => void;
|
||||
headerName?: string;
|
||||
setHeaderName?: (name: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
clearStdErrNotifications: () => void;
|
||||
logLevel: LoggingLevel;
|
||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||
loggingSupported: boolean;
|
||||
@@ -75,9 +78,12 @@ const Sidebar = ({
|
||||
setEnv,
|
||||
bearerToken,
|
||||
setBearerToken,
|
||||
headerName,
|
||||
setHeaderName,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
stdErrNotifications,
|
||||
clearStdErrNotifications,
|
||||
logLevel,
|
||||
sendLogLevelRequest,
|
||||
loggingSupported,
|
||||
@@ -92,7 +98,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}
|
||||
@@ -103,14 +109,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>
|
||||
@@ -123,8 +134,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)}
|
||||
@@ -132,8 +146,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)}
|
||||
@@ -144,8 +164,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)}
|
||||
@@ -157,6 +180,8 @@ const Sidebar = ({
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
@@ -167,11 +192,28 @@ const Sidebar = ({
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bearer Token</label>
|
||||
<label className="text-sm font-medium">Header Name</label>
|
||||
<Input
|
||||
placeholder="Authorization"
|
||||
onChange={(e) =>
|
||||
setHeaderName && setHeaderName(e.target.value)
|
||||
}
|
||||
data-testid="header-input"
|
||||
className="font-mono"
|
||||
value={headerName}
|
||||
/>
|
||||
<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)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
@@ -187,6 +229,7 @@ const Sidebar = ({
|
||||
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" />
|
||||
@@ -201,6 +244,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) => {
|
||||
@@ -243,6 +287,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}
|
||||
@@ -309,6 +354,7 @@ const Sidebar = ({
|
||||
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" />
|
||||
@@ -325,8 +371,11 @@ const Sidebar = ({
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm font-medium text-green-600">
|
||||
{configKey}
|
||||
<label
|
||||
className="text-sm font-medium text-green-600 break-all"
|
||||
htmlFor={`${configKey}-input`}
|
||||
>
|
||||
{configItem.label}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -339,6 +388,7 @@ const Sidebar = ({
|
||||
</div>
|
||||
{typeof configItem.value === "number" ? (
|
||||
<Input
|
||||
id={`${configKey}-input`}
|
||||
type="number"
|
||||
data-testid={`${configKey}-input`}
|
||||
value={configItem.value}
|
||||
@@ -365,7 +415,7 @@ const Sidebar = ({
|
||||
setConfig(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={`${configKey}-input`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -375,6 +425,7 @@ const Sidebar = ({
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`${configKey}-input`}
|
||||
data-testid={`${configKey}-input`}
|
||||
value={configItem.value}
|
||||
onChange={(e) => {
|
||||
@@ -398,7 +449,13 @@ const Sidebar = ({
|
||||
<div className="space-y-2">
|
||||
{connectionStatus === "connected" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button data-testid="connect-button" onClick={onConnect}>
|
||||
<Button
|
||||
data-testid="connect-button"
|
||||
onClick={() => {
|
||||
onDisconnect();
|
||||
onConnect();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||
</Button>
|
||||
@@ -448,19 +505,26 @@ const Sidebar = ({
|
||||
|
||||
{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>
|
||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||
<SelectItem value={level}>{level}</SelectItem>
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -470,9 +534,19 @@ const Sidebar = ({
|
||||
{stdErrNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearStdErrNotifications}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||
{stdErrNotifications.map((notification, index) => (
|
||||
<div
|
||||
|
||||
@@ -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,8 +40,16 @@ const ToolsTab = ({
|
||||
error: string | null;
|
||||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setParams({});
|
||||
const params = Object.entries(
|
||||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(value as JsonSchemaType),
|
||||
]);
|
||||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
@@ -66,11 +75,18 @@ 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" && <JsonView data={item.text} />}
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
@@ -106,147 +122,179 @@ const ToolsTab = ({
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
) : 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>
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type="number"
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
[key]: Number(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 className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={params[key] as JsonValue}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } 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 = {}) => {
|
||||
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Complex Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
// The simplified JsonSchemaType does not accept oneOf fields
|
||||
// But they exist in the more-complete JsonSchema7Type
|
||||
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
||||
},
|
||||
} as unknown as JsonSchemaType,
|
||||
value: undefined,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("Basic Operations", () => {
|
||||
it("should render textbox and autoformat button, but no switch-to-form button", () => {
|
||||
renderForm();
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "textarea");
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
|
||||
});
|
||||
|
||||
it("should pass changed values to onChange", () => {
|
||||
const onChange = jest.fn();
|
||||
renderForm({ onChange });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, {
|
||||
target: { value: `{ "nested": "i am string" }` },
|
||||
});
|
||||
|
||||
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
|
||||
waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import Sidebar from "../Sidebar";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||
@@ -29,6 +30,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
onConnect: jest.fn(),
|
||||
onDisconnect: jest.fn(),
|
||||
stdErrNotifications: [],
|
||||
clearStdErrNotifications: jest.fn(),
|
||||
logLevel: "info" as const,
|
||||
sendLogLevelRequest: jest.fn(),
|
||||
loggingSupported: true,
|
||||
@@ -108,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
const openAuthSection = () => {
|
||||
const button = screen.getByTestId("auth-button");
|
||||
fireEvent.click(button);
|
||||
};
|
||||
|
||||
it("should update bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||
});
|
||||
|
||||
it("should update header name", () => {
|
||||
const setHeaderName = jest.fn();
|
||||
renderSidebar({
|
||||
headerName: "Authorization",
|
||||
setHeaderName,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||
|
||||
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||
});
|
||||
|
||||
it("should clear bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should properly render bearer token input", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain token visibility state after update", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain header name when toggling auth section", () => {
|
||||
renderSidebar({
|
||||
headerName: "X-API-Key",
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
// Open auth section
|
||||
openAuthSection();
|
||||
|
||||
// Verify header name is displayed
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveValue("X-API-Key");
|
||||
|
||||
// Close auth section
|
||||
const authButton = screen.getByTestId("auth-button");
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Reopen auth section
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Verify header name is still preserved
|
||||
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||
});
|
||||
|
||||
it("should display default header name when not specified", () => {
|
||||
renderSidebar({
|
||||
headerName: undefined,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Editing", () => {
|
||||
it("should maintain order when editing first key", () => {
|
||||
const setEnv = jest.fn();
|
||||
@@ -343,6 +496,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 5000,
|
||||
},
|
||||
@@ -350,6 +504,56 @@ describe("Sidebar Environment Variables", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const setConfig = jest.fn();
|
||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||
@@ -364,6 +568,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 0,
|
||||
},
|
||||
@@ -409,6 +614,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 3000,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import "@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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -15,13 +15,6 @@ type ToasterToast = ToastProps & {
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
@@ -29,23 +22,28 @@ function genId() {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
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"];
|
||||
type: ActionType.ADD_TOAST;
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
type: ActionType.UPDATE_TOAST;
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
type: ActionType.DISMISS_TOAST;
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
type: ActionType.REMOVE_TOAST;
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
@@ -63,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
type: ActionType.REMOVE_TOAST,
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
@@ -73,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
case ActionType.ADD_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
case ActionType.UPDATE_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
@@ -87,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
case ActionType.DISMISS_TOAST: {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
@@ -112,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
case ActionType.REMOVE_TOAST:
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
@@ -144,13 +142,14 @@ function toast({ ...props }: Toast) {
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
type: ActionType.UPDATE_TOAST,
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
const dismiss = () =>
|
||||
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
type: ActionType.ADD_TOAST,
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
@@ -184,7 +183,8 @@ function useToast() {
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
dismiss: (toastId?: string) =>
|
||||
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS } from "./constants";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(private serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
|
||||
class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
@@ -24,7 +29,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async clientInformation() {
|
||||
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
this.serverUrl,
|
||||
);
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -33,14 +42,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||
sessionStorage.setItem(
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
JSON.stringify(clientInformation),
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
||||
}
|
||||
|
||||
async tokens() {
|
||||
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
|
||||
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||
const tokens = sessionStorage.getItem(key);
|
||||
if (!tokens) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -49,7 +60,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
|
||||
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||
sessionStorage.setItem(key, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL) {
|
||||
@@ -57,11 +69,19 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string) {
|
||||
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CODE_VERIFIER,
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, codeVerifier);
|
||||
}
|
||||
|
||||
codeVerifier() {
|
||||
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CODE_VERIFIER,
|
||||
this.serverUrl,
|
||||
);
|
||||
const verifier = sessionStorage.getItem(key);
|
||||
if (!verifier) {
|
||||
throw new Error("No code verifier saved for session");
|
||||
}
|
||||
@@ -69,5 +89,3 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
|
||||
export const authProvider = new InspectorOAuthClientProvider();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ConfigItem = {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string | number | boolean;
|
||||
};
|
||||
@@ -15,5 +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;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,15 @@ export const SESSION_KEYS = {
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
export const getServerSpecificKey = (
|
||||
baseKey: string,
|
||||
serverUrl?: string,
|
||||
): string => {
|
||||
if (!serverUrl) return baseKey;
|
||||
return `[${serverUrl}] ${baseKey}`;
|
||||
};
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "disconnected"
|
||||
| "connected"
|
||||
@@ -22,10 +31,23 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||
**/
|
||||
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: "",
|
||||
|
||||
166
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
166
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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(),
|
||||
getServerVersion: jest.fn(),
|
||||
getInstructions: 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", () => ({
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useConnection", () => {
|
||||
const defaultProps = {
|
||||
transportType: "sse" as const,
|
||||
command: "",
|
||||
args: "",
|
||||
sseUrl: "http://localhost:8080",
|
||||
env: {},
|
||||
config: DEFAULT_INSPECTOR_CONFIG,
|
||||
};
|
||||
|
||||
describe("Request Configuration", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("uses the default config values in makeRequest", async () => {
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
// Connect the client
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Wait for state update
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const mockRequest: ClientRequest = {
|
||||
method: "ping",
|
||||
params: {},
|
||||
};
|
||||
|
||||
const mockSchema = z.object({
|
||||
test: z.string(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeRequest(mockRequest, mockSchema);
|
||||
});
|
||||
|
||||
expect(mockClient.request).toHaveBeenCalledWith(
|
||||
mockRequest,
|
||||
mockSchema,
|
||||
expect.objectContaining({
|
||||
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
|
||||
maxTotalTimeout:
|
||||
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
|
||||
resetTimeoutOnProgress:
|
||||
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
|
||||
.value,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("overrides the default config values when passed in options in makeRequest", async () => {
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
// Connect the client
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Wait for state update
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const mockRequest: ClientRequest = {
|
||||
method: "ping",
|
||||
params: {},
|
||||
};
|
||||
|
||||
const mockSchema = z.object({
|
||||
test: z.string(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeRequest(mockRequest, mockSchema, {
|
||||
timeout: 1000,
|
||||
maxTotalTimeout: 2000,
|
||||
resetTimeoutOnProgress: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockClient.request).toHaveBeenCalledWith(
|
||||
mockRequest,
|
||||
mockSchema,
|
||||
expect.objectContaining({
|
||||
timeout: 1000,
|
||||
maxTotalTimeout: 2000,
|
||||
resetTimeoutOnProgress: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when mcpClient is not connected", async () => {
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
const mockRequest: ClientRequest = {
|
||||
method: "ping",
|
||||
params: {},
|
||||
};
|
||||
|
||||
const mockSchema = z.object({
|
||||
test: z.string(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.makeRequest(mockRequest, mockSchema),
|
||||
).rejects.toThrow("MCP client not connected");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import {
|
||||
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(() => {
|
||||
|
||||
@@ -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 { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
import { ConnectionStatus, SESSION_KEYS } from "../constants";
|
||||
import { ConnectionStatus } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { authProvider } from "../auth";
|
||||
import { InspectorOAuthClientProvider } 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,9 @@ interface UseConnectionOptions {
|
||||
args: string;
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
proxyServerUrl: string;
|
||||
bearerToken?: string;
|
||||
requestTimeout?: number;
|
||||
headerName?: string;
|
||||
config: InspectorConfig;
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -50,21 +58,15 @@ interface UseConnectionOptions {
|
||||
getRoots?: () => any[];
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
export function useConnection({
|
||||
transportType,
|
||||
command,
|
||||
args,
|
||||
sseUrl,
|
||||
env,
|
||||
proxyServerUrl,
|
||||
bearerToken,
|
||||
requestTimeout,
|
||||
headerName,
|
||||
config,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
onPendingRequest,
|
||||
@@ -94,31 +96,50 @@ 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;
|
||||
@@ -211,7 +232,7 @@ export function useConnection({
|
||||
|
||||
const checkProxyHealth = async () => {
|
||||
try {
|
||||
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
|
||||
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
||||
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
||||
const proxyHealth = await proxyHealthResponse.json();
|
||||
if (proxyHealth?.status !== "ok") {
|
||||
@@ -225,9 +246,10 @@ export function useConnection({
|
||||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
||||
// Create a new auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
const result = await auth(authProvider, { serverUrl: sseUrl });
|
||||
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
|
||||
return result === "AUTHORIZED";
|
||||
}
|
||||
|
||||
@@ -256,7 +278,7 @@ export function useConnection({
|
||||
setConnectionStatus("error-connecting-to-proxy");
|
||||
return;
|
||||
}
|
||||
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
|
||||
const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("transportType", transportType);
|
||||
if (transportType === "stdio") {
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
@@ -271,10 +293,15 @@ export function useConnection({
|
||||
// proxying through the inspector server first.
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
||||
const token =
|
||||
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
const authHeaderName = headerName || "Authorization";
|
||||
headers[authHeaderName] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
||||
@@ -289,7 +316,6 @@ export function useConnection({
|
||||
if (onNotification) {
|
||||
[
|
||||
CancelledNotificationSchema,
|
||||
ProgressNotificationSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
@@ -314,8 +340,19 @@ export function useConnection({
|
||||
);
|
||||
}
|
||||
|
||||
let capabilities;
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
capabilities = client.getServerCapabilities();
|
||||
const initializeRequest = {
|
||||
method: "initialize",
|
||||
};
|
||||
pushHistory(initializeRequest, {
|
||||
capabilities,
|
||||
serverInfo: client.getServerVersion(),
|
||||
instructions: client.getInstructions(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||
@@ -332,8 +369,6 @@ export function useConnection({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const capabilities = client.getServerCapabilities();
|
||||
setServerCapabilities(capabilities ?? null);
|
||||
setCompletionsSupported(true); // Reset completions support on new connection
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -12,3 +12,15 @@ export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
|
||||
};
|
||||
|
||||
export const resetRequestTimeoutOnProgress = (
|
||||
config: InspectorConfig,
|
||||
): boolean => {
|
||||
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
|
||||
};
|
||||
|
||||
export const getMCPServerRequestMaxTotalTimeout = (
|
||||
config: InspectorConfig,
|
||||
): number => {
|
||||
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,66 @@
|
||||
import { JsonValue } from "../components/DynamicJsonForm";
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
export type 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user