diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..c9c2ee11d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,8 @@ import { LoggingLevel, Task, GetTaskResultSchema, + McpError, + ErrorCode, } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { @@ -252,12 +254,7 @@ const App = () => { > >([]); const [pendingElicitationRequests, setPendingElicitationRequests] = useState< - Array< - PendingElicitationRequest & { - resolve: (response: ElicitationResponse) => void; - decline: (error: Error) => void; - } - > + Array >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); @@ -445,7 +442,10 @@ const App = () => { request: { id: nextRequestId.current, message: request.params.message, + mode: request.params.mode || "form", // Default to form for backwards compatibility requestedSchema: request.params.requestedSchema, + url: request.params.url, + elicitationId: request.params.elicitationId, }, originatingTab: currentTab, resolve, @@ -1191,16 +1191,89 @@ const App = () => { return directResult; } } catch (e) { - const toolResult: CompatibilityCallToolResult = { - content: [ - { - type: "text", - text: (e as Error).message ?? String(e), - }, - ], - isError: true, - }; - setToolResult(toolResult); + let toolResult: CompatibilityCallToolResult; + // Check if this is a URLElicitationRequiredError (error code -32042) + if ( + e instanceof McpError && + e.code === ErrorCode.UrlElicitationRequired + ) { + // Extract elicitation info from error data + const errorData = e.data as { + elicitations?: Array<{ + mode: "url"; + elicitationId: string; + url: string; + message: string; + }>; + }; + + if (errorData?.elicitations && errorData.elicitations.length > 0) { + // Process each elicitation request + errorData.elicitations.forEach((elicitation) => { + setPendingElicitationRequests((prev) => [ + ...prev, + { + id: nextRequestId.current++, + request: { + id: nextRequestId.current, + mode: elicitation.mode, + message: elicitation.message, + url: elicitation.url, + elicitationId: elicitation.elicitationId, + }, + originatingTab: "tools", + resolve: (result) => { + // After elicitation is resolved, user can retry the tool call + console.log("URL elicitation resolved:", result); + }, + decline: (error: Error) => { + console.error("URL elicitation declined:", error); + }, + }, + ]); + }); + + // Switch to elicitations tab + setActiveTab("elicitations"); + window.location.hash = "elicitations"; + + // Show a message in the tool result that elicitation is required + toolResult = { + content: [ + { + type: "text", + text: `This operation requires additional authorization. Please complete the elicitation request in the Elicitations tab.`, + }, + ], + isError: false, + }; + setToolResult(toolResult); + } else { + // No elicitation data provided, show the error + toolResult = { + content: [ + { + type: "text", + text: (e as Error).message ?? String(e), + }, + ], + isError: true, + }; + setToolResult(toolResult); + } + } else { + // Regular error handling + toolResult = { + content: [ + { + type: "text", + text: (e as Error).message ?? String(e), + }, + ], + isError: true, + }; + setToolResult(toolResult); + } // Clear validation errors - tool execution errors are shown in ToolResults setErrors((prev) => ({ ...prev, tools: null })); return toolResult; diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationRequest.tsx index 4488a9620..c95494cf3 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/client/src/components/ElicitationRequest.tsx @@ -9,6 +9,8 @@ import { ElicitationResponse, } from "./ElicitationTab"; import Ajv from "ajv"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ExternalLink } from "lucide-react"; export type ElicitationRequestProps = { request: PendingElicitationRequest; @@ -22,11 +24,18 @@ const ElicitationRequest = ({ const [formData, setFormData] = useState({}); const [validationError, setValidationError] = useState(null); + const requestedSchema = + request.request.mode === "form" + ? request.request.requestedSchema + : undefined; + useEffect(() => { - const defaultValue = generateDefaultValue(request.request.requestedSchema); - setFormData(defaultValue); - setValidationError(null); - }, [request.request.requestedSchema]); + if (requestedSchema) { + const defaultValue = generateDefaultValue(requestedSchema); + setFormData(defaultValue); + setValidationError(null); + } + }, [requestedSchema]); const validateEmailFormat = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -73,30 +82,62 @@ const ElicitationRequest = ({ return true; }; - const handleAccept = () => { + const validateUrl = (url: string): boolean => { try { - if (!validateFormData(formData, request.request.requestedSchema)) { - return; + const parsedUrl = new URL(url); + // Only allow HTTPS URLs for security + if (parsedUrl.protocol !== "https:") { + setValidationError("Only HTTPS URLs are allowed for security reasons"); + return false; } + return true; + } catch { + setValidationError("Invalid URL format"); + return false; + } + }; + + const handleAccept = () => { + if (request.request.mode === "url") { + // For URL mode, just accept and let the browser handle the URL + onResolve(request.id, { action: "accept" }); + } else if ( + request.request.mode === "form" && + request.request.requestedSchema + ) { + // For form mode, validate and submit the form data + try { + if (!validateFormData(formData, request.request.requestedSchema)) { + return; + } + + const ajv = new Ajv(); + const validate = ajv.compile(request.request.requestedSchema); + const isValid = validate(formData); - 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; + } - if (!isValid) { - const errorMessage = ajv.errorsText(validate.errors); - setValidationError(errorMessage); - return; + onResolve(request.id, { + action: "accept", + content: formData as Record, + }); + } catch (error) { + setValidationError( + error instanceof Error ? error.message : "Validation failed", + ); } + } + }; - onResolve(request.id, { - action: "accept", - content: formData as Record, - }); - } catch (error) { - setValidationError( - error instanceof Error ? error.message : "Validation failed", - ); + const handleOpenUrl = () => { + if (request.request.mode === "url" && request.request.url) { + if (validateUrl(request.request.url)) { + window.open(request.request.url, "_blank", "noopener,noreferrer"); + } } }; @@ -108,9 +149,85 @@ const ElicitationRequest = ({ onResolve(request.id, { action: "cancel" }); }; + // Render URL mode elicitation + if (request.request.mode === "url") { + return ( +
+
+ + + +
+

URL Elicitation Request

+

{request.request.message}

+ {request.request.url && ( +
+

+ Target Domain: + + {(() => { + try { + return new URL(request.request.url!).host; + } catch { + return request.request.url; + } + })()} + +

+

+ URL: + + {request.request.url} + +

+
+ )} + {request.request.elicitationId && ( +

+ Elicitation ID: + + {request.request.elicitationId} + +

+ )} +
+
+
+ +
+ + + + +
+ + {validationError && ( +
+
+ Error: {validationError} +
+
+ )} +
+
+ ); + } + + // Render form mode elicitation (existing implementation) const schemaTitle = - request.request.requestedSchema.title || "Information Request"; - const schemaDescription = request.request.requestedSchema.description; + request.request.requestedSchema?.title || "Information Request"; + const schemaDescription = request.request.requestedSchema?.description; return (
{schemaDescription}

)} -
-
Request Schema:
- -
+ {request.request.requestedSchema && ( +
+
Request Schema:
+ +
+ )}

Response Form

- { - setFormData(newValue); - setValidationError(null); - }} - /> + {request.request.requestedSchema && ( + { + setFormData(newValue); + setValidationError(null); + }} + /> + )} {validationError && (
diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx index cf3be2b5c..15e023913 100644 --- a/client/src/components/ElicitationTab.tsx +++ b/client/src/components/ElicitationTab.tsx @@ -3,12 +3,26 @@ import { TabsContent } from "@/components/ui/tabs"; import { JsonSchemaType } from "@/utils/jsonUtils"; import ElicitationRequest from "./ElicitationRequest"; -export interface ElicitationRequestData { +interface ElicitationRequestDataBase { id: number; message: string; - requestedSchema: JsonSchemaType; } +interface FormElicitationRequestData extends ElicitationRequestDataBase { + mode: "form"; + requestedSchema?: JsonSchemaType; +} + +interface UrlElicitationRequestData extends ElicitationRequestDataBase { + mode: "url"; + url: string; + elicitationId: string; +} + +export type ElicitationRequestData = + | FormElicitationRequestData + | UrlElicitationRequestData; + export interface ElicitationResponse { action: "accept" | "decline" | "cancel"; content?: Record; @@ -18,6 +32,8 @@ export type PendingElicitationRequest = { id: number; request: ElicitationRequestData; originatingTab?: string; + resolve: (response: ElicitationResponse) => void; + decline: (error: Error) => void; }; export type Props = { diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationRequest.test.tsx index f2af25936..368d5ef7e 100644 --- a/client/src/components/__tests__/ElicitationRequest.test.tsx +++ b/client/src/components/__tests__/ElicitationRequest.test.tsx @@ -53,6 +53,7 @@ describe("ElicitationRequest", () => { request: { id: 1, message: "Please provide your information", + mode: "form", requestedSchema: { type: "object", properties: { @@ -65,6 +66,20 @@ describe("ElicitationRequest", () => { ...overrides, }); + const createMockUrlRequest = ( + overrides: Partial = {}, + ): PendingElicitationRequest => ({ + id: 1, + request: { + id: 1, + message: "Please authorize access to your GitHub account", + mode: "url", + url: "https://github.com/login/oauth/authorize?client_id=test", + elicitationId: "test-elicitation-id", + }, + ...overrides, + }); + const renderElicitationRequest = ( request: PendingElicitationRequest = createMockRequest(), ) => { @@ -86,6 +101,7 @@ describe("ElicitationRequest", () => { request: { id: 1, message, + mode: "form", requestedSchema: { type: "object", properties: { name: { type: "string" } }, @@ -199,4 +215,112 @@ describe("ElicitationRequest", () => { ); }); }); + + describe("URL Mode Elicitation", () => { + it("should render URL mode elicitation request", () => { + renderElicitationRequest(createMockUrlRequest()); + expect(screen.getByText(/URL Elicitation Request/i)).toBeInTheDocument(); + expect( + screen.getByText(/Please authorize access to your GitHub account/i), + ).toBeInTheDocument(); + }); + + it("should display the target URL", () => { + const url = "https://github.com/login/oauth/authorize?client_id=test"; + renderElicitationRequest(createMockUrlRequest()); + expect(screen.getByText(url)).toBeInTheDocument(); + }); + + it("should display the elicitation ID", () => { + renderElicitationRequest(createMockUrlRequest()); + expect(screen.getByText(/Elicitation ID:/i)).toBeInTheDocument(); + expect(screen.getByText("test-elicitation-id")).toBeInTheDocument(); + }); + + it("should render Open URL button", () => { + renderElicitationRequest(createMockUrlRequest()); + expect( + screen.getByRole("button", { name: /open url/i }), + ).toBeInTheDocument(); + }); + + it("should call window.open when Open URL button is clicked", async () => { + const mockOpen = jest.fn(); + const originalOpen = window.open; + window.open = mockOpen as unknown as typeof window.open; + + renderElicitationRequest(createMockUrlRequest()); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /open url/i })); + }); + + expect(mockOpen).toHaveBeenCalledWith( + "https://github.com/login/oauth/authorize?client_id=test", + "_blank", + "noopener,noreferrer", + ); + + window.open = originalOpen; + }); + + it("should call onResolve with accept action when Accept button is clicked", async () => { + renderElicitationRequest(createMockUrlRequest()); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^accept$/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "accept" }); + }); + + it("should call onResolve with decline action when Decline button is clicked", async () => { + renderElicitationRequest(createMockUrlRequest()); + + 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(createMockUrlRequest()); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" }); + }); + + it("should reject non-HTTPS URLs", async () => { + const mockOpen = jest.fn(); + const originalOpen = window.open; + window.open = mockOpen as unknown as typeof window.open; + + renderElicitationRequest( + createMockUrlRequest({ + request: { + id: 1, + message: "Test", + mode: "url", + url: "http://insecure.com/oauth", + elicitationId: "test-id", + }, + }), + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /open url/i })); + }); + + expect(mockOpen).not.toHaveBeenCalled(); + expect( + screen.getByText(/Only HTTPS URLs are allowed for security reasons/i), + ).toBeInTheDocument(); + + window.open = originalOpen; + }); + }); }); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 4907a085b..4989cd367 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -518,7 +518,10 @@ describe("useConnection", () => { }), expect.objectContaining({ capabilities: expect.objectContaining({ - elicitation: {}, + elicitation: { + form: {}, + url: {}, + }, }), }), ); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e14d1037f..320f0a220 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -29,6 +29,7 @@ import { ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema, Progress, LoggingLevel, ElicitRequestSchema, @@ -452,7 +453,10 @@ export function useConnection({ const clientCapabilities = { capabilities: { sampling: {}, - elicitation: {}, + elicitation: { + form: {}, + url: {}, + }, roots: { listChanged: true, }, @@ -754,6 +758,7 @@ export function useConnection({ ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema, ].forEach((notificationSchema) => { client.setNotificationHandler(notificationSchema, onNotification); }); diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index bc081b8f8..22696d8b8 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -143,17 +143,19 @@ export const initializeInspectorConfig = ( baseConfig = { ...baseConfig, ...parsedEphemeralConfig }; } - // Ensure all config items have the latest labels/descriptions from defaults - for (const [key, value] of Object.entries(baseConfig)) { - baseConfig[key as keyof InspectorConfig] = { - ...value, - label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, - description: - DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description, - is_session_item: - DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item, + // Ensure all config items have the latest labels/descriptions from defaults, + // and remove any stale keys that no longer exist in defaults + const validConfig = {} as InspectorConfig; + for (const key of Object.keys(DEFAULT_INSPECTOR_CONFIG)) { + const typedKey = key as keyof InspectorConfig; + validConfig[typedKey] = { + ...(baseConfig[typedKey] ?? DEFAULT_INSPECTOR_CONFIG[typedKey]), + label: DEFAULT_INSPECTOR_CONFIG[typedKey].label, + description: DEFAULT_INSPECTOR_CONFIG[typedKey].description, + is_session_item: DEFAULT_INSPECTOR_CONFIG[typedKey].is_session_item, }; } + baseConfig = validConfig; // Apply query param overrides const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);