Merge branch 'main' into main
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.10.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",
|
||||
@@ -23,7 +23,7 @@
|
||||
"test:watch": "jest --config jest.config.cjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
Tool,
|
||||
LoggingLevel,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useConnection } from "./lib/hooks/useConnection";
|
||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||
import { StdErrNotification } from "./lib/notificationTypes";
|
||||
@@ -46,14 +52,10 @@ import ToolsTab from "./components/ToolsTab";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
||||
import { InspectorConfig } from "./lib/configurationTypes";
|
||||
import { getMCPProxyAddress } from "./utils/configUtils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
const App = () => {
|
||||
const { toast } = useToast();
|
||||
// Handle OAuth callback route
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
@@ -122,6 +124,10 @@ const App = () => {
|
||||
return localStorage.getItem("lastBearerToken") || "";
|
||||
});
|
||||
|
||||
const [headerName, setHeaderName] = useState<string>(() => {
|
||||
return localStorage.getItem("lastHeaderName") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
@@ -174,6 +180,7 @@ const App = () => {
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
@@ -213,35 +220,23 @@ const App = () => {
|
||||
localStorage.setItem("lastBearerToken", bearerToken);
|
||||
}, [bearerToken]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastHeaderName", headerName);
|
||||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||
}, [config]);
|
||||
|
||||
const hasProcessedRef = useRef(false);
|
||||
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
||||
useEffect(() => {
|
||||
if (hasProcessedRef.current) {
|
||||
// Only try to connect once
|
||||
return;
|
||||
}
|
||||
const serverUrl = params.get("serverUrl");
|
||||
if (serverUrl) {
|
||||
// Auto-connect to previously saved serverURL after OAuth callback
|
||||
const onOAuthConnect = useCallback(
|
||||
(serverUrl: string) => {
|
||||
setSseUrl(serverUrl);
|
||||
setTransportType("sse");
|
||||
// Remove serverUrl from URL without reloading the page
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete("serverUrl");
|
||||
window.history.replaceState({}, "", newUrl.toString());
|
||||
// Show success toast for OAuth
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Successfully authenticated with OAuth",
|
||||
});
|
||||
hasProcessedRef.current = true;
|
||||
// Connect to the server
|
||||
connectMcpServer();
|
||||
}
|
||||
}, [connectMcpServer, toast]);
|
||||
void connectMcpServer();
|
||||
},
|
||||
[connectMcpServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||
@@ -472,13 +467,17 @@ const App = () => {
|
||||
setLogLevel(level);
|
||||
};
|
||||
|
||||
const clearStdErrNotifications = () => {
|
||||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OAuthCallback />
|
||||
<OAuthCallback onConnect={onOAuthConnect} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -501,12 +500,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">
|
||||
|
||||
@@ -1,11 +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 } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
@@ -14,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
|
||||
@@ -207,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;
|
||||
}
|
||||
@@ -350,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 ? (
|
||||
|
||||
@@ -227,7 +227,7 @@ const JsonNode = memo(
|
||||
)}
|
||||
<pre
|
||||
className={clsx(
|
||||
typeStyleMap.string,
|
||||
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||
"break-all whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,9 +1,19 @@
|
||||
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";
|
||||
import {
|
||||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
|
||||
const OAuthCallback = () => {
|
||||
interface OAuthCallbackProps {
|
||||
onConnect: (serverUrl: string) => void;
|
||||
}
|
||||
|
||||
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||
const { toast } = useToast();
|
||||
const hasProcessedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -14,37 +24,56 @@ const OAuthCallback = () => {
|
||||
}
|
||||
hasProcessedRef.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||
const notifyError = (description: string) =>
|
||||
void toast({
|
||||
title: "OAuth Authorization Error",
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!code || !serverUrl) {
|
||||
console.error("Missing code or server URL");
|
||||
window.location.href = "/";
|
||||
return;
|
||||
const params = parseOAuthCallbackParams(window.location.search);
|
||||
if (!params.successful) {
|
||||
return notifyError(generateOAuthErrorDescription(params));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await auth(authProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: code,
|
||||
});
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new Error(
|
||||
`Expected to be authorized after providing auth code, got: ${result}`,
|
||||
);
|
||||
}
|
||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||
if (!serverUrl) {
|
||||
return notifyError("Missing Server URL");
|
||||
}
|
||||
|
||||
// Redirect back to the main app with server URL to trigger auto-connect
|
||||
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
|
||||
let result;
|
||||
try {
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
|
||||
|
||||
result = await auth(serverAuthProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: params.code,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("OAuth callback error:", error);
|
||||
window.location.href = "/";
|
||||
return notifyError(`Unexpected error occurred: ${error}`);
|
||||
}
|
||||
|
||||
if (result !== "AUTHORIZED") {
|
||||
return notifyError(
|
||||
`Expected to be authorized after providing auth code, got: ${result}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Finally, trigger auto-connect
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Successfully authenticated with OAuth",
|
||||
variant: "default",
|
||||
});
|
||||
onConnect(serverUrl);
|
||||
};
|
||||
|
||||
void handleCallback();
|
||||
}, []);
|
||||
handleCallback().finally(() => {
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
});
|
||||
}, [toast, onConnect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
|
||||
@@ -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,
|
||||
@@ -89,7 +89,10 @@ const PromptsTab = ({
|
||||
<ListPane
|
||||
items={prompts}
|
||||
listItems={listPrompts}
|
||||
clearItems={clearPrompts}
|
||||
clearItems={() => {
|
||||
clearPrompts();
|
||||
setSelectedPrompt(null);
|
||||
}}
|
||||
setSelectedItem={(prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setPromptArgs({});
|
||||
@@ -108,7 +111,7 @@ const PromptsTab = ({
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold">
|
||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||
</h3>
|
||||
|
||||
@@ -104,7 +104,6 @@ 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);
|
||||
}
|
||||
@@ -116,7 +115,13 @@ const ResourcesTab = ({
|
||||
<ListPane
|
||||
items={resources}
|
||||
listItems={listResources}
|
||||
clearItems={clearResources}
|
||||
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);
|
||||
@@ -139,7 +144,14 @@ const ResourcesTab = ({
|
||||
<ListPane
|
||||
items={resourceTemplates}
|
||||
listItems={listResourceTemplates}
|
||||
clearItems={clearResourceTemplates}
|
||||
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);
|
||||
@@ -162,7 +174,7 @@ const ResourcesTab = ({
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<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}
|
||||
|
||||
@@ -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}
|
||||
@@ -175,6 +181,7 @@ const Sidebar = ({
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
@@ -186,6 +193,16 @@ const Sidebar = ({
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<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"
|
||||
@@ -197,6 +214,7 @@ const Sidebar = ({
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
@@ -432,7 +450,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>
|
||||
@@ -499,7 +523,9 @@ const Sidebar = ({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||
<SelectItem value={level}>{level}</SelectItem>
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -509,9 +535,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
|
||||
|
||||
@@ -43,7 +43,13 @@ const ToolsTab = ({
|
||||
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 = () => {
|
||||
@@ -140,7 +146,7 @@ const ToolsTab = ({
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||
</h3>
|
||||
@@ -217,13 +223,10 @@ const ToolsTab = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type={
|
||||
prop.type === "number" || prop.type === "integer"
|
||||
? "number"
|
||||
: "text"
|
||||
}
|
||||
type="number"
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
@@ -231,15 +234,29 @@ const ToolsTab = ({
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
[key]: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 "@/utils/jsonUtils";
|
||||
@@ -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();
|
||||
|
||||
@@ -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,17 +69,35 @@ 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");
|
||||
}
|
||||
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
|
||||
export const authProvider = new InspectorOAuthClientProvider();
|
||||
clear() {
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
|
||||
);
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
||||
);
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,6 +17,8 @@ const mockClient = {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn(),
|
||||
getServerCapabilities: jest.fn(),
|
||||
getServerVersion: jest.fn(),
|
||||
getInstructions: jest.fn(),
|
||||
setNotificationHandler: jest.fn(),
|
||||
setRequestHandler: jest.fn(),
|
||||
};
|
||||
@@ -43,9 +45,9 @@ jest.mock("@/hooks/use-toast", () => ({
|
||||
|
||||
// Mock the auth provider
|
||||
jest.mock("../../auth", () => ({
|
||||
authProvider: {
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useConnection", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -28,10 +28,10 @@ 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,
|
||||
@@ -48,6 +48,7 @@ interface UseConnectionOptions {
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken?: string;
|
||||
headerName?: string;
|
||||
config: InspectorConfig;
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
@@ -64,6 +65,7 @@ export function useConnection({
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
config,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
@@ -244,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";
|
||||
}
|
||||
|
||||
@@ -290,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, {
|
||||
@@ -332,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}:`,
|
||||
@@ -350,8 +369,6 @@ export function useConnection({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const capabilities = client.getServerCapabilities();
|
||||
setServerCapabilities(capabilities ?? null);
|
||||
setCompletionsSupported(true); // Reset completions support on new connection
|
||||
|
||||
@@ -379,6 +396,8 @@ export function useConnection({
|
||||
|
||||
const disconnect = async () => {
|
||||
await mcpClient?.close();
|
||||
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
authProvider.clear();
|
||||
setMcpClient(null);
|
||||
setConnectionStatus("disconnected");
|
||||
setCompletionsSupported(false);
|
||||
|
||||
@@ -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;
|
||||
|
||||
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("Returns successful: true and code when present", () => {
|
||||
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||
successful: true,
|
||||
code: "fake-code",
|
||||
});
|
||||
});
|
||||
it("Returns successful: false and error when error is present", () => {
|
||||
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
it("Returns optional error metadata fields when present", () => {
|
||||
const search =
|
||||
"?error=access_denied&" +
|
||||
"error_description=User%20Denied%20Request&" +
|
||||
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User Denied Request",
|
||||
error_uri: "https://example.com/error-docs",
|
||||
});
|
||||
});
|
||||
it("Returns error when nothing present", () => {
|
||||
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription", () => {
|
||||
it("When only error is present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
}),
|
||||
).toBe("Error: invalid_request.");
|
||||
});
|
||||
it("When error description is present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "The request could not be completed as dialed",
|
||||
error_uri: null,
|
||||
}),
|
||||
).toEqual(
|
||||
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||
);
|
||||
});
|
||||
it("When all fields present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "The request could not be completed as dialed",
|
||||
error_uri: "https://example.com/error-docs",
|
||||
}),
|
||||
).toEqual(
|
||||
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
client/src/utils/oauthUtils.ts
Normal file
65
client/src/utils/oauthUtils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// The parsed query parameters returned by the Authorization Server
|
||||
// representing either a valid authorization_code or an error
|
||||
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
|
||||
type CallbackParams =
|
||||
| {
|
||||
successful: true;
|
||||
// The authorization code is generated by the authorization server.
|
||||
code: string;
|
||||
}
|
||||
| {
|
||||
successful: false;
|
||||
// The OAuth 2.1 Error Code.
|
||||
// Usually one of:
|
||||
// ```
|
||||
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
|
||||
// invalid_scope, server_error, temporarily_unavailable
|
||||
// ```
|
||||
error: string;
|
||||
// Human-readable ASCII text providing additional information, used to assist the
|
||||
// developer in understanding the error that occurred.
|
||||
error_description: string | null;
|
||||
// A URI identifying a human-readable web page with information about the error,
|
||||
// used to provide the client developer with additional information about the error.
|
||||
error_uri: string | null;
|
||||
};
|
||||
|
||||
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
|
||||
const params = new URLSearchParams(location);
|
||||
|
||||
const code = params.get("code");
|
||||
if (code) {
|
||||
return { successful: true, code };
|
||||
}
|
||||
|
||||
const error = params.get("error");
|
||||
const error_description = params.get("error_description");
|
||||
const error_uri = params.get("error_uri");
|
||||
|
||||
if (error) {
|
||||
return { successful: false, error, error_description, error_uri };
|
||||
}
|
||||
|
||||
return {
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateOAuthErrorDescription = (
|
||||
params: Extract<CallbackParams, { successful: false }>,
|
||||
): string => {
|
||||
const error = params.error;
|
||||
const errorDescription = params.error_description;
|
||||
const errorUri = params.error_uri;
|
||||
|
||||
return [
|
||||
`Error: ${error}.`,
|
||||
errorDescription ? `Details: ${errorDescription}.` : "",
|
||||
errorUri ? `More info: ${errorUri}.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
};
|
||||
Reference in New Issue
Block a user