feat: upgrade @mcp-ui/client package and improve UI message handling (#4164)

This commit is contained in:
Andrew Harvard
2025-08-21 16:21:32 -04:00
committed by GitHub
parent 79b163cc60
commit db8518f52d
9 changed files with 447 additions and 98 deletions
+9 -41
View File
@@ -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",
@@ -2601,18 +2601,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": {
@@ -2647,40 +2649,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",
+5 -1
View File
@@ -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",
@@ -140,6 +140,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'",
+15
View File
@@ -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 (
<div className="h-full flex flex-col min-h-0">
<MainPanelLayout
@@ -208,6 +208,7 @@ export default function GooseMessage({
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
append={append}
/>
</div>
))}
@@ -1,74 +1,416 @@
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<string, unknown>;
};
type UIRequestData = {
type: 'ui-request-data';
messageId: string;
payload: {
requestType: string;
params: Record<string, unknown>;
};
};
// 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<T = unknown> = {
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<T = unknown> =
| UIActionHandlerSuccess<T>
| 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',
}
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` };
};
// toast component
const ToastComponent = ({
messageType,
message,
isImplemented = true,
}: {
messageType: string;
message?: string;
isImplemented?: boolean;
}) => {
const title = `MCP-UI ${messageType} message`;
const handleUIAction = useCallback(async (result: UIActionResult) => {
switch (result.type) {
case 'intent': {
// TODO: Implement intent handling
handleAction(result);
break;
}
return (
<div className="flex flex-col gap-0 py-2 pr-4">
<p className="font-bold">{title}</p>
{isImplemented ? (
<p>
Message received for <span className="font-bold">{message}</span>.
</p>
) : (
<p>
Message received for <span className="font-bold">{message}</span>.
<br />
{messageType.charAt(0).toUpperCase() + messageType.slice(1)} messages aren't supported
yet, refer to console for more details.
</p>
)}
</div>
);
};
case 'link': {
// TODO: Implement link handling
handleAction(result);
break;
}
export default function MCPUIResourceRenderer({
content,
appendPromptToChat,
}: MCPUIResourceRendererProps) {
const [currentThemeValue, setCurrentThemeValue] = useState<string>('light');
case 'notify': {
// TODO: Implement notify handling
handleAction(result);
break;
}
case 'prompt': {
// TODO: Implement prompt handling
handleAction(result);
break;
}
case 'tool': {
// TODO: Implement tool call handling
handleAction(result);
break;
}
default: {
console.warn('unsupported message sent from MCP-UI:', result);
break;
}
}
useEffect(() => {
const theme = localStorage.getItem('theme') || 'light';
setCurrentThemeValue(theme);
console.log('[MCP-UI] Current theme value:', theme);
}, []);
const handleUIAction = async (
actionEvent: ActionEventsFromIframe
): Promise<UIActionHandlerResult> => {
// result to pass back to the MCP-UI
let result: UIActionHandlerResult;
const handleToolCase = async (
actionEvent: UIActionResultToolCall
): Promise<UIActionHandlerResult> => {
const { toolName, params } = actionEvent.payload;
toast.info(<ToastComponent messageType="tool" message={toolName} isImplemented={false} />, {
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<UIActionHandlerResult> => {
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,
},
};
}
}
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<UIActionHandlerResult> => {
const { message } = actionEvent.payload;
toast.info(<ToastComponent messageType="notify" message={message} isImplemented={true} />, {
theme: currentThemeValue,
});
return {
status: 'success' as const,
data: {
displayedAt: new Date().toISOString(),
message: 'Notification displayed',
details: actionEvent.payload,
},
};
};
const handleIntentCase = async (
actionEvent: UIActionResultIntent
): Promise<UIActionHandlerResult> => {
toast.info(
<ToastComponent
messageType="intent"
message={actionEvent.payload.intent}
isImplemented={false}
/>,
{
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<UIActionHandlerResult> => {
return {
status: 'success' as const,
message: 'Size change handled',
data: actionEvent.payload,
};
};
const handleIframeReadyCase = async (
actionEvent: UILifecycleIframeReady
): Promise<UIActionHandlerResult> => {
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<UIActionHandlerResult> => {
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;
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 (
<div className="mt-3 p-4 border border-borderSubtle rounded-lg bg-background-muted">
<div className="overflow-hidden rounded-sm">
<UIResourceRenderer
resource={content.resource}
onUIAction={handleUIAction}
htmlProps={
{
// Remove autoResizeIframe as it's not in the type definition
}
}
htmlProps={{
autoResizeIframe: {
height: true,
width: false, // set to false to allow for responsive design
},
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,
},
}}
/>
</div>
</div>
@@ -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 (
<div key={`${content.type}-${index}`} className="mt-3">
<MCPUIResourceRenderer content={content} />
<MCPUIResourceRenderer content={content} appendPromptToChat={append} />
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
+11
View File
@@ -1070,6 +1070,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) => {
return openDirectoryDialog();
+5
View File
@@ -96,6 +96,8 @@ type ElectronAPI = {
// Functions for image pasting
saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise<SaveDataUrlResponse>;
deleteTempFile: (filePath: string) => void;
// Function for opening external URLs securely
openExternal: (url: string) => Promise<void>;
// Function to serve temp images
getTempImage: (filePath: string) => Promise<string | null>;
// Update-related functions
@@ -208,6 +210,9 @@ const electronAPI: ElectronAPI = {
deleteTempFile: (filePath: string): void => {
ipcRenderer.send('delete-temp-file', filePath);
},
openExternal: (url: string): Promise<void> => {
return ipcRenderer.invoke('open-external', url);
},
getTempImage: (filePath: string): Promise<string | null> => {
return ipcRenderer.invoke('get-temp-image', filePath);
},
+1 -1
View File
@@ -12,5 +12,5 @@ export default defineConfig({
build: {
target: 'esnext'
}
},
});