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'
- }
+ },
});