diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index ae8fe8974598..6c69c50cc4e5 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@ai-sdk/openai": "^2.0.14", "@ai-sdk/ui-utils": "^1.2.11", - "@mcp-ui/client": "~5.6.2", + "@mcp-ui/client": "^5.9.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -2600,18 +2600,20 @@ } }, "node_modules/@mcp-ui/client": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.6.2.tgz", - "integrity": "sha512-CLHin0eDM+0m0AmSc/PS0XgZAU2D+b/ppt4CxLykTck4CrQ0Gi589ieqh9VidijiB9R5Aw3F8Wi/IUr6Tp6urg==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.9.0.tgz", + "integrity": "sha512-I7ZAZKSo08GtDRrPZNlD8Ij6EFOJvnIfsal6MIwYxY8AtMkbLkI+DDkM9QhSmMDDJvK0jtqWEVu+5KZK+pQYlQ==", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "*", "@quilted/threads": "^3.1.3", "@r2wc/react-to-web-component": "^2.0.4", "@remote-dom/core": "^1.8.0", - "@remote-dom/react": "^1.2.2", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "@remote-dom/react": "^1.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" } }, "node_modules/@mcp-ui/client/node_modules/@remote-dom/react": { @@ -2646,40 +2648,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@mcp-ui/client/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@mcp-ui/client/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/@mcp-ui/client/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index b8dd418f4f71..94635ea8e29d 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -41,7 +41,7 @@ "dependencies": { "@ai-sdk/openai": "^2.0.14", "@ai-sdk/ui-utils": "^1.2.11", - "@mcp-ui/client": "~5.6.2", + "@mcp-ui/client": "^5.9.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -139,6 +139,10 @@ }, "keywords": [], "license": "Apache-2.0", + "overrides": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, "lint-staged": { "src/**/*.{ts,tsx}": [ "bash -c 'npm run typecheck'", diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index facb7167fbb5..e54f101a73c3 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -332,6 +332,21 @@ function BaseChatContent({ } }, []); + // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) + useEffect(() => { + const handleGlobalScrollRequest = () => { + // Add a small delay to ensure content has been rendered + setTimeout(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, 200); + }; + + window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); + return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); + }, []); + return (
))} diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 50cc4522cd22..3e5ad494a564 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -1,62 +1,395 @@ -import { UIResourceRenderer, UIActionResult } from '@mcp-ui/client'; +import { + UIResourceRenderer, + UIActionResultIntent, + UIActionResultLink, + UIActionResultNotification, + UIActionResultPrompt, + UIActionResultToolCall, +} from '@mcp-ui/client'; +import { useState, useEffect } from 'react'; import { ResourceContent } from '../types/message'; -import { useCallback } from 'react'; import { toast } from 'react-toastify'; interface MCPUIResourceRendererProps { content: ResourceContent; + appendPromptToChat?: (value: string) => void; } +type UISizeChange = { + type: 'ui-size-change'; + payload: { + height: number; + width: number; + }; +}; + +// Reserved message types from iframe to host +type UILifecycleIframeReady = { + type: 'ui-lifecycle-iframe-ready'; + payload?: Record; +}; -export default function MCPUIResourceRenderer({ content }: MCPUIResourceRendererProps) { - const handleAction = (action: UIActionResult) => { - console.log( - `MCP UI message received (but only handled with a toast notification for now):`, - action - ); - toast.info(`${action.type} message sent from MCP UI, refer to console for more info`, { - data: action, - }); - return { status: 'handled', message: `${action.type} action logged` }; +type UIRequestData = { + type: 'ui-request-data'; + messageId: string; + payload: { + requestType: string; + params: Record; }; +}; - const handleUIAction = useCallback(async (result: UIActionResult) => { - switch (result.type) { - case 'intent': { - // TODO: Implement intent handling - handleAction(result); - break; - } +// We are creating a new type to support all reserved message types that may come from the iframe +// Not all reserved message types are currently exported by @mcp-ui/client +type ActionEventsFromIframe = + | UIActionResultIntent + | UIActionResultLink + | UIActionResultNotification + | UIActionResultPrompt + | UIActionResultToolCall + | UISizeChange + | UILifecycleIframeReady + | UIRequestData; - case 'link': { - // TODO: Implement link handling - handleAction(result); - break; - } +// More specific result types using discriminated unions +type UIActionHandlerSuccess = { + status: 'success'; + data?: T; + message?: string; +}; - case 'notify': { - // TODO: Implement notify handling - handleAction(result); - break; - } +type UIActionHandlerError = { + status: 'error'; + error: { + code: UIActionErrorCode; + message: string; + details?: unknown; + }; +}; + +type UIActionHandlerPending = { + status: 'pending'; + message: string; +}; + +type UIActionHandlerResult = + | UIActionHandlerSuccess + | UIActionHandlerError + | UIActionHandlerPending; + +// Strongly typed error codes +enum UIActionErrorCode { + UNSUPPORTED_ACTION = 'UNSUPPORTED_ACTION', + UNKNOWN_ACTION = 'UNKNOWN_ACTION', + TOOL_NOT_FOUND = 'TOOL_NOT_FOUND', + TOOL_EXECUTION_FAILED = 'TOOL_EXECUTION_FAILED', + NAVIGATION_FAILED = 'NAVIGATION_FAILED', + PROMPT_FAILED = 'PROMPT_FAILED', + INTENT_FAILED = 'INTENT_FAILED', + INVALID_PARAMS = 'INVALID_PARAMS', + NETWORK_ERROR = 'NETWORK_ERROR', + TIMEOUT = 'TIMEOUT', +} + +// toast component +const ToastComponent = ({ + messageType, + message, + isImplemented = true, +}: { + messageType: string; + message?: string; + isImplemented?: boolean; +}) => { + const title = `MCP-UI ${messageType} message`; - case 'prompt': { - // TODO: Implement prompt handling - handleAction(result); - break; + return ( +
+

{title}

+ {isImplemented ? ( +

+ Message received for {message}. +

+ ) : ( +

+ Message received for {message}. +
+ {messageType.charAt(0).toUpperCase() + messageType.slice(1)} messages aren't supported + yet, refer to console for more details. +

+ )} +
+ ); +}; + +export default function MCPUIResourceRenderer({ + content, + appendPromptToChat, +}: MCPUIResourceRendererProps) { + const [currentThemeValue, setCurrentThemeValue] = useState('light'); + + useEffect(() => { + const theme = localStorage.getItem('theme') || 'light'; + setCurrentThemeValue(theme); + console.log('[MCP-UI] Current theme value:', theme); + }, []); + + const handleUIAction = async ( + actionEvent: ActionEventsFromIframe + ): Promise => { + // result to pass back to the MCP-UI + let result: UIActionHandlerResult; + + const handleToolCase = async ( + actionEvent: UIActionResultToolCall + ): Promise => { + const { toolName, params } = actionEvent.payload; + toast.info(, { + theme: currentThemeValue, + }); + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Tool calls are not yet implemented', + details: { toolName, params }, + }, + }; + }; + + const handlePromptCase = async ( + actionEvent: UIActionResultPrompt + ): Promise => { + const { prompt } = actionEvent.payload; + + if (appendPromptToChat) { + try { + appendPromptToChat(prompt); + window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); + return { + status: 'success' as const, + message: 'Prompt sent to chat successfully', + }; + } catch (error) { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.PROMPT_FAILED, + message: 'Failed to send prompt to chat', + details: error instanceof Error ? error.message : error, + }, + }; + } } - case 'tool': { - // TODO: Implement tool call handling - handleAction(result); - break; + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Prompt handling is not implemented - append prop is required', + details: { prompt }, + }, + }; + }; + + const handleLinkCase = async (actionEvent: UIActionResultLink) => { + const { url } = actionEvent.payload; + + try { + const urlObj = new URL(url); + if (!['http:', 'https:'].includes(urlObj.protocol)) { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, + details: { url, protocol: urlObj.protocol }, + }, + }; + } + + await window.electron.openExternal(url); + return { + status: 'success' as const, + message: `Opened ${url} in default browser`, + }; + } catch (error) { + if (error instanceof TypeError && error.message.includes('Invalid URL')) { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.INVALID_PARAMS, + message: `Invalid URL format: ${url}`, + details: { url, error: error.message }, + }, + }; + } else if (error instanceof Error && error.message.includes('Failed to open')) { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Failed to open URL in default browser`, + details: { url, error: error.message }, + }, + }; + } else { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Unexpected error opening URL: ${url}`, + details: error instanceof Error ? error.message : error, + }, + }; + } } + }; + + const handleNotifyCase = async ( + actionEvent: UIActionResultNotification + ): Promise => { + const { message } = actionEvent.payload; + + toast.info(, { + theme: currentThemeValue, + }); + return { + status: 'success' as const, + data: { + displayedAt: new Date().toISOString(), + message: 'Notification displayed', + details: actionEvent.payload, + }, + }; + }; + + const handleIntentCase = async ( + actionEvent: UIActionResultIntent + ): Promise => { + toast.info( + , + { + theme: currentThemeValue, + } + ); + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Intent handling is not yet implemented', + details: actionEvent.payload, + }, + }; + }; + + const handleSizeChangeCase = async ( + actionEvent: UISizeChange + ): Promise => { + return { + status: 'success' as const, + message: 'Size change handled', + data: actionEvent.payload, + }; + }; + + const handleIframeReadyCase = async ( + actionEvent: UILifecycleIframeReady + ): Promise => { + console.log('[MCP-UI] Iframe ready to receive messages'); + return { + status: 'success' as const, + message: 'Iframe is ready to receive messages', + data: actionEvent.payload, + }; + }; + + const handleRequestDataCase = async ( + actionEvent: UIRequestData + ): Promise => { + const { messageId, payload } = actionEvent; + const { requestType, params } = payload; + console.log('[MCP-UI] Data request received:', { messageId, requestType, params }); + return { + status: 'success' as const, + message: `Data request received: ${requestType}`, + data: { + messageId, + requestType, + params, + response: { status: 'acknowledged' }, + }, + }; + }; + + try { + switch (actionEvent.type) { + case 'tool': + result = await handleToolCase(actionEvent); + break; + + case 'prompt': + result = await handlePromptCase(actionEvent); + break; + + case 'link': + result = await handleLinkCase(actionEvent); + break; - default: { - console.warn('unsupported message sent from MCP-UI:', result); - break; + case 'notify': + result = await handleNotifyCase(actionEvent); + break; + + case 'intent': + result = await handleIntentCase(actionEvent); + break; + + case 'ui-size-change': + result = await handleSizeChangeCase(actionEvent); + break; + + case 'ui-lifecycle-iframe-ready': + result = await handleIframeReadyCase(actionEvent); + break; + + case 'ui-request-data': + result = await handleRequestDataCase(actionEvent); + break; + + default: { + const _exhaustiveCheck: never = actionEvent; + console.error('Unhandled action type:', _exhaustiveCheck); + result = { + status: 'error', + error: { + code: UIActionErrorCode.UNKNOWN_ACTION, + message: `Unknown action type`, + details: actionEvent, + }, + }; + } } + } catch (error) { + console.error('[MCP-UI] Unexpected error:', error); + result = { + status: 'error', + error: { + code: UIActionErrorCode.UNKNOWN_ACTION, + message: 'An unexpected error occurred', + details: error instanceof Error ? error.stack : error, + }, + }; } - }, []); + + if (result.status === 'error') { + console.error('[MCP-UI] Action failed:', result); + } else { + console.log('[MCP-UI] Action succeeded:', result); + } + + return result; + }; return (
@@ -64,11 +397,20 @@ export default function MCPUIResourceRenderer({ content }: MCPUIResourceRenderer
diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 0565d56b8c97..274224f79e93 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -9,6 +9,7 @@ import { NotificationEvent } from '../hooks/useMessageStream'; import { ChevronRight, FlaskConical, LoaderCircle } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; +import { isUIResource } from '@mcp-ui/client'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -16,6 +17,7 @@ interface ToolCallWithResponseProps { toolResponse?: ToolResponseMessageContent; notifications?: NotificationEvent[]; isStreamingMessage?: boolean; + append?: (value: string) => void; // Function to append messages to the chat } export default function ToolCallWithResponse({ @@ -24,6 +26,7 @@ export default function ToolCallWithResponse({ toolResponse, notifications, isStreamingMessage = false, + append, }: ToolCallWithResponseProps) { const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; if (!toolCall) { @@ -50,10 +53,10 @@ export default function ToolCallWithResponse({ {/* MCP UI — Inline */} {toolResponse?.toolResult?.value && toolResponse.toolResult.value.map((content, index) => { - if (content.type === 'resource' && content.resource.uri?.startsWith('ui://')) { + if (isUIResource(content)) { return (
- +
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f154ef0d511e..9d7e5b9aabc8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1109,6 +1109,17 @@ ipcMain.on('react-ready', () => { console.log('[main] React ready - window is prepared for deep links'); }); +// Handle external URL opening +ipcMain.handle('open-external', async (_event, url: string) => { + try { + await shell.openExternal(url); + return true; + } catch (error) { + console.error('Error opening external URL:', error); + throw error; + } +}); + // Handle directory chooser ipcMain.handle('directory-chooser', (_event, replace: boolean = false) => { return openDirectoryDialog(replace); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 8de1613fce1d..abe97dced174 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -98,6 +98,8 @@ type ElectronAPI = { // Functions for image pasting saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise; deleteTempFile: (filePath: string) => void; + // Function for opening external URLs securely + openExternal: (url: string) => Promise; // Function to serve temp images getTempImage: (filePath: string) => Promise; // Update-related functions @@ -212,6 +214,9 @@ const electronAPI: ElectronAPI = { deleteTempFile: (filePath: string): void => { ipcRenderer.send('delete-temp-file', filePath); }, + openExternal: (url: string): Promise => { + return ipcRenderer.invoke('open-external', url); + }, getTempImage: (filePath: string): Promise => { return ipcRenderer.invoke('get-temp-image', filePath); }, diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index 59e215b6b3d8..66464c10f97b 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -12,5 +12,5 @@ export default defineConfig({ build: { target: 'esnext' - } + }, });