From af87ca23cb5c59b4df61ae22e6e40a4c9da21361 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Mon, 18 Aug 2025 13:16:42 -0400 Subject: [PATCH 01/17] chore(deps): update @mcp-ui/client to version 5.7.0 in package.json and package-lock.json --- ui/desktop/package-lock.json | 8 ++++---- ui/desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 1e58ba23cd6f..454850f6936f 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -12,7 +12,7 @@ "@ai-sdk/openai": "^0.0.72", "@ai-sdk/ui-utils": "^1.0.2", "@hey-api/client-fetch": "^0.8.1", - "@mcp-ui/client": "~5.6.2", + "@mcp-ui/client": "^5.7.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.7", @@ -2695,9 +2695,9 @@ } }, "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.7.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.7.0.tgz", + "integrity": "sha512-+HbPw3VS46WUSWmyJ34ZVnygb81QByA3luR6y0JDbyDZxjYtHw1FcIN7v9WbbE8PrfI0WcuWCSiNOO6sOGbwpQ==", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "*", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 8223e3143c9b..e554dd5a7b64 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -42,7 +42,7 @@ "@ai-sdk/openai": "^0.0.72", "@ai-sdk/ui-utils": "^1.0.2", "@hey-api/client-fetch": "^0.8.1", - "@mcp-ui/client": "~5.6.2", + "@mcp-ui/client": "^5.7.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.7", From 642c7ab7b1d3ad793d9681abc7070c3981442a27 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Mon, 18 Aug 2025 13:17:44 -0400 Subject: [PATCH 02/17] fix: clean up mcp-ui implementation and properly return payload result in response --- .../src/components/MCPUIResourceRenderer.tsx | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 8800c93e63bf..4e61e6704570 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -8,54 +8,45 @@ interface MCPUIResourceRendererProps { } 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` }; + const handleUnsupportedMessage = (type: string) => { + console.warn(`MCP-UI "${type}" message type not supported`); + toast.info(`MCP-UI "${type}" message posted, refer to console for more info`); }; const handleUIAction = useCallback(async (result: UIActionResult) => { switch (result.type) { - case 'intent': { - // TODO: Implement intent handling - handleAction(result); + case 'tool': + handleUnsupportedMessage('tool'); break; - } - - case 'link': { - // TODO: Implement link handling - handleAction(result); + case 'intent': + handleUnsupportedMessage('intent'); break; - } - - case 'notify': { - // TODO: Implement notify handling - handleAction(result); + case 'prompt': + handleUnsupportedMessage('prompt'); break; - } - - case 'prompt': { - // TODO: Implement prompt handling - handleAction(result); + case 'link': + handleUnsupportedMessage('link'); break; - } - - case 'tool': { - // TODO: Implement tool call handling - handleAction(result); + case 'notify': + handleUnsupportedMessage('notify'); break; - } - - default: { - console.warn('unsupported message sent from MCP-UI:', result); + default: + console.log(`MCP-UI message received:`, result); break; - } } + + // SUPER IMPORTANT: MCP-UIs depend on receiving a response to their message + const response = { + type: 'ui-message-response', + payload: result, + }; + + console.info( + `Goose posted the following response message back to the MCP-UI request:`, + response + ); + + return response; }, []); return ( @@ -69,6 +60,7 @@ export default function MCPUIResourceRenderer({ content }: MCPUIResourceRenderer height: true, width: false, // set to false to allow for responsive design }, + // sandboxPermissions: 'allow-forms', // THIS PROP IS UNDERCONSIDERATION }} /> From 5e91a4cad5f934a13583bde9017b6b1ab3a03e4d Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Mon, 18 Aug 2025 13:19:16 -0400 Subject: [PATCH 03/17] fix: enable sandbox permissions for MCP UI resource renderer --- ui/desktop/src/components/MCPUIResourceRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 4e61e6704570..8524ee3129a9 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -60,7 +60,7 @@ export default function MCPUIResourceRenderer({ content }: MCPUIResourceRenderer height: true, width: false, // set to false to allow for responsive design }, - // sandboxPermissions: 'allow-forms', // THIS PROP IS UNDERCONSIDERATION + sandboxPermissions: 'allow-forms', }} /> From c9993be8d820671b7720961ecfca5b9bc06023d0 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 21:28:52 -0400 Subject: [PATCH 04/17] chore(deps): update @mcp-ui/client to version 5.8.0 in package.json and package-lock.json --- ui/desktop/package-lock.json | 8 ++++---- ui/desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 454850f6936f..7f28941170d9 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -12,7 +12,7 @@ "@ai-sdk/openai": "^0.0.72", "@ai-sdk/ui-utils": "^1.0.2", "@hey-api/client-fetch": "^0.8.1", - "@mcp-ui/client": "^5.7.0", + "@mcp-ui/client": "^5.8.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.7", @@ -2695,9 +2695,9 @@ } }, "node_modules/@mcp-ui/client": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.7.0.tgz", - "integrity": "sha512-+HbPw3VS46WUSWmyJ34ZVnygb81QByA3luR6y0JDbyDZxjYtHw1FcIN7v9WbbE8PrfI0WcuWCSiNOO6sOGbwpQ==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.8.0.tgz", + "integrity": "sha512-RrtGEWmxliEg22siEuXyJ1L5POWNtyOI1xsLeXBfs9uvVt+5woGWFEORqhsiRKq3OLNmYENj234iQl+nmz22yw==", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "*", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index e554dd5a7b64..7434a1a12d5f 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -42,7 +42,7 @@ "@ai-sdk/openai": "^0.0.72", "@ai-sdk/ui-utils": "^1.0.2", "@hey-api/client-fetch": "^0.8.1", - "@mcp-ui/client": "^5.7.0", + "@mcp-ui/client": "^5.8.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.7", From 47ff5a800ebcd31776e5cf8b58b8f5e09a44528b Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 21:29:36 -0400 Subject: [PATCH 05/17] fix: refactor resource type check in ToolCallWithResponse component to use isUIResource utility --- ui/desktop/src/components/ToolCallWithResponse.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index e72fb19b2589..897b6e66007c 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; @@ -50,7 +51,7 @@ 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 (
From d5a57bbeb1545c0267e510dfb5a4339879745007 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 22:46:03 -0400 Subject: [PATCH 06/17] chore: (temp) add path resolution and deduplication for React in Vite configuration --- ui/desktop/vite.renderer.config.mts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index 59e215b6b3d8..e3888c4054e9 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; // https://vitejs.dev/config export default defineConfig({ @@ -10,7 +11,17 @@ export default defineConfig({ plugins: [tailwindcss()], + resolve: { + alias: { + // Force @mcp-ui/client to use the same React version as the main app + react: resolve(__dirname, 'node_modules/react'), + 'react-dom': resolve(__dirname, 'node_modules/react-dom'), + }, + // Deduplicate React packages + dedupe: ['react', 'react-dom'], + }, + build: { - target: 'esnext' - } + target: 'esnext', + }, }); From b1f8120679fa75f2d45c413ca26bb9be38104bd3 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 22:57:00 -0400 Subject: [PATCH 07/17] feat: enhance MCPUIResourceRenderer with typed action handlers and error management - Introduced specific result types for tool calls, prompts, links, notifications, and intents. - Added error handling with strongly typed error codes for better clarity and debugging. - Implemented separate handlers for each action type to improve type safety and maintainability. - Updated the main action handler to support new action types and provide exhaustive type checking. --- .../src/components/MCPUIResourceRenderer.tsx | 403 ++++++++++++++++-- 1 file changed, 364 insertions(+), 39 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 8524ee3129a9..25e755b68804 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -3,51 +3,376 @@ import { ResourceContent } from '../types/message'; import { useCallback } from 'react'; import { toast } from 'react-toastify'; +// TODOS +// support ui-lifecycle-iframe-ready +// support size-change + interface MCPUIResourceRendererProps { content: ResourceContent; + // Optional callbacks for when we actually implement these actions + onToolCall?: (toolName: string, params: Record) => Promise; + onPrompt?: (prompt: string) => Promise; + onNavigate?: (url: string) => void; + onIntent?: (intent: string, params: Record) => Promise; } -export default function MCPUIResourceRenderer({ content }: MCPUIResourceRendererProps) { - const handleUnsupportedMessage = (type: string) => { - console.warn(`MCP-UI "${type}" message type not supported`); - toast.info(`MCP-UI "${type}" message posted, refer to console for more info`); +// More specific result types using discriminated unions +type UIActionHandlerSuccess = { + status: 'success'; + data?: T; + message?: string; +}; + +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', +} + +// Specific result types for each action +type ToolCallResult = { + toolName: string; + executionTime: number; + output: unknown; +}; + +type NotificationResult = { + notificationId?: string; + displayedAt: string; + message: string; +}; + +export default function MCPUIResourceRenderer({ + content, + onToolCall, + onPrompt, + onNavigate, + onIntent, +}: MCPUIResourceRendererProps) { + // Separate handlers for each action type for better type safety + const handleToolAction = useCallback( + async ( + toolName: string, + params: Record + ): Promise> => { + if (!onToolCall) { + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Tool calls are not yet implemented', + details: { toolName, params }, + }, + }; + } + + const startTime = Date.now(); + try { + const output = await onToolCall(toolName, params); + return { + status: 'success', + data: { + toolName, + executionTime: Date.now() - startTime, + output, + }, + message: `Tool "${toolName}" executed successfully`, + }; + } catch (error) { + return { + status: 'error', + error: { + code: UIActionErrorCode.TOOL_EXECUTION_FAILED, + message: `Failed to execute tool "${toolName}"`, + details: error instanceof Error ? error.message : error, + }, + }; + } + }, + [onToolCall] + ); + + const handlePromptAction = useCallback( + async (prompt: string): Promise> => { + if (!onPrompt) { + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Prompt handling is not yet implemented', + details: { prompt }, + }, + }; + } + + try { + await onPrompt(prompt); + return { + status: 'success', + message: 'Prompt sent successfully', + }; + } catch (error) { + return { + status: 'error', + error: { + code: UIActionErrorCode.PROMPT_FAILED, + message: 'Failed to send prompt', + details: error instanceof Error ? error.message : error, + }, + }; + } + }, + [onPrompt] + ); + + const handleLinkAction = useCallback( + async (url: string): Promise> => { + if (!onNavigate) { + // For links, we provide a default implementation using Electron shell + try { + // Validate URL before opening + const urlObj = new URL(url); - const handleUIAction = useCallback(async (result: UIActionResult) => { - switch (result.type) { - case 'tool': - handleUnsupportedMessage('tool'); - break; - case 'intent': - handleUnsupportedMessage('intent'); - break; - case 'prompt': - handleUnsupportedMessage('prompt'); - break; - case 'link': - handleUnsupportedMessage('link'); - break; - case 'notify': - handleUnsupportedMessage('notify'); - break; - default: - console.log(`MCP-UI message received:`, result); - break; - } - - // SUPER IMPORTANT: MCP-UIs depend on receiving a response to their message - const response = { - type: 'ui-message-response', - payload: result, - }; - - console.info( - `Goose posted the following response message back to the MCP-UI request:`, - response - ); - - return response; - }, []); + // Only allow http/https protocols for security + if (!['http:', 'https:'].includes(urlObj.protocol)) { + return { + status: 'error', + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, + details: { url, protocol: urlObj.protocol }, + }, + }; + } + + // Use the exposed electron API for secure external URL opening + // This calls the main process via IPC, which then uses shell.openExternal() + await window.electron.openExternal(url); + + return { + status: 'success', + message: `Opened ${url} in default browser`, + }; + } catch (error) { + // Handle different types of errors + if (error instanceof TypeError && error.message.includes('Invalid URL')) { + return { + status: 'error', + error: { + code: UIActionErrorCode.INVALID_PARAMS, + message: `Invalid URL format: ${url}`, + details: { url, error: error.message }, + }, + }; + } + + if (error instanceof Error && error.message.includes('Failed to open')) { + return { + status: 'error', + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Failed to open URL in default browser`, + details: { url, error: error.message }, + }, + }; + } + + return { + status: 'error', + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Unexpected error opening URL: ${url}`, + details: error instanceof Error ? error.message : error, + }, + }; + } + } + + try { + onNavigate(url); + return { + status: 'success', + message: `Navigated to ${url}`, + }; + } catch (error) { + return { + status: 'error', + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Failed to navigate to ${url}`, + details: error instanceof Error ? error.message : error, + }, + }; + } + }, + [onNavigate] + ); + + const handleNotifyAction = useCallback( + (message: string): UIActionHandlerResult => { + try { + const notificationId = `notify-${Date.now()}`; + toast.info(message); + + return { + status: 'success', + data: { + notificationId, + displayedAt: new Date().toISOString(), + message, + }, + }; + } catch (error) { + return { + status: 'error', + error: { + code: UIActionErrorCode.UNKNOWN_ACTION, + message: 'Failed to display notification', + details: error instanceof Error ? error.message : error, + }, + }; + } + }, + [] + ); + + const handleIntentAction = useCallback( + async ( + intent: string, + params: Record + ): Promise> => { + if (!onIntent) { + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Intent handling is not yet implemented', + details: { intent, params }, + }, + }; + } + + try { + await onIntent(intent, params); + return { + status: 'success', + message: `Intent "${intent}" processed successfully`, + }; + } catch (error) { + return { + status: 'error', + error: { + code: UIActionErrorCode.INTENT_FAILED, + message: `Failed to process intent "${intent}"`, + details: error instanceof Error ? error.message : error, + }, + }; + } + }, + [onIntent] + ); + + // Main handler with exhaustive type checking + const handleUIAction = useCallback( + async (actionEvent: UIActionResult): Promise => { + console.log('[MCP-UI] Action received:', actionEvent); + + let result: UIActionHandlerResult; + + try { + switch (actionEvent.type) { + case 'tool': + result = await handleToolAction( + actionEvent.payload.toolName, + actionEvent.payload.params + ); + break; + + case 'prompt': + result = await handlePromptAction(actionEvent.payload.prompt); + break; + + case 'link': + result = await handleLinkAction(actionEvent.payload.url); + break; + + case 'notify': + result = handleNotifyAction(actionEvent.payload.message); + break; + + case 'intent': + result = await handleIntentAction( + actionEvent.payload.intent, + actionEvent.payload.params + ); + break; + + default: { + // TypeScript exhaustiveness check + 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, + }, + }; + } + + // Log result with appropriate level + if (result.status === 'error') { + console.error('[MCP-UI] Action failed:', result); + } else if (result.status === 'pending') { + console.info('[MCP-UI] Action pending:', result); + } else { + console.log('[MCP-UI] Action succeeded:', result); + } + + return result; + }, + [handleToolAction, handlePromptAction, handleLinkAction, handleNotifyAction, handleIntentAction] + ); return (
From d588b652cc06261bfdc7adfb3d909210330dcbab Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 22:57:19 -0400 Subject: [PATCH 08/17] feat: add external URL handling in Electron main and preload processes - Implemented a new IPC handler in the main process to open external URLs securely. - Updated the preload script to expose the openExternal function for invoking the new handler from the renderer process. --- ui/desktop/src/main.ts | 11 +++++++++++ ui/desktop/src/preload.ts | 5 +++++ 2 files changed, 16 insertions(+) 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); }, From d066e121d14060be7e59f2ef5952e1d713132819 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 23:43:00 -0400 Subject: [PATCH 09/17] feat: implement global scroll-to-bottom functionality and enhance message handling - Added a global event listener for scroll-to-bottom requests in BaseChat to improve user experience. - Enhanced MCPUIResourceRenderer and ToolCallWithResponse components to support an append function for message handling. - Updated prompt action handling to utilize the append function, providing fallback options for message delivery. --- ui/desktop/src/components/BaseChat.tsx | 15 ++++ ui/desktop/src/components/GooseMessage.tsx | 1 + .../src/components/MCPUIResourceRenderer.tsx | 74 +++++++++++++------ .../src/components/ToolCallWithResponse.tsx | 4 +- 4 files changed, 70 insertions(+), 24 deletions(-) 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 25e755b68804..d195ffa2d679 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -14,6 +14,7 @@ interface MCPUIResourceRendererProps { onPrompt?: (prompt: string) => Promise; onNavigate?: (url: string) => void; onIntent?: (intent: string, params: Record) => Promise; + append?: (value: string) => void; // Function to append messages to the chat } // More specific result types using discriminated unions @@ -75,6 +76,7 @@ export default function MCPUIResourceRenderer({ onPrompt, onNavigate, onIntent, + append, }: MCPUIResourceRendererProps) { // Separate handlers for each action type for better type safety const handleToolAction = useCallback( @@ -121,35 +123,61 @@ export default function MCPUIResourceRenderer({ const handlePromptAction = useCallback( async (prompt: string): Promise> => { - if (!onPrompt) { + const handlePromptSuccess = (message: string) => { + window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); return { - status: 'error', - error: { - code: UIActionErrorCode.UNSUPPORTED_ACTION, - message: 'Prompt handling is not yet implemented', - details: { prompt }, - }, + status: 'success' as const, + message, }; + }; + + const handlePromptError = (message: string, details: unknown) => ({ + status: 'error' as const, + error: { + code: UIActionErrorCode.PROMPT_FAILED, + message, + details, + }, + }); + + // If onPrompt is provided, use it + if (onPrompt) { + try { + await onPrompt(prompt); + return handlePromptSuccess('Prompt sent successfully'); + } catch (error) { + return handlePromptError( + 'Failed to send prompt', + error instanceof Error ? error.message : error + ); + } } - try { - await onPrompt(prompt); - return { - status: 'success', - message: 'Prompt sent successfully', - }; - } catch (error) { - return { - status: 'error', - error: { - code: UIActionErrorCode.PROMPT_FAILED, - message: 'Failed to send prompt', - details: error instanceof Error ? error.message : error, - }, - }; + // Fallback to append if available + if (append) { + try { + append(prompt); + return handlePromptSuccess('Prompt sent to chat successfully'); + } catch (error) { + return handlePromptError( + 'Failed to send prompt to chat', + error instanceof Error ? error.message : error + ); + } } + + // No prompt handler available + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: + 'Prompt handling is not implemented - either onPrompt or append prop is required', + details: { prompt }, + }, + }; }, - [onPrompt] + [onPrompt, append] ); const handleLinkAction = useCallback( diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 55670600f44d..4b7c8a71be20 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -17,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({ @@ -25,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) { @@ -54,7 +56,7 @@ export default function ToolCallWithResponse({ if (isUIResource(content)) { return (
- +
From 8ba07ad57f7488f0232c370458537280336089d6 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 23:49:40 -0400 Subject: [PATCH 10/17] refactor: simplify MCPUIResourceRenderer by removing unused callbacks and enhancing error handling - Removed optional callbacks for tool calls, prompts, navigation, and intents to streamline the component. - Improved error handling for unsupported actions in tool calls, prompts, and intents. - Ensured that the append function is utilized for prompt actions and added custom event dispatching for chat scrolling. --- .../src/components/MCPUIResourceRenderer.tsx | 246 ++++++------------ 1 file changed, 76 insertions(+), 170 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index d195ffa2d679..3d93ddcbdc14 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -9,11 +9,6 @@ import { toast } from 'react-toastify'; interface MCPUIResourceRendererProps { content: ResourceContent; - // Optional callbacks for when we actually implement these actions - onToolCall?: (toolName: string, params: Record) => Promise; - onPrompt?: (prompt: string) => Promise; - onNavigate?: (url: string) => void; - onIntent?: (intent: string, params: Record) => Promise; append?: (value: string) => void; // Function to append messages to the chat } @@ -70,99 +65,49 @@ type NotificationResult = { message: string; }; -export default function MCPUIResourceRenderer({ - content, - onToolCall, - onPrompt, - onNavigate, - onIntent, - append, -}: MCPUIResourceRendererProps) { +export default function MCPUIResourceRenderer({ content, append }: MCPUIResourceRendererProps) { // Separate handlers for each action type for better type safety const handleToolAction = useCallback( async ( toolName: string, params: Record ): Promise> => { - if (!onToolCall) { - return { - status: 'error', - error: { - code: UIActionErrorCode.UNSUPPORTED_ACTION, - message: 'Tool calls are not yet implemented', - details: { toolName, params }, - }, - }; - } - - const startTime = Date.now(); - try { - const output = await onToolCall(toolName, params); - return { - status: 'success', - data: { - toolName, - executionTime: Date.now() - startTime, - output, - }, - message: `Tool "${toolName}" executed successfully`, - }; - } catch (error) { - return { - status: 'error', - error: { - code: UIActionErrorCode.TOOL_EXECUTION_FAILED, - message: `Failed to execute tool "${toolName}"`, - details: error instanceof Error ? error.message : error, - }, - }; - } + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Tool calls are not yet implemented', + details: { toolName, params }, + }, + }; }, - [onToolCall] + [] ); const handlePromptAction = useCallback( async (prompt: string): Promise> => { - const handlePromptSuccess = (message: string) => { - window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); - return { - status: 'success' as const, - message, - }; - }; - - const handlePromptError = (message: string, details: unknown) => ({ - status: 'error' as const, - error: { - code: UIActionErrorCode.PROMPT_FAILED, - message, - details, - }, - }); - - // If onPrompt is provided, use it - if (onPrompt) { - try { - await onPrompt(prompt); - return handlePromptSuccess('Prompt sent successfully'); - } catch (error) { - return handlePromptError( - 'Failed to send prompt', - error instanceof Error ? error.message : error - ); - } - } - - // Fallback to append if available + // Use append if available if (append) { try { append(prompt); - return handlePromptSuccess('Prompt sent to chat successfully'); + + // Dispatch a custom event to trigger scroll to bottom + // This ensures the chat scrolls down to show the new prompt + window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); + + return { + status: 'success', + message: 'Prompt sent to chat successfully', + }; } catch (error) { - return handlePromptError( - 'Failed to send prompt to chat', - error instanceof Error ? error.message : error - ); + return { + status: 'error', + error: { + code: UIActionErrorCode.PROMPT_FAILED, + message: 'Failed to send prompt to chat', + details: error instanceof Error ? error.message : error, + }, + }; } } @@ -171,96 +116,76 @@ export default function MCPUIResourceRenderer({ status: 'error', error: { code: UIActionErrorCode.UNSUPPORTED_ACTION, - message: - 'Prompt handling is not implemented - either onPrompt or append prop is required', + message: 'Prompt handling is not implemented - append prop is required', details: { prompt }, }, }; }, - [onPrompt, append] + [append] ); const handleLinkAction = useCallback( async (url: string): Promise> => { - if (!onNavigate) { - // For links, we provide a default implementation using Electron shell - try { - // Validate URL before opening - const urlObj = new URL(url); + // Always use default implementation using Electron shell + try { + // Validate URL before opening + const urlObj = new URL(url); - // Only allow http/https protocols for security - if (!['http:', 'https:'].includes(urlObj.protocol)) { - return { - status: 'error', - error: { - code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, - details: { url, protocol: urlObj.protocol }, - }, - }; - } + // Only allow http/https protocols for security + if (!['http:', 'https:'].includes(urlObj.protocol)) { + return { + status: 'error', + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, + details: { url, protocol: urlObj.protocol }, + }, + }; + } - // Use the exposed electron API for secure external URL opening - // This calls the main process via IPC, which then uses shell.openExternal() - await window.electron.openExternal(url); + // Use the exposed electron API for secure external URL opening + // This calls the main process via IPC, which then uses shell.openExternal() + await window.electron.openExternal(url); + return { + status: 'success', + message: `Opened ${url} in default browser`, + }; + } catch (error) { + // Handle different types of errors + if (error instanceof TypeError && error.message.includes('Invalid URL')) { return { - status: 'success', - message: `Opened ${url} in default browser`, + status: 'error', + error: { + code: UIActionErrorCode.INVALID_PARAMS, + message: `Invalid URL format: ${url}`, + details: { url, error: error.message }, + }, }; - } catch (error) { - // Handle different types of errors - if (error instanceof TypeError && error.message.includes('Invalid URL')) { - return { - status: 'error', - error: { - code: UIActionErrorCode.INVALID_PARAMS, - message: `Invalid URL format: ${url}`, - details: { url, error: error.message }, - }, - }; - } - - if (error instanceof Error && error.message.includes('Failed to open')) { - return { - status: 'error', - error: { - code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Failed to open URL in default browser`, - details: { url, error: error.message }, - }, - }; - } + } + if (error instanceof Error && error.message.includes('Failed to open')) { return { status: 'error', error: { code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Unexpected error opening URL: ${url}`, - details: error instanceof Error ? error.message : error, + message: `Failed to open URL in default browser`, + details: { url, error: error.message }, }, }; } - } - try { - onNavigate(url); - return { - status: 'success', - message: `Navigated to ${url}`, - }; - } catch (error) { return { status: 'error', error: { code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Failed to navigate to ${url}`, + message: `Unexpected error opening URL: ${url}`, details: error instanceof Error ? error.message : error, }, }; } }, - [onNavigate] + [] ); const handleNotifyAction = useCallback( @@ -296,35 +221,16 @@ export default function MCPUIResourceRenderer({ intent: string, params: Record ): Promise> => { - if (!onIntent) { - return { - status: 'error', - error: { - code: UIActionErrorCode.UNSUPPORTED_ACTION, - message: 'Intent handling is not yet implemented', - details: { intent, params }, - }, - }; - } - - try { - await onIntent(intent, params); - return { - status: 'success', - message: `Intent "${intent}" processed successfully`, - }; - } catch (error) { - return { - status: 'error', - error: { - code: UIActionErrorCode.INTENT_FAILED, - message: `Failed to process intent "${intent}"`, - details: error instanceof Error ? error.message : error, - }, - }; - } + return { + status: 'error', + error: { + code: UIActionErrorCode.UNSUPPORTED_ACTION, + message: 'Intent handling is not yet implemented', + details: { intent, params }, + }, + }; }, - [onIntent] + [] ); // Main handler with exhaustive type checking From 930ccc16b59df04444f84dc6eea6e328b690a780 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 19 Aug 2025 23:53:06 -0400 Subject: [PATCH 11/17] refactor: update MCPUIResourceRenderer to use appendPromptToChat for message handling - Renamed the append prop to appendPromptToChat for clarity in MCPUIResourceRenderer. - Updated ToolCallWithResponse to pass the new appendPromptToChat prop to MCPUIResourceRenderer. - Revised TODO comments for better clarity on future enhancements regarding message handling. --- .../src/components/MCPUIResourceRenderer.tsx | 17 ++++++++++------- .../src/components/ToolCallWithResponse.tsx | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 3d93ddcbdc14..e0cb33973e5c 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -4,12 +4,12 @@ import { useCallback } from 'react'; import { toast } from 'react-toastify'; // TODOS -// support ui-lifecycle-iframe-ready -// support size-change +// figure out how best to handle the ui-lifecycle-iframe-ready message +// figure out how best to support size-change messages interface MCPUIResourceRendererProps { content: ResourceContent; - append?: (value: string) => void; // Function to append messages to the chat + appendPromptToChat?: (value: string) => void; } // More specific result types using discriminated unions @@ -65,7 +65,10 @@ type NotificationResult = { message: string; }; -export default function MCPUIResourceRenderer({ content, append }: MCPUIResourceRendererProps) { +export default function MCPUIResourceRenderer({ + content, + appendPromptToChat, +}: MCPUIResourceRendererProps) { // Separate handlers for each action type for better type safety const handleToolAction = useCallback( async ( @@ -87,9 +90,9 @@ export default function MCPUIResourceRenderer({ content, append }: MCPUIResource const handlePromptAction = useCallback( async (prompt: string): Promise> => { // Use append if available - if (append) { + if (appendPromptToChat) { try { - append(prompt); + appendPromptToChat(prompt); // Dispatch a custom event to trigger scroll to bottom // This ensures the chat scrolls down to show the new prompt @@ -121,7 +124,7 @@ export default function MCPUIResourceRenderer({ content, append }: MCPUIResource }, }; }, - [append] + [appendPromptToChat] ); const handleLinkAction = useCallback( diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 4b7c8a71be20..274224f79e93 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -56,7 +56,7 @@ export default function ToolCallWithResponse({ if (isUIResource(content)) { return (
- +
From d029e73312d432d08b0137f55927cb7b8eb2a902 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Wed, 20 Aug 2025 00:10:51 -0400 Subject: [PATCH 12/17] refactor: enhance MCPUIResourceRenderer with comprehensive action handling - Expanded action handling in MCPUIResourceRenderer to include specific cases for tools, prompts, links, notifications, and intents. - Improved error handling with consistent status codes and messages for unsupported actions. - Streamlined the main action handler for better readability and maintainability, ensuring exhaustive type checking. - Removed unused callback functions to simplify the component structure. --- .../src/components/MCPUIResourceRenderer.tsx | 264 +++++++----------- 1 file changed, 108 insertions(+), 156 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index e0cb33973e5c..f0604fdcc769 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -1,6 +1,13 @@ -import { UIResourceRenderer, UIActionResult } from '@mcp-ui/client'; +import { + UIResourceRenderer, + UIActionResult, + UIActionResultIntent, + UIActionResultLink, + UIActionResultNotification, + UIActionResultPrompt, + UIActionResultToolCall, +} from '@mcp-ui/client'; import { ResourceContent } from '../types/message'; -import { useCallback } from 'react'; import { toast } from 'react-toastify'; // TODOS @@ -52,59 +59,41 @@ enum UIActionErrorCode { TIMEOUT = 'TIMEOUT', } -// Specific result types for each action -type ToolCallResult = { - toolName: string; - executionTime: number; - output: unknown; -}; - -type NotificationResult = { - notificationId?: string; - displayedAt: string; - message: string; -}; - export default function MCPUIResourceRenderer({ content, appendPromptToChat, }: MCPUIResourceRendererProps) { - // Separate handlers for each action type for better type safety - const handleToolAction = useCallback( - async ( - toolName: string, - params: Record - ): Promise> => { + const handleUIAction = async (actionEvent: UIActionResult): Promise => { + console.log('[MCP-UI] Action received:', actionEvent); + + let result: UIActionHandlerResult; + + const handleToolCase = (actionEvent: UIActionResultToolCall) => { + const { toolName, params } = actionEvent.payload; return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.UNSUPPORTED_ACTION, message: 'Tool calls are not yet implemented', details: { toolName, params }, }, }; - }, - [] - ); + }; + + const handlePromptCase = (actionEvent: UIActionResultPrompt) => { + const { prompt } = actionEvent.payload; - const handlePromptAction = useCallback( - async (prompt: string): Promise> => { - // Use append if available if (appendPromptToChat) { try { appendPromptToChat(prompt); - - // Dispatch a custom event to trigger scroll to bottom - // This ensures the chat scrolls down to show the new prompt window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); - return { - status: 'success', + status: 'success' as const, message: 'Prompt sent to chat successfully', }; } catch (error) { return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.PROMPT_FAILED, message: 'Failed to send prompt to chat', @@ -114,30 +103,24 @@ export default function MCPUIResourceRenderer({ } } - // No prompt handler available return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.UNSUPPORTED_ACTION, message: 'Prompt handling is not implemented - append prop is required', details: { prompt }, }, }; - }, - [appendPromptToChat] - ); + }; + + const handleLinkCase = async (actionEvent: UIActionResultLink) => { + const { url } = actionEvent.payload; - const handleLinkAction = useCallback( - async (url: string): Promise> => { - // Always use default implementation using Electron shell try { - // Validate URL before opening const urlObj = new URL(url); - - // Only allow http/https protocols for security if (!['http:', 'https:'].includes(urlObj.protocol)) { return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.NAVIGATION_FAILED, message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, @@ -146,59 +129,51 @@ export default function MCPUIResourceRenderer({ }; } - // Use the exposed electron API for secure external URL opening - // This calls the main process via IPC, which then uses shell.openExternal() await window.electron.openExternal(url); - return { - status: 'success', + status: 'success' as const, message: `Opened ${url} in default browser`, }; } catch (error) { - // Handle different types of errors if (error instanceof TypeError && error.message.includes('Invalid URL')) { return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.INVALID_PARAMS, message: `Invalid URL format: ${url}`, details: { url, error: error.message }, }, }; - } - - if (error instanceof Error && error.message.includes('Failed to open')) { + } else if (error instanceof Error && error.message.includes('Failed to open')) { return { - status: 'error', + 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, + }, + }; } - - return { - status: 'error', - error: { - code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Unexpected error opening URL: ${url}`, - details: error instanceof Error ? error.message : error, - }, - }; } - }, - [] - ); + }; + + const handleNotifyCase = (actionEvent: UIActionResultNotification) => { + const { message } = actionEvent.payload; - const handleNotifyAction = useCallback( - (message: string): UIActionHandlerResult => { try { const notificationId = `notify-${Date.now()}`; toast.info(message); - return { - status: 'success', + status: 'success' as const, data: { notificationId, displayedAt: new Date().toISOString(), @@ -207,7 +182,7 @@ export default function MCPUIResourceRenderer({ }; } catch (error) { return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.UNKNOWN_ACTION, message: 'Failed to display notification', @@ -215,101 +190,78 @@ export default function MCPUIResourceRenderer({ }, }; } - }, - [] - ); + }; + + const handleIntentCase = (actionEvent: UIActionResultIntent) => { + const { intent, params } = actionEvent.payload; - const handleIntentAction = useCallback( - async ( - intent: string, - params: Record - ): Promise> => { return { - status: 'error', + status: 'error' as const, error: { code: UIActionErrorCode.UNSUPPORTED_ACTION, message: 'Intent handling is not yet implemented', details: { intent, params }, }, }; - }, - [] - ); - - // Main handler with exhaustive type checking - const handleUIAction = useCallback( - async (actionEvent: UIActionResult): Promise => { - console.log('[MCP-UI] Action received:', actionEvent); - - let result: UIActionHandlerResult; - - try { - switch (actionEvent.type) { - case 'tool': - result = await handleToolAction( - actionEvent.payload.toolName, - actionEvent.payload.params - ); - break; - - case 'prompt': - result = await handlePromptAction(actionEvent.payload.prompt); - break; - - case 'link': - result = await handleLinkAction(actionEvent.payload.url); - break; - - case 'notify': - result = handleNotifyAction(actionEvent.payload.message); - break; - - case 'intent': - result = await handleIntentAction( - actionEvent.payload.intent, - actionEvent.payload.params - ); - break; - - default: { - // TypeScript exhaustiveness check - const _exhaustiveCheck: never = actionEvent; - console.error('Unhandled action type:', _exhaustiveCheck); - result = { - status: 'error', - error: { - code: UIActionErrorCode.UNKNOWN_ACTION, - message: `Unknown action type`, - details: actionEvent, - }, - }; - } + }; + + try { + switch (actionEvent.type) { + case 'tool': + result = handleToolCase(actionEvent); + break; + + case 'prompt': + result = await handlePromptCase(actionEvent); + break; + + case 'link': + result = await handleLinkCase(actionEvent); + break; + + case 'notify': + result = handleNotifyCase(actionEvent); + break; + + case 'intent': + result = handleIntentCase(actionEvent); + break; + + default: { + // TypeScript exhaustiveness check + 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, - }, - }; } + } 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, + }, + }; + } - // Log result with appropriate level - if (result.status === 'error') { - console.error('[MCP-UI] Action failed:', result); - } else if (result.status === 'pending') { - console.info('[MCP-UI] Action pending:', result); - } else { - console.log('[MCP-UI] Action succeeded:', result); - } + // Log result with appropriate level + if (result.status === 'error') { + console.error('[MCP-UI] Action failed:', result); + } else { + console.log('[MCP-UI] Action succeeded:', result); + } - return result; - }, - [handleToolAction, handlePromptAction, handleLinkAction, handleNotifyAction, handleIntentAction] - ); + return result; + }; return (
From 89d4edf8b9bd17c80e93d4b56f3ba8d224912f13 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Wed, 20 Aug 2025 00:16:30 -0400 Subject: [PATCH 13/17] refactor: update MCPUIResourceRenderer action handlers to be asynchronous - Converted action handler functions in MCPUIResourceRenderer to asynchronous to support promise-based operations. - Ensured that the main action handler awaits results from tool, prompt, notify, and intent cases for improved error handling and response management. --- .../src/components/MCPUIResourceRenderer.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index f0604fdcc769..fe4ddf034644 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -68,7 +68,9 @@ export default function MCPUIResourceRenderer({ let result: UIActionHandlerResult; - const handleToolCase = (actionEvent: UIActionResultToolCall) => { + const handleToolCase = async ( + actionEvent: UIActionResultToolCall + ): Promise => { const { toolName, params } = actionEvent.payload; return { status: 'error' as const, @@ -80,7 +82,9 @@ export default function MCPUIResourceRenderer({ }; }; - const handlePromptCase = (actionEvent: UIActionResultPrompt) => { + const handlePromptCase = async ( + actionEvent: UIActionResultPrompt + ): Promise => { const { prompt } = actionEvent.payload; if (appendPromptToChat) { @@ -166,7 +170,9 @@ export default function MCPUIResourceRenderer({ } }; - const handleNotifyCase = (actionEvent: UIActionResultNotification) => { + const handleNotifyCase = async ( + actionEvent: UIActionResultNotification + ): Promise => { const { message } = actionEvent.payload; try { @@ -192,7 +198,9 @@ export default function MCPUIResourceRenderer({ } }; - const handleIntentCase = (actionEvent: UIActionResultIntent) => { + const handleIntentCase = async ( + actionEvent: UIActionResultIntent + ): Promise => { const { intent, params } = actionEvent.payload; return { @@ -208,7 +216,7 @@ export default function MCPUIResourceRenderer({ try { switch (actionEvent.type) { case 'tool': - result = handleToolCase(actionEvent); + result = await handleToolCase(actionEvent); break; case 'prompt': @@ -220,11 +228,11 @@ export default function MCPUIResourceRenderer({ break; case 'notify': - result = handleNotifyCase(actionEvent); + result = await handleNotifyCase(actionEvent); break; case 'intent': - result = handleIntentCase(actionEvent); + result = await handleIntentCase(actionEvent); break; default: { From 5602b6142700573e44e6f488ae32a9d3abc42f9e Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Wed, 20 Aug 2025 09:49:20 -0400 Subject: [PATCH 14/17] Remove @hey-api/client-fetch dependency This dependency was accidentally included through a merge conflict resolution and should not be there. --- ui/desktop/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/desktop/package.json b/ui/desktop/package.json index e056ffa0d854..49d03925f1cf 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -41,7 +41,6 @@ "dependencies": { "@ai-sdk/openai": "^2.0.14", "@ai-sdk/ui-utils": "^1.2.11", - "@hey-api/client-fetch": "^0.8.1", "@mcp-ui/client": "^5.8.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", From 26593cc1ce5f3bc7837ef9f5d9622678b641767d Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Wed, 20 Aug 2025 15:25:14 -0400 Subject: [PATCH 15/17] update package lock --- ui/desktop/package-lock.json | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 907b85d0b635..97344f3a28d1 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@ai-sdk/openai": "^2.0.14", "@ai-sdk/ui-utils": "^1.2.11", - "@hey-api/client-fetch": "^0.8.1", "@mcp-ui/client": "^5.8.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", @@ -2348,16 +2347,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@hey-api/client-fetch": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.4.tgz", - "integrity": "sha512-SWtUjVEFIUdiJGR2NiuF0njsSrSdTe7WHWkp3BLH3DEl2bRhiflOnBo29NSDdrY90hjtTQiTQkBxUgGOF29Xzg==", - "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, "node_modules/@hey-api/json-schema-ref-parser": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", From 76c9303b87f4bca630461c27341cc410b9519519 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Wed, 20 Aug 2025 16:33:45 -0400 Subject: [PATCH 16/17] chore: upgrade to @mcp-ui/client 5.9.0 and revert vite config change --- ui/desktop/package-lock.json | 50 ++++++----------------------- ui/desktop/package.json | 6 +++- ui/desktop/vite.renderer.config.mts | 13 +------- 3 files changed, 15 insertions(+), 54 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 97344f3a28d1..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.8.0", + "@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.8.0", - "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.8.0.tgz", - "integrity": "sha512-RrtGEWmxliEg22siEuXyJ1L5POWNtyOI1xsLeXBfs9uvVt+5woGWFEORqhsiRKq3OLNmYENj234iQl+nmz22yw==", + "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 49d03925f1cf..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.8.0", + "@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/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index e3888c4054e9..66464c10f97b 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -1,6 +1,5 @@ import { defineConfig } from 'vite'; import tailwindcss from '@tailwindcss/vite'; -import { resolve } from 'path'; // https://vitejs.dev/config export default defineConfig({ @@ -11,17 +10,7 @@ export default defineConfig({ plugins: [tailwindcss()], - resolve: { - alias: { - // Force @mcp-ui/client to use the same React version as the main app - react: resolve(__dirname, 'node_modules/react'), - 'react-dom': resolve(__dirname, 'node_modules/react-dom'), - }, - // Deduplicate React packages - dedupe: ['react', 'react-dom'], - }, - build: { - target: 'esnext', + target: 'esnext' }, }); From 09c122dd00797be6f315b6a3ac580ea1415a0909 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Thu, 21 Aug 2025 11:57:45 -0400 Subject: [PATCH 17/17] refactor: enhance MCPUIResourceRenderer with additional action types and toast notifications - Introduced new action types for size changes, iframe readiness, and data requests to improve message handling. - Implemented a ToastComponent for displaying notifications with support for implemented and unimplemented message types. - Updated the main action handler to accommodate new action cases, ensuring comprehensive handling of messages from the iframe. - Improved theme management for toast notifications to enhance user experience. --- .../src/components/MCPUIResourceRenderer.tsx | 195 +++++++++++++++--- 1 file changed, 161 insertions(+), 34 deletions(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index fe4ddf034644..3e5ad494a564 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -1,23 +1,53 @@ import { UIResourceRenderer, - UIActionResult, UIActionResultIntent, UIActionResultLink, UIActionResultNotification, UIActionResultPrompt, UIActionResultToolCall, } from '@mcp-ui/client'; +import { useState, useEffect } from 'react'; import { ResourceContent } from '../types/message'; import { toast } from 'react-toastify'; -// TODOS -// figure out how best to handle the ui-lifecycle-iframe-ready message -// figure out how best to support size-change messages - 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; +}; + +type UIRequestData = { + type: 'ui-request-data'; + messageId: string; + payload: { + requestType: string; + params: Record; + }; +}; + +// 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; // More specific result types using discriminated unions type UIActionHandlerSuccess = { @@ -59,19 +89,62 @@ enum UIActionErrorCode { TIMEOUT = 'TIMEOUT', } +// toast component +const ToastComponent = ({ + messageType, + message, + isImplemented = true, +}: { + messageType: string; + message?: string; + isImplemented?: boolean; +}) => { + const title = `MCP-UI ${messageType} message`; + + 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 handleUIAction = async (actionEvent: UIActionResult): Promise => { - console.log('[MCP-UI] Action received:', actionEvent); + 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: { @@ -175,40 +248,77 @@ export default function MCPUIResourceRenderer({ ): Promise => { const { message } = actionEvent.payload; - try { - const notificationId = `notify-${Date.now()}`; - toast.info(message); - return { - status: 'success' as const, - data: { - notificationId, - displayedAt: new Date().toISOString(), - message, - }, - }; - } catch (error) { - return { - status: 'error' as const, - error: { - code: UIActionErrorCode.UNKNOWN_ACTION, - message: 'Failed to display notification', - details: error instanceof Error ? error.message : error, - }, - }; - } + 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 => { - const { intent, params } = actionEvent.payload; - + toast.info( + , + { + theme: currentThemeValue, + } + ); return { status: 'error' as const, error: { code: UIActionErrorCode.UNSUPPORTED_ACTION, message: 'Intent handling is not yet implemented', - details: { intent, params }, + 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' }, }, }; }; @@ -235,8 +345,19 @@ export default function MCPUIResourceRenderer({ 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: { - // TypeScript exhaustiveness check const _exhaustiveCheck: never = actionEvent; console.error('Unhandled action type:', _exhaustiveCheck); result = { @@ -261,7 +382,6 @@ export default function MCPUIResourceRenderer({ }; } - // Log result with appropriate level if (result.status === 'error') { console.error('[MCP-UI] Action failed:', result); } else { @@ -282,7 +402,14 @@ export default function MCPUIResourceRenderer({ height: true, width: false, // set to false to allow for responsive design }, - sandboxPermissions: 'allow-forms', + sandboxPermissions: 'allow-forms', // enabled for experimentation, is spread into underlying iframe defaults + iframeRenderData: { + // iframeRenderData allows us to pass data down to MCP-UIs + // MPC-UIs might find stuff like host and theme for conditional rendering + // usage of this is experimental, leaving in place for demos + host: 'goose', + theme: currentThemeValue, + }, }} />