Merge branch 'main' into feature/completion-context

This commit is contained in:
Ola Hungerford 2025-07-26 09:41:56 -07:00 • committed by GitHub
commit f274a52612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2005 additions and 149 deletions

41
.github/workflows/cli_tests.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: CLI Tests
on:
push:
paths:
- "cli/**"
pull_request:
paths:
- "cli/**"
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: npm
- name: Install dependencies
run: |
cd ..
npm ci --ignore-scripts
- name: Build CLI
run: npm run build
- name: Explicitly pre-install test dependencies
run: npx -y @modelcontextprotocol/server-everything --help || true
- name: Run tests
run: npm test
env:
NPM_CONFIG_YES: true
CI: true

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.1",
"version": "0.16.2",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -21,7 +21,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.1",
"@modelcontextprotocol/sdk": "^1.17.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -12,7 +12,7 @@ const colors = {
import fs from "fs";
import path from "path";
import { execSync, spawn } from "child_process";
import { spawn } from "child_process";
import os from "os";
import { fileURLToPath } from "url";

View file

@ -66,8 +66,21 @@ async function runWebClient(args: Args): Promise<void> {
abort.abort();
});
// Build arguments to pass to start.js
const startArgs: string[] = [];
// Pass environment variables
for (const [key, value] of Object.entries(args.envArgs)) {
startArgs.push("-e", `${key}=${value}`);
}
// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
}
try {
await spawnPromise("node", [inspectorClientPath], {
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
signal: abort.signal,
echoOutput: true,
});
@ -233,7 +246,7 @@ async function main(): Promise<void> {
const args = parseArgs();
if (args.cli) {
runCli(args);
await runCli(args);
} else {
await runWebClient(args);
}

View file

@ -287,6 +287,8 @@ async function main(): Promise<void> {
try {
const args = parseArgs();
await callMethod(args);
// Explicitly exit to ensure process terminates in CI
process.exit(0);
} catch (error) {
handleError(error);
}

View file

@ -91,8 +91,10 @@ async function startProdServer(serverOptions) {
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
...(command ? [`--command`, command] : []),
...(mcpServerArgs && mcpServerArgs.length > 0
? [`--args`, mcpServerArgs.join(" ")]
: []),
],
{
env: {

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.1",
"version": "0.16.2",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,9 +25,8 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.1",
"@modelcontextprotocol/sdk": "^1.17.0",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
@ -37,6 +36,7 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"ajv": "^6.12.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",

View file

@ -71,6 +71,10 @@ import {
initializeInspectorConfig,
saveInspectorConfig,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
ElicitationResponse,
} from "./components/ElicitationTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -119,6 +123,14 @@ const App = () => {
return localStorage.getItem("lastHeaderName") || "";
});
const [oauthClientId, setOauthClientId] = useState<string>(() => {
return localStorage.getItem("lastOauthClientId") || "";
});
const [oauthScope, setOauthScope] = useState<string>(() => {
return localStorage.getItem("lastOauthScope") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@ -127,13 +139,19 @@ const App = () => {
}
>
>([]);
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
Array<
PendingElicitationRequest & {
resolve: (response: ElicitationResponse) => void;
decline: (error: Error) => void;
}
>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Helper function to update specific auth state properties
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
@ -161,6 +179,19 @@ const App = () => {
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
const hash = window.location.hash.slice(1);
const initialTab = hash || "resources";
return initialTab;
});
const currentTabRef = useRef<string>(activeTab);
const lastToolCallOriginTabRef = useRef<string>(activeTab);
useEffect(() => {
currentTabRef.current = activeTab;
}, [activeTab]);
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
@ -187,6 +218,8 @@ const App = () => {
env,
bearerToken,
headerName,
oauthClientId,
oauthScope,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
@ -203,9 +236,64 @@ const App = () => {
{ id: nextRequestId.current++, request, resolve, reject },
]);
},
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;
setPendingElicitationRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request: {
id: nextRequestId.current,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
},
originatingTab: currentTab,
resolve,
decline: (error: Error) => {
console.error("Elicitation request rejected:", error);
},
},
]);
setActiveTab("elicitations");
window.location.hash = "elicitations";
},
getRoots: () => rootsRef.current,
});
useEffect(() => {
if (serverCapabilities) {
const hash = window.location.hash.slice(1);
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
const isValidTab = validTabs.includes(hash);
if (!isValidTab) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
setActiveTab(defaultTab);
window.location.hash = defaultTab;
}
}
}, [serverCapabilities]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@ -230,11 +318,18 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName);
}, [headerName]);
useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);
useEffect(() => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
// Auto-connect to previously saved serverURL after OAuth callback
const onOAuthConnect = useCallback(
(serverUrl: string) => {
setSseUrl(serverUrl);
@ -244,7 +339,6 @@ const App = () => {
[connectMcpServer],
);
// Update OAuth debug state during debug callback
const onOAuthDebugConnect = useCallback(
async ({
authorizationCode,
@ -265,7 +359,6 @@ const App = () => {
}
if (restoredState && authorizationCode) {
// Restore the previous auth state and continue the OAuth flow
let currentState: AuthDebuggerState = {
...restoredState,
authorizationCode,
@ -276,12 +369,10 @@ const App = () => {
};
try {
// Create a new state machine instance to continue the flow
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
// Continue stepping through the OAuth flow from where we left off
while (
currentState.oauthStep !== "complete" &&
currentState.oauthStep !== "authorization_code"
@ -290,7 +381,6 @@ const App = () => {
}
if (currentState.oauthStep === "complete") {
// After the flow completes or reaches a user-input step, update the app state
updateAuthState({
...currentState,
statusMessage: {
@ -313,7 +403,6 @@ const App = () => {
});
}
} else if (authorizationCode) {
// Fallback to the original behavior if no state was restored
updateAuthState({
authorizationCode,
oauthStep: "token_request",
@ -323,7 +412,6 @@ const App = () => {
[sseUrl],
);
// Load OAuth tokens when sseUrl changes
useEffect(() => {
const loadOAuthTokens = async () => {
try {
@ -382,6 +470,18 @@ const App = () => {
}
}, []);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && hash !== activeTab) {
setActiveTab(hash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [activeTab]);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
@ -398,6 +498,44 @@ const App = () => {
});
};
const handleResolveElicitation = (
id: number,
response: ElicitationResponse,
) => {
setPendingElicitationRequests((prev) => {
const request = prev.find((r) => r.id === id);
if (request) {
request.resolve(response);
if (request.originatingTab) {
const originatingTab = request.originatingTab;
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
if (validTabs.includes(originatingTab)) {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
setTimeout(() => {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
}, 100);
}
}
}
return prev.filter((r) => r.id !== id);
});
};
const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
@ -455,7 +593,23 @@ const App = () => {
setNextResourceTemplateCursor(response.nextCursor);
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest(
{
method: "prompts/get" as const,
params: { name, arguments: args },
},
GetPromptResultSchema,
"prompts",
);
setPromptContent(JSON.stringify(response, null, 2));
};
const readResource = async (uri: string) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest(
{
method: "resources/read" as const,
@ -517,18 +671,6 @@ const App = () => {
setNextPromptCursor(response.nextCursor);
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
const response = await sendMCPRequest(
{
method: "prompts/get" as const,
params: { name, arguments: args },
},
GetPromptResultSchema,
"prompts",
);
setPromptContent(JSON.stringify(response, null, 2));
};
const listTools = async () => {
const response = await sendMCPRequest(
{
@ -540,11 +682,12 @@ const App = () => {
);
setTools(response.tools);
setNextToolCursor(response.nextCursor);
// Cache output schemas for validation
cacheToolOutputSchemas(response.tools);
};
const callTool = async (name: string, params: Record<string, unknown>) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
const response = await sendMCPRequest(
{
@ -560,6 +703,7 @@ const App = () => {
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
@ -594,7 +738,6 @@ const App = () => {
setStdErrNotifications([]);
};
// Helper component for rendering the AuthDebugger
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
@ -606,7 +749,6 @@ const App = () => {
</TabsContent>
);
// Helper function to render OAuth callback components
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
@ -658,6 +800,10 @@ const App = () => {
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
@ -666,7 +812,6 @@ const App = () => {
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
{/* Drag handle for resizing sidebar */}
<div
onMouseDown={handleSidebarDragStart}
style={{
@ -687,21 +832,12 @@ const App = () => {
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs
defaultValue={
Object.keys(serverCapabilities ?? {}).includes(
window.location.hash.slice(1),
)
? window.location.hash.slice(1)
: serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping"
}
value={activeTab}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
onValueChange={(value) => {
setActiveTab(value);
window.location.hash = value;
}}
>
<TabsList className="mb-4 py-0">
<TabsTrigger
@ -738,6 +874,15 @@ const App = () => {
</span>
)}
</TabsTrigger>
<TabsTrigger value="elicitations" className="relative">
<MessageSquare className="w-4 h-4 mr-2" />
Elicitations
{pendingElicitationRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingElicitationRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="roots">
<FolderTree className="w-4 h-4 mr-2" />
Roots
@ -854,7 +999,6 @@ const App = () => {
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
// Clear cached output schemas
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
@ -893,6 +1037,10 @@ const App = () => {
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<ElicitationTab
pendingRequests={pendingElicitationRequests}
onResolve={handleResolveElicitation}
/>
<RootsTab
roots={roots}
setRoots={setRoots}

View file

@ -17,10 +17,10 @@ interface DynamicJsonFormProps {
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type && supportedTypes.includes(schema.type)) return true;
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
return Object.values(schema.properties ?? {}).every(
(prop) => prop.type && supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
@ -182,10 +182,89 @@ const DynamicJsonForm = ({
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
case "string": {
if (
propSchema.oneOf &&
propSchema.oneOf.every(
(option) =>
typeof option.const === "string" &&
typeof option.title === "string",
)
) {
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.oneOf.map((option) => (
<option
key={option.const as string}
value={option.const as string}
>
{option.title as string}
</option>
))}
</select>
);
}
if (propSchema.enum) {
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
let inputType = "text";
switch (propSchema.format) {
case "email":
inputType = "email";
break;
case "uri":
inputType = "url";
break;
case "date":
inputType = "date";
break;
case "date-time":
inputType = "datetime-local";
break;
default:
inputType = "text";
break;
}
return (
<Input
type="text"
type={inputType}
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
@ -194,8 +273,13 @@ const DynamicJsonForm = ({
}}
placeholder={propSchema.description}
required={isRequired}
minLength={propSchema.minLength}
maxLength={propSchema.maxLength}
pattern={propSchema.pattern}
/>
);
}
case "number":
return (
<Input
@ -214,8 +298,11 @@ const DynamicJsonForm = ({
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
max={propSchema.maximum}
/>
);
case "integer":
return (
<Input
@ -228,7 +315,6 @@ const DynamicJsonForm = ({
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
@ -236,8 +322,11 @@ const DynamicJsonForm = ({
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
max={propSchema.maximum}
/>
);
case "boolean":
return (
<Input

View file

@ -0,0 +1,173 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import DynamicJsonForm from "./DynamicJsonForm";
import JsonView from "./JsonView";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
ElicitationResponse,
} from "./ElicitationTab";
import Ajv from "ajv";
export type ElicitationRequestProps = {
request: PendingElicitationRequest;
onResolve: (id: number, response: ElicitationResponse) => void;
};
const ElicitationRequest = ({
request,
onResolve,
}: ElicitationRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
const defaultValue = generateDefaultValue(request.request.requestedSchema);
setFormData(defaultValue);
setValidationError(null);
}, [request.request.requestedSchema]);
const validateEmailFormat = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validateFormData = (
data: JsonValue,
schema: JsonSchemaType,
): boolean => {
if (
schema.type === "object" &&
schema.properties &&
typeof data === "object" &&
data !== null
) {
const dataObj = data as Record<string, unknown>;
if (Array.isArray(schema.required)) {
for (const field of schema.required) {
const value = dataObj[field];
if (value === undefined || value === null || value === "") {
setValidationError(`Required field missing: ${field}`);
return false;
}
}
}
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
const fieldSchema = schema.properties[fieldName];
if (
fieldSchema &&
fieldSchema.format === "email" &&
typeof fieldValue === "string"
) {
if (!validateEmailFormat(fieldValue)) {
setValidationError(`Invalid email format: ${fieldName}`);
return false;
}
}
}
}
return true;
};
const handleAccept = () => {
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
return;
}
const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const isValid = validate(formData);
if (!isValid) {
const errorMessage = ajv.errorsText(validate.errors);
setValidationError(errorMessage);
return;
}
onResolve(request.id, {
action: "accept",
content: formData as Record<string, unknown>,
});
} catch (error) {
setValidationError(
error instanceof Error ? error.message : "Validation failed",
);
}
};
const handleDecline = () => {
onResolve(request.id, { action: "decline" });
};
const handleCancel = () => {
onResolve(request.id, { action: "cancel" });
};
const schemaTitle =
request.request.requestedSchema.title || "Information Request";
const schemaDescription = request.request.requestedSchema.description;
return (
<div
data-testid="elicitation-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">{schemaTitle}</h4>
<p className="text-sm">{request.request.message}</p>
{schemaDescription && (
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
)}
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(request.request.requestedSchema, null, 2)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-4">
<div className="space-y-2">
<h4 className="font-medium">Response Form</h4>
<DynamicJsonForm
schema={request.request.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);
setValidationError(null);
}}
/>
{validationError && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="text-sm text-red-600 dark:text-red-400">
<strong>Validation Error:</strong> {validationError}
</div>
</div>
)}
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={handleAccept}>
Submit
</Button>
<Button type="button" variant="outline" onClick={handleDecline}>
Decline
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>
</div>
</div>
);
};
export default ElicitationRequest;

View file

@ -0,0 +1,56 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";
export interface ElicitationRequestData {
id: number;
message: string;
requestedSchema: JsonSchemaType;
}
export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
content?: Record<string, unknown>;
}
export type PendingElicitationRequest = {
id: number;
request: ElicitationRequestData;
originatingTab?: string;
};
export type Props = {
pendingRequests: PendingElicitationRequest[];
onResolve: (id: number, response: ElicitationResponse) => void;
};
const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
return (
<TabsContent value="elicitations">
<div className="h-96">
<Alert>
<AlertDescription>
When the server requests information from the user, requests will
appear here for response.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<ElicitationRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
</div>
</div>
</TabsContent>
);
};
export default ElicitationTab;

View file

@ -56,6 +56,10 @@ interface SidebarProps {
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
@ -83,6 +87,10 @@ const Sidebar = ({
setBearerToken,
headerName,
setHeaderName,
oauthClientId,
setOauthClientId,
oauthScope,
setOauthScope,
onConnect,
onDisconnect,
stdErrNotifications,
@ -95,7 +103,7 @@ const Sidebar = ({
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [showAuthConfig, setShowAuthConfig] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
@ -308,51 +316,6 @@ const Sidebar = ({
/>
)}
</div>
<div className="space-y-2">
<Button
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" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</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"
>
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"
/>
</div>
)}
</div>
</>
)}
@ -521,6 +484,94 @@ const Sidebar = ({
</Tooltip>
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowAuthConfig(!showAuthConfig)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showAuthConfig}
>
{showAuthConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showAuthConfig && (
<>
{/* Bearer Token Section */}
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
API Token Authentication
</h4>
<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"
>
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"
/>
</div>
</div>
{transportType !== "stdio" && (
// OAuth Configuration
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
OAuth 2.0 Flow
</h4>
<div className="space-y-2">
<label className="text-sm font-medium">Client ID</label>
<Input
placeholder="Client ID"
onChange={(e) => setOauthClientId(e.target.value)}
value={oauthClientId}
data-testid="oauth-client-id-input"
className="font-mono"
/>
<label className="text-sm font-medium">
Redirect URL
</label>
<Input
readOnly
placeholder="Redirect URL"
value={window.location.origin + "/oauth/callback"}
className="font-mono"
/>
<label className="text-sm font-medium">Scope</label>
<Input
placeholder="Scope (space-separated)"
onChange={(e) => setOauthScope(e.target.value)}
value={oauthScope}
data-testid="oauth-scope-input"
className="font-mono"
/>
</div>
</div>
)}
</>
)}
</div>
{/* Configuration */}
<div className="space-y-2">
<Button

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@ -35,6 +36,188 @@ describe("DynamicJsonForm String Fields", () => {
expect(input).toHaveProperty("type", "text");
});
});
describe("Format Support", () => {
it("should render email input for email format", () => {
const schema: JsonSchemaType = {
type: "string",
format: "email",
description: "Email address",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "email");
});
it("should render url input for uri format", () => {
const schema: JsonSchemaType = {
type: "string",
format: "uri",
description: "Website URL",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "url");
});
it("should render date input for date format", () => {
const schema: JsonSchemaType = {
type: "string",
format: "date",
description: "Birth date",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByDisplayValue("");
expect(input).toHaveProperty("type", "date");
});
it("should render datetime-local input for date-time format", () => {
const schema: JsonSchemaType = {
type: "string",
format: "date-time",
description: "Event datetime",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByDisplayValue("");
expect(input).toHaveProperty("type", "datetime-local");
});
});
describe("Enum Support", () => {
it("should render select dropdown for enum fields", () => {
const schema: JsonSchemaType = {
type: "string",
enum: ["option1", "option2", "option3"],
description: "Select an option",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const select = screen.getByRole("combobox");
expect(select.tagName).toBe("SELECT");
const options = screen.getAllByRole("option");
expect(options).toHaveLength(4);
});
it("should use oneOf with const and title for labeled options", () => {
const schema: JsonSchemaType = {
type: "string",
oneOf: [
{ const: "val1", title: "Label 1" },
{ const: "val2", title: "Label 2" },
],
description: "Select with labels",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const options = screen.getAllByRole("option");
expect(options[1]).toHaveProperty("textContent", "Label 1");
expect(options[2]).toHaveProperty("textContent", "Label 2");
});
it("should call onChange with selected oneOf value", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "string",
oneOf: [
{ const: "option1", title: "Option 1" },
{ const: "option2", title: "Option 2" },
],
description: "Select an option",
};
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "option1" } });
expect(onChange).toHaveBeenCalledWith("option1");
});
it("should call onChange with selected enum value", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "string",
enum: ["option1", "option2"],
description: "Select an option",
};
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "option1" } });
expect(onChange).toHaveBeenCalledWith("option1");
});
it("should render JSON Schema spec compliant oneOf with const for labeled enums", () => {
// Example from JSON Schema spec: labeled enums using oneOf with const
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "string",
title: "Traffic Light",
description: "Select a traffic light color",
oneOf: [
{ const: "red", title: "Stop" },
{ const: "amber", title: "Caution" },
{ const: "green", title: "Go" },
],
};
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
// Should render as a select dropdown
const select = screen.getByRole("combobox");
expect(select.tagName).toBe("SELECT");
// Should have options with proper labels
const options = screen.getAllByRole("option");
expect(options).toHaveLength(4); // 3 options + 1 default "Select an option..."
expect(options[0]).toHaveProperty("textContent", "Select an option...");
expect(options[1]).toHaveProperty("textContent", "Stop");
expect(options[2]).toHaveProperty("textContent", "Caution");
expect(options[3]).toHaveProperty("textContent", "Go");
// Should have proper values
expect(options[1]).toHaveProperty("value", "red");
expect(options[2]).toHaveProperty("value", "amber");
expect(options[3]).toHaveProperty("value", "green");
// Test onChange behavior
fireEvent.change(select, { target: { value: "amber" } });
expect(onChange).toHaveBeenCalledWith("amber");
});
});
describe("Validation Attributes", () => {
it("should apply minLength and maxLength", () => {
const schema: JsonSchemaType = {
type: "string",
minLength: 3,
maxLength: 10,
description: "Username",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("minLength", 3);
expect(input).toHaveProperty("maxLength", 10);
});
it("should apply pattern validation", () => {
const schema: JsonSchemaType = {
type: "string",
pattern: "^[A-Za-z]+$",
description: "Letters only",
};
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("pattern", "^[A-Za-z]+$");
});
});
});
describe("DynamicJsonForm Integer Fields", () => {
@ -81,6 +264,38 @@ describe("DynamicJsonForm Integer Fields", () => {
});
});
describe("Validation", () => {
it("should apply min and max constraints", () => {
const schema: JsonSchemaType = {
type: "integer",
minimum: 0,
maximum: 100,
description: "Age",
};
render(
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
);
const input = screen.getByRole("spinbutton");
expect(input).toHaveProperty("min", "0");
expect(input).toHaveProperty("max", "100");
});
it("should only accept integer values", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "integer",
description: "Count",
};
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "3.14" } });
expect(onChange).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle non-numeric input by not calling onChange", () => {
const onChange = jest.fn();
@ -94,6 +309,233 @@ describe("DynamicJsonForm Integer Fields", () => {
});
});
describe("DynamicJsonForm Number Fields", () => {
describe("Validation", () => {
it("should apply min and max constraints", () => {
const schema: JsonSchemaType = {
type: "number",
minimum: 0.5,
maximum: 99.9,
description: "Score",
};
render(
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
);
const input = screen.getByRole("spinbutton");
expect(input).toHaveProperty("min", "0.5");
expect(input).toHaveProperty("max", "99.9");
});
it("should accept decimal values", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "number",
description: "Temperature",
};
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "98.6" } });
expect(onChange).toHaveBeenCalledWith(98.6);
});
});
});
describe("DynamicJsonForm Boolean Fields", () => {
describe("Basic Operations", () => {
it("should render checkbox for boolean type", () => {
const schema: JsonSchemaType = {
type: "boolean",
description: "Enable notifications",
};
render(
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toHaveProperty("type", "checkbox");
});
it("should call onChange with boolean value", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "boolean",
description: "Accept terms",
};
render(
<DynamicJsonForm schema={schema} value={false} onChange={onChange} />,
);
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith(true);
});
it("should render boolean field with description", () => {
const schema: JsonSchemaType = {
type: "boolean",
description: "Enable dark mode",
};
render(
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toHaveProperty("checked", false);
});
});
});
describe("DynamicJsonForm Object Fields", () => {
describe("Property Rendering", () => {
it("should render input fields for object properties", () => {
const schema: JsonSchemaType = {
type: "object",
properties: {
name: {
type: "string",
title: "Full Name",
description: "Your full name",
},
age: {
type: "integer",
title: "Age",
description: "Your age in years",
minimum: 18,
},
email: {
type: "string",
format: "email",
title: "Email",
description: "Your email address",
},
},
};
render(
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
);
const textInputs = screen.getAllByRole("textbox");
const numberInput = screen.getByRole("spinbutton");
expect(textInputs).toHaveLength(2);
expect(textInputs[0]).toHaveProperty("type", "text");
expect(textInputs[1]).toHaveProperty("type", "email");
expect(numberInput).toHaveProperty("type", "number");
expect(numberInput).toHaveProperty("min", "18");
});
it("should handle object field changes correctly", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "object",
properties: {
username: {
type: "string",
description: "Your username",
},
},
};
render(
<DynamicJsonForm schema={schema} value={{}} onChange={onChange} />,
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "testuser" } });
expect(onChange).toHaveBeenCalledWith({ username: "testuser" });
});
it("should handle nested object values correctly", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "object",
properties: {
name: {
type: "string",
title: "Name",
},
},
};
render(
<DynamicJsonForm
schema={schema}
value={{ name: "John" }}
onChange={onChange}
/>,
);
const input = screen.getByDisplayValue("John");
fireEvent.change(input, { target: { value: "Jane" } });
expect(onChange).toHaveBeenCalledWith({ name: "Jane" });
});
});
describe("Required Fields", () => {
it("should mark required fields with required attribute", () => {
const schema: JsonSchemaType = {
type: "object",
properties: {
name: {
type: "string",
title: "Name",
},
email: {
type: "string",
title: "Email",
},
},
required: ["name"],
};
render(
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
);
const inputs = screen.getAllByRole("textbox");
const nameInput = inputs[0];
const emailInput = inputs[1];
expect(nameInput).toHaveProperty("required", true);
expect(emailInput).toHaveProperty("required", false);
});
it("should mark required fields with required attribute", () => {
const schema: JsonSchemaType = {
type: "object",
properties: {
name: {
type: "string",
title: "Name",
},
optional: {
type: "string",
title: "Optional",
},
},
required: ["name"],
};
render(
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
);
const nameLabel = screen.getByText("name");
const optionalLabel = screen.getByText("optional");
const nameInput = nameLabel.closest("div")?.querySelector("input");
const optionalInput = optionalLabel
.closest("div")
?.querySelector("input");
expect(nameInput).toHaveProperty("required", true);
expect(optionalInput).toHaveProperty("required", false);
});
});
});
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {

View file

@ -0,0 +1,202 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach, afterEach } from "@jest/globals";
import ElicitationRequest from "../ElicitationRequest";
import { PendingElicitationRequest } from "../ElicitationTab";
jest.mock("../DynamicJsonForm", () => {
return function MockDynamicJsonForm({
value,
onChange,
}: {
value: unknown;
onChange: (value: unknown) => void;
}) {
return (
<div data-testid="dynamic-json-form">
<input
data-testid="form-input"
value={
typeof value === "object" && value !== null
? JSON.stringify(value)
: String(value || "")
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
onChange(e.target.value);
}
}}
/>
</div>
);
};
});
describe("ElicitationRequest", () => {
const mockOnResolve = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
const createMockRequest = (
overrides: Partial<PendingElicitationRequest> = {},
): PendingElicitationRequest => ({
id: 1,
request: {
id: 1,
message: "Please provide your information",
requestedSchema: {
type: "object",
properties: {
name: { type: "string", description: "Your name" },
email: { type: "string", format: "email", description: "Your email" },
},
required: ["name"],
},
},
...overrides,
});
const renderElicitationRequest = (
request: PendingElicitationRequest = createMockRequest(),
) => {
return render(
<ElicitationRequest request={request} onResolve={mockOnResolve} />,
);
};
describe("Rendering", () => {
it("should render the component", () => {
renderElicitationRequest();
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
});
it("should display request message", () => {
const message = "Please provide your GitHub username";
renderElicitationRequest(
createMockRequest({
request: {
id: 1,
message,
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
},
},
}),
);
expect(screen.getByText(message)).toBeInTheDocument();
});
it("should render all three action buttons", () => {
renderElicitationRequest();
expect(
screen.getByRole("button", { name: /cancel/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /decline/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /submit/i }),
).toBeInTheDocument();
});
it("should render DynamicJsonForm component", () => {
renderElicitationRequest();
expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument();
});
});
describe("User Interactions", () => {
it("should call onResolve with accept action when Submit button is clicked", async () => {
renderElicitationRequest();
const input = screen.getByTestId("form-input");
await act(async () => {
fireEvent.change(input, {
target: {
value: '{"name": "John Doe", "email": "john@example.com"}',
},
});
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(1, {
action: "accept",
content: { name: "John Doe", email: "john@example.com" },
});
});
it("should call onResolve with decline action when Decline button is clicked", async () => {
renderElicitationRequest();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /decline/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" });
});
it("should call onResolve with cancel action when Cancel button is clicked", async () => {
renderElicitationRequest();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" });
});
});
describe("Form Validation", () => {
it("should show validation error for missing required fields", async () => {
renderElicitationRequest();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
});
expect(
screen.getByText(/Required field missing: name/),
).toBeInTheDocument();
expect(mockOnResolve).not.toHaveBeenCalledWith(
1,
expect.objectContaining({ action: "accept" }),
);
});
it("should show validation error for invalid email format", async () => {
renderElicitationRequest();
const input = screen.getByTestId("form-input");
await act(async () => {
fireEvent.change(input, {
target: { value: '{"name": "John", "email": "invalid-email"}' },
});
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
});
expect(
screen.getByText(/Invalid email format: email/),
).toBeInTheDocument();
expect(mockOnResolve).not.toHaveBeenCalledWith(
1,
expect.objectContaining({ action: "accept" }),
);
});
});
});

View file

@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import { Tabs } from "@/components/ui/tabs";
import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab";
describe("Elicitation tab", () => {
const mockOnResolve = jest.fn();
const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) =>
render(
<Tabs defaultValue="elicitations">
<ElicitationTab
pendingRequests={pendingRequests}
onResolve={mockOnResolve}
/>
</Tabs>,
);
it("should render 'No pending requests' when there are no pending requests", () => {
renderElicitationTab([]);
expect(
screen.getByText(
"When the server requests information from the user, requests will appear here for response.",
),
).toBeTruthy();
expect(screen.findByText("No pending requests")).toBeTruthy();
});
it("should render the correct number of requests", () => {
renderElicitationTab(
Array.from({ length: 3 }, (_, i) => ({
id: i,
request: {
id: i,
message: `Please provide information ${i}`,
requestedSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Your name",
},
},
required: ["name"],
},
},
})),
);
expect(screen.getAllByTestId("elicitation-request").length).toBe(3);
});
});

View file

@ -42,6 +42,10 @@ describe("Sidebar Environment Variables", () => {
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",

View file

@ -9,8 +9,64 @@ import {
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export const getClientInformationFromSessionStorage = async ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
};
export const saveClientInformationToSessionStorage = ({
serverUrl,
clientInformation,
isPreregistered,
}: {
serverUrl: string;
clientInformation: OAuthClientInformation;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
};
export const clearClientInformationFromSessionStorage = ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.removeItem(key);
};
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(public serverUrl: string) {
constructor(protected serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
@ -31,24 +87,30 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation() {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =
await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: true,
});
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
// If no preregistered client information is found, get the dynamically registered client information
return (
preregisteredClientInformation ??
(await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
}))
);
}
saveClientInformation(clientInformation: OAuthClientInformation) {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
// Save the dynamically registered client information to session storage
saveClientInformationToSessionStorage({
serverUrl: this.serverUrl,
clientInformation,
isPreregistered: false,
});
}
async tokens() {
@ -92,9 +154,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
clear() {
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
);
clearClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
});
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);

View file

@ -6,6 +6,7 @@ export const SESSION_KEYS = {
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
SERVER_METADATA: "mcp_server_metadata",
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
} as const;

View file

@ -4,6 +4,10 @@ import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
import {
ElicitResult,
ElicitRequest,
} from "@modelcontextprotocol/sdk/types.js";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
@ -82,6 +86,8 @@ jest.mock("../../auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
})),
clearClientInformationFromSessionStorage: jest.fn(),
saveClientInformationToSessionStorage: jest.fn(),
}));
describe("useConnection", () => {
@ -198,6 +204,252 @@ describe("useConnection", () => {
).rejects.toThrow("MCP client not connected");
});
describe("Elicitation Support", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("declares elicitation capability during client initialization", async () => {
const Client = jest.requireMock(
"@modelcontextprotocol/sdk/client/index.js",
).Client;
const { result } = renderHook(() => useConnection(defaultProps));
await act(async () => {
await result.current.connect();
});
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
name: "mcp-inspector",
version: expect.any(String),
}),
expect.objectContaining({
capabilities: expect.objectContaining({
elicitation: {},
}),
}),
);
});
test("sets up elicitation request handler when onElicitationRequest is provided", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeDefined();
expect(mockClient.setRequestHandler).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
);
});
test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeUndefined();
});
test("elicitation request handler calls onElicitationRequest callback", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeDefined();
const [, handler] = elicitRequestHandlerCall;
const mockElicitationRequest: ElicitRequest = {
method: "elicitation/create",
params: {
message: "Please provide your name",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
resolve({ action: "accept", content: { name: "test" } });
});
await act(async () => {
await handler(mockElicitationRequest);
});
expect(mockOnElicitationRequest).toHaveBeenCalledWith(
mockElicitationRequest,
expect.any(Function),
);
});
test("elicitation request handler returns a promise that resolves with the callback result", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
const [, handler] = elicitRequestHandlerCall;
const mockElicitationRequest: ElicitRequest = {
method: "elicitation/create",
params: {
message: "Please provide your name",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
const mockResponse: ElicitResult = {
action: "accept",
content: { name: "John Doe" },
};
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
resolve(mockResponse);
});
let handlerResult;
await act(async () => {
handlerResult = await handler(mockElicitationRequest);
});
expect(handlerResult).toEqual(mockResponse);
});
});
describe("URL Port Handling", () => {
const SSEClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/sse.js",

View file

@ -28,15 +28,20 @@ import {
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
Progress,
ElicitRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { InspectorOAuthClientProvider } from "../auth";
import {
clearClientInformationFromSessionStorage,
InspectorOAuthClientProvider,
saveClientInformationToSessionStorage,
} from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
@ -56,12 +61,16 @@ interface UseConnectionOptions {
env: Record<string, string>;
bearerToken?: string;
headerName?: string;
oauthClientId?: string;
oauthScope?: string;
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onElicitationRequest?: (request: any, resolve: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
}
@ -73,10 +82,13 @@ export function useConnection({
env,
bearerToken,
headerName,
oauthClientId,
oauthScope,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
onElicitationRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] =
@ -93,6 +105,22 @@ export function useConnection({
>([]);
const [completionsSupported, setCompletionsSupported] = useState(false);
useEffect(() => {
if (!oauthClientId) {
clearClientInformationFromSessionStorage({
serverUrl: sseUrl,
isPreregistered: true,
});
return;
}
saveClientInformationToSessionStorage({
serverUrl: sseUrl,
clientInformation: { client_id: oauthClientId },
isPreregistered: true,
});
}, [oauthClientId, sseUrl]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
@ -286,7 +314,10 @@ export function useConnection({
if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
const result = await auth(serverAuthProvider, {
serverUrl: sseUrl,
scope: oauthScope,
});
return result === "AUTHORIZED";
}
@ -302,6 +333,7 @@ export function useConnection({
{
capabilities: {
sampling: {},
elicitation: {},
roots: {
listChanged: true,
},
@ -528,6 +560,14 @@ export function useConnection({
});
}
if (onElicitationRequest) {
client.setRequestHandler(ElicitRequestSchema, async (request) => {
return new Promise((resolve) => {
onElicitationRequest(request, resolve);
});
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {

Some files were not shown because too many files have changed in this diff Show more