Merge branch 'main' into auto_open
This commit is contained in:
124
client/bin/start.js
Executable file
124
client/bin/start.js
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import open from "open";
|
||||
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 {
|
||||
if (process.env.MCP_AUTO_OPEN_DISABLED !== "true") {
|
||||
open(`http://127.0.0.1:${CLIENT_PORT}`);
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@ const App = () => {
|
||||
return localStorage.getItem("lastBearerToken") || "";
|
||||
});
|
||||
|
||||
const [headerName, setHeaderName] = useState<string>(() => {
|
||||
return localStorage.getItem("lastHeaderName") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
@@ -169,6 +173,7 @@ const App = () => {
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
@@ -208,6 +213,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]);
|
||||
@@ -467,6 +476,10 @@ const App = () => {
|
||||
setLogLevel(level);
|
||||
};
|
||||
|
||||
const clearStdErrNotifications = () => {
|
||||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
@@ -496,12 +509,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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -25,7 +25,10 @@ const OAuthCallback = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await auth(authProvider, {
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
|
||||
|
||||
const result = await auth(serverAuthProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: code,
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -174,6 +180,7 @@ const Sidebar = ({
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
@@ -185,6 +192,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"
|
||||
@@ -196,6 +213,7 @@ const Sidebar = ({
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
@@ -516,7 +534,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>
|
||||
@@ -526,9 +546,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 = () => {
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user