diff --git a/client/package.json b/client/package.json index ee3f7c30b..600408660 100644 --- a/client/package.json +++ b/client/package.json @@ -25,9 +25,8 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.14.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", diff --git a/client/src/App.tsx b/client/src/App.tsx index c8692c091..c4c89564b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -71,6 +71,10 @@ import { initializeInspectorConfig, saveInspectorConfig, } from "./utils/configUtils"; +import ElicitationTab, { + PendingElicitationRequest, + ElicitationResponse, +} from "./components/ElicitationTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -135,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(EMPTY_DEBUGGER_STATE); - // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { setAuthState((prev) => ({ ...prev, ...updates })); }; @@ -169,6 +179,19 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + const initialTab = hash || "resources"; + return initialTab; + }); + + const currentTabRef = useRef(activeTab); + const lastToolCallOriginTabRef = useRef(activeTab); + + useEffect(() => { + currentTabRef.current = activeTab; + }, [activeTab]); + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); const { width: sidebarWidth, @@ -213,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]); @@ -252,7 +330,6 @@ const App = () => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); - // Auto-connect to previously saved serverURL after OAuth callback const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); @@ -262,7 +339,6 @@ const App = () => { [connectMcpServer], ); - // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( async ({ authorizationCode, @@ -283,7 +359,6 @@ const App = () => { } if (restoredState && authorizationCode) { - // Restore the previous auth state and continue the OAuth flow let currentState: AuthDebuggerState = { ...restoredState, authorizationCode, @@ -294,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" @@ -308,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: { @@ -331,7 +403,6 @@ const App = () => { }); } } else if (authorizationCode) { - // Fallback to the original behavior if no state was restored updateAuthState({ authorizationCode, oauthStep: "token_request", @@ -341,7 +412,6 @@ const App = () => { [sseUrl], ); - // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -400,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); @@ -416,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 })); }; @@ -473,7 +593,23 @@ const App = () => { setNextResourceTemplateCursor(response.nextCursor); }; + const getPrompt = async (name: string, args: Record = {}) => { + 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, @@ -535,18 +671,6 @@ const App = () => { setNextPromptCursor(response.nextCursor); }; - const getPrompt = async (name: string, args: Record = {}) => { - 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( { @@ -558,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) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + try { const response = await sendMCPRequest( { @@ -578,6 +703,7 @@ const App = () => { CompatibilityCallToolResultSchema, "tools", ); + setToolResult(response); } catch (e) { const toolResult: CompatibilityCallToolResult = { @@ -612,7 +738,6 @@ const App = () => { setStdErrNotifications([]); }; - // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( { ); - // Helper function to render OAuth callback components if (window.location.pathname === "/oauth/callback") { const OAuthCallback = React.lazy( () => import("./components/OAuthCallback"), @@ -688,7 +812,6 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} clearStdErrNotifications={clearStdErrNotifications} /> - {/* Drag handle for resizing sidebar */}
{
{mcpClient ? ( (window.location.hash = value)} + onValueChange={(value) => { + setActiveTab(value); + window.location.hash = value; + }} > { )} + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + Roots @@ -876,7 +999,6 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); - // Clear cached output schemas cacheToolOutputSchemas([]); }} callTool={async (name, params) => { @@ -915,6 +1037,10 @@ const App = () => { onApprove={handleApproveSampling} onReject={handleRejectSampling} /> + { 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 ( + + ); + } + + if (propSchema.enum) { + return ( + + ); + } + + 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 ( { 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 ( ); + case "integer": return ( ); + case "boolean": return ( void; +}; + +const ElicitationRequest = ({ + request, + onResolve, +}: ElicitationRequestProps) => { + const [formData, setFormData] = useState({}); + const [validationError, setValidationError] = useState(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; + + 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, + }); + } 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 ( +
+
+
+

{schemaTitle}

+

{request.request.message}

+ {schemaDescription && ( +

{schemaDescription}

+ )} +
+
Request Schema:
+ +
+
+
+ +
+
+

Response Form

+ { + setFormData(newValue); + setValidationError(null); + }} + /> + + {validationError && ( +
+
+ Validation Error: {validationError} +
+
+ )} +
+ +
+ + + +
+
+
+ ); +}; + +export default ElicitationRequest; diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx new file mode 100644 index 000000000..cf3be2b5c --- /dev/null +++ b/client/src/components/ElicitationTab.tsx @@ -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; +} + +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 ( + +
+ + + When the server requests information from the user, requests will + appear here for response. + + +
+

Recent Requests

+ {pendingRequests.map((request) => ( + + ))} + {pendingRequests.length === 0 && ( +

No pending requests

+ )} +
+
+
+ ); +}; + +export default ElicitationTab; diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index cd3214187..22813c9bc 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 = { diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationRequest.test.tsx new file mode 100644 index 000000000..f2af25936 --- /dev/null +++ b/client/src/components/__tests__/ElicitationRequest.test.tsx @@ -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 ( +
+ { + try { + const parsed = JSON.parse(e.target.value); + onChange(parsed); + } catch { + onChange(e.target.value); + } + }} + /> +
+ ); + }; +}); + +describe("ElicitationRequest", () => { + const mockOnResolve = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createMockRequest = ( + overrides: Partial = {}, + ): 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( + , + ); + }; + + 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" }), + ); + }); + }); +}); diff --git a/client/src/components/__tests__/ElicitationTab.test.tsx b/client/src/components/__tests__/ElicitationTab.test.tsx new file mode 100644 index 000000000..ca5194619 --- /dev/null +++ b/client/src/components/__tests__/ElicitationTab.test.tsx @@ -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( + + + , + ); + + 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); + }); +}); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 277e4e463..c340d7748 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -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({ @@ -200,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", diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4cea62e53..849eb909b 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -28,6 +28,7 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, Progress, + ElicitRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { useEffect, useState } from "react"; @@ -68,6 +69,8 @@ interface UseConnectionOptions { // 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[]; } @@ -85,6 +88,7 @@ export function useConnection({ onNotification, onStdErrNotification, onPendingRequest, + onElicitationRequest, getRoots, }: UseConnectionOptions) { const [connectionStatus, setConnectionStatus] = @@ -322,6 +326,7 @@ export function useConnection({ { capabilities: { sampling: {}, + elicitation: {}, roots: { listChanged: true, }, @@ -548,6 +553,14 @@ export function useConnection({ }); } + if (onElicitationRequest) { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + return new Promise((resolve) => { + onElicitationRequest(request, resolve); + }); + }); + } + setMcpClient(client); setConnectionStatus("connected"); } catch (e) { diff --git a/client/src/utils/__tests__/jsonUtils.test.ts b/client/src/utils/__tests__/jsonUtils.test.ts index 055e1dfd0..42938eb55 100644 --- a/client/src/utils/__tests__/jsonUtils.test.ts +++ b/client/src/utils/__tests__/jsonUtils.test.ts @@ -4,7 +4,7 @@ import { updateValueAtPath, getValueAtPath, } from "../jsonUtils"; -import type { JsonValue } from "../jsonUtils"; +import type { JsonValue, JsonSchemaType } from "../jsonUtils"; describe("getDataType", () => { test("should return 'string' for string values", () => { @@ -317,3 +317,210 @@ describe("getValueAtPath", () => { expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane"); }); }); + +describe("JsonSchemaType elicitation field support", () => { + const sampleSchema: JsonSchemaType = { + type: "object", + title: "User Info", + description: "User information form", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Your full name", + minLength: 2, + maxLength: 50, + pattern: "^[A-Za-z\\s]+$", + }, + email: { + type: "string", + format: "email", + title: "Email Address", + }, + age: { + type: "integer", + minimum: 18, + maximum: 120, + default: 25, + }, + role: { + type: "string", + oneOf: [ + { const: "admin", title: "Administrator" }, + { const: "user", title: "User" }, + { const: "guest", title: "Guest" }, + ], + }, + }, + required: ["name", "email"], + }; + + test("should parse JsonSchemaType with elicitation fields", () => { + const schemaString = JSON.stringify(sampleSchema); + const result = tryParseJson(schemaString); + + expect(result.success).toBe(true); + expect(result.data).toEqual(sampleSchema); + }); + + test("should update schema properties with new validation fields", () => { + const updated = updateValueAtPath( + sampleSchema, + ["properties", "name", "minLength"], + 5, + ); + + expect(getValueAtPath(updated, ["properties", "name", "minLength"])).toBe( + 5, + ); + }); + + test("should handle oneOf with const and title fields", () => { + const schema = { + type: "string", + oneOf: [ + { const: "option1", title: "Option 1" }, + { const: "option2", title: "Option 2" }, + ], + }; + + expect(getValueAtPath(schema, ["oneOf", "0", "const"])).toBe("option1"); + expect(getValueAtPath(schema, ["oneOf", "1", "title"])).toBe("Option 2"); + }); + + test("should handle validation constraints", () => { + const numberSchema = { + type: "number" as const, + minimum: 0, + maximum: 100, + default: 50, + }; + + expect(getValueAtPath(numberSchema, ["minimum"])).toBe(0); + expect(getValueAtPath(numberSchema, ["maximum"])).toBe(100); + expect(getValueAtPath(numberSchema, ["default"])).toBe(50); + }); + + test("should handle string format and pattern fields", () => { + const stringSchema = { + type: "string" as const, + format: "email", + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + minLength: 5, + maxLength: 100, + }; + + expect(getValueAtPath(stringSchema, ["format"])).toBe("email"); + expect(getValueAtPath(stringSchema, ["pattern"])).toBe( + "^[a-z]+@[a-z]+\\.[a-z]+$", + ); + expect(getValueAtPath(stringSchema, ["minLength"])).toBe(5); + }); + + test("should handle title and description fields", () => { + const schema = { + type: "boolean" as const, + title: "Accept Terms", + description: "Do you accept the terms and conditions?", + default: false, + }; + + expect(getValueAtPath(schema, ["title"])).toBe("Accept Terms"); + expect(getValueAtPath(schema, ["description"])).toBe( + "Do you accept the terms and conditions?", + ); + }); + + test("should handle JSON Schema spec compliant oneOf with const for labeled enums", () => { + // Example from JSON Schema spec: labeled enums using oneOf with const + const trafficLightSchema = { + type: "string" as const, + title: "Traffic Light", + description: "Select a traffic light color", + oneOf: [ + { const: "red", title: "Stop" }, + { const: "amber", title: "Caution" }, + { const: "green", title: "Go" }, + ], + }; + + // Verify the schema structure + expect(trafficLightSchema.type).toBe("string"); + expect(trafficLightSchema.oneOf).toHaveLength(3); + + // Verify each oneOf option has const and title + expect(trafficLightSchema.oneOf[0].const).toBe("red"); + expect(trafficLightSchema.oneOf[0].title).toBe("Stop"); + + expect(trafficLightSchema.oneOf[1].const).toBe("amber"); + expect(trafficLightSchema.oneOf[1].title).toBe("Caution"); + + expect(trafficLightSchema.oneOf[2].const).toBe("green"); + expect(trafficLightSchema.oneOf[2].title).toBe("Go"); + + // Test with JsonValue operations + const schemaAsJsonValue = trafficLightSchema as JsonValue; + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "0", "const"])).toBe( + "red", + ); + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "1", "title"])).toBe( + "Caution", + ); + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "2", "const"])).toBe( + "green", + ); + }); + + test("should handle complex oneOf scenarios with mixed schema types", () => { + const complexSchema = { + type: "object" as const, + title: "User Preference", + properties: { + theme: { + type: "string" as const, + oneOf: [ + { const: "light", title: "Light Mode" }, + { const: "dark", title: "Dark Mode" }, + { const: "auto", title: "Auto (System)" }, + ], + }, + notifications: { + type: "string" as const, + oneOf: [ + { const: "all", title: "All Notifications" }, + { const: "important", title: "Important Only" }, + { const: "none", title: "None" }, + ], + }, + }, + }; + + expect( + getValueAtPath(complexSchema, [ + "properties", + "theme", + "oneOf", + "0", + "const", + ]), + ).toBe("light"); + expect( + getValueAtPath(complexSchema, [ + "properties", + "theme", + "oneOf", + "1", + "title", + ]), + ).toBe("Dark Mode"); + expect( + getValueAtPath(complexSchema, [ + "properties", + "notifications", + "oneOf", + "2", + "const", + ]), + ).toBe("none"); + }); +}); diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 833179389..338b642ac 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -7,8 +7,14 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +export type JsonSchemaConst = { + const: JsonValue; + title?: string; + description?: string; +}; + export type JsonSchemaType = { - type: + type?: | "string" | "number" | "integer" @@ -16,11 +22,22 @@ export type JsonSchemaType = { | "array" | "object" | "null"; + title?: string; description?: string; required?: string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + enum?: string[]; + const?: JsonValue; + oneOf?: (JsonSchemaType | JsonSchemaConst)[]; + anyOf?: (JsonSchemaType | JsonSchemaConst)[]; }; export type JsonObject = { [key: string]: JsonValue }; diff --git a/package-lock.json b/package-lock.json index 709ce6217..616e96491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.16.1", "@modelcontextprotocol/inspector-client": "^0.16.1", "@modelcontextprotocol/inspector-server": "^0.16.1", - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.14.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -70,7 +70,7 @@ "version": "0.16.1", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.14.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -2009,15 +2009,17 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.1.tgz", - "integrity": "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.0.tgz", + "integrity": "sha512-67hnl/ROKdb03Vuu0YOr+baKTvf1/5YBHBm9KnZdjdAh8hjt4FRCPD5ucwxGB237sBpzlqQsLy1PFu7z/ekZ9Q==", + "license": "MIT", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/package.json b/package.json index b0a2e98ea..f9461491c 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@modelcontextprotocol/inspector-cli": "^0.16.1", "@modelcontextprotocol/inspector-client": "^0.16.1", "@modelcontextprotocol/inspector-server": "^0.16.1", - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.14.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2",