diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json index 583d51d0..063ca4f5 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -17,6 +17,7 @@ "Configure events attachment": "Configure events attachment", "Configure log attachment": "Configure log attachment", "Confirm chat deletion": "Confirm chat deletion", + "Content": "Content", "Conversation history has been truncated to fit within context window.": "Conversation history has been truncated to fit within context window.", "Copied": "Copied", "Copy conversation": "Copy conversation", @@ -45,8 +46,10 @@ "Failed to load events": "Failed to load events", "Failed to load jobs": "Failed to load jobs", "Failed to load logs": "Failed to load logs", + "Failed to load MCP App: {{error}}": "Failed to load MCP App: {{error}}", "Failed to load pods": "Failed to load pods", "Failed to load scale target": "Failed to load scale target", + "Failed to refresh data: {{error}}": "Failed to refresh data: {{error}}", "Filtered YAML": "Filtered YAML", "For questions or feedback about OpenShift Lightspeed,": "For questions or feedback about OpenShift Lightspeed,", "Full YAML file": "Full YAML file", @@ -55,10 +58,15 @@ "If this error persists, please contact an administrator. Error details: {{e}}": "If this error persists, please contact an administrator. Error details: {{e}}", "Import to console": "Import to console", "Important": "Important", + "Interactive view from {{toolName}}": "Interactive view from {{toolName}}", "Kind, Metadata, and Status sections only": "Kind, Metadata, and Status sections only", "Large prompt": "Large prompt", "Leave": "Leave", + "Loading MCP App...": "Loading MCP App...", "Logs": "Logs", + "MCP App Error": "MCP App Error", + "MCP App: {{toolName}}": "MCP App: {{toolName}}", + "MCP server": "MCP server", "Minimize": "Minimize", "Most recent {{lines}} lines": "Most recent {{lines}} lines", "Most recent {{numEvents}} events": "Most recent {{numEvents}} events", @@ -77,16 +85,21 @@ "Preview attachment": "Preview attachment", "Preview attachment - modified": "Preview attachment - modified", "Red Hat OpenShift Lightspeed": "Red Hat OpenShift Lightspeed", + "Refresh": "Refresh", + "Restore": "Restore", "Revert to original": "Revert to original", "Save": "Save", "Send a message...": "Send a message...", + "Status": "Status", "Stay": "Stay", + "Structured content": "Structured content", "Submit": "Submit", "The following output was generated when running <2>{{name}} with arguments <5>{{argsFormatted}}.": "The following output was generated when running <2>{{name}} with arguments <5>{{argsFormatted}}.", "The following output was generated when running <2>{{name}} with no arguments.": "The following output was generated when running <2>{{name}} with no arguments.", "The OpenShift Lightspeed service is not yet ready to receive requests. If this message persists, please check the OLSConfig.": "The OpenShift Lightspeed service is not yet ready to receive requests. If this message persists, please check the OLSConfig.", "Tool output": "Tool output", "Total size of attachments exceeds {{max}} characters.": "Total size of attachments exceeds {{max}} characters.", + "UI resource": "UI resource", "Upload from computer": "Upload from computer", "Uploaded file is not valid YAML": "Uploaded file is not valid YAML", "Uploaded file is too large. Max size is {{max}} MB.": "Uploaded file is too large. Max size is {{max}} MB.", diff --git a/src/components/MCPAppFrame.tsx b/src/components/MCPAppFrame.tsx new file mode 100644 index 00000000..5897bff3 --- /dev/null +++ b/src/components/MCPAppFrame.tsx @@ -0,0 +1,676 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { + Alert, + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Spinner, +} from '@patternfly/react-core'; +import { + CompressIcon, + ExpandIcon, + MinusIcon, + SyncAltIcon, + TimesIcon, + WindowRestoreIcon, +} from '@patternfly/react-icons'; + +import { getRequestInitWithAuthHeader } from '../hooks/useAuth'; +import { useIsDarkTheme } from '../hooks/useIsDarkTheme'; + +import './mcp-app-card.css'; + +type MCPAppFrameProps = { + resourceUri: string; + serverName: string; + status?: string; + toolArgs?: Record; + toolContent?: string; + toolName: string; +}; + +// Ext-apps protocol message types +type ExtAppsRequest = { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: Record; +}; + +type ExtAppsResponse = { + jsonrpc: '2.0'; + id: string | number; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +// Generate generic HTML for structured data +const generateGenericDataHtml = ( + data: Record, + toolName: string, + isDarkTheme: boolean, +): string => { + const bgColor = isDarkTheme ? '#1b1d21' : '#ffffff'; + const textColor = isDarkTheme ? '#e0e0e0' : '#151515'; + + return ` + + + + + + + +

${toolName}

+ +
${JSON.stringify(data, null, 2)}
+ + + +`; +}; + +// Generate simple HTML wrapper for raw content +const wrapHtmlContent = (html: string, isDarkTheme: boolean): string => { + const bgColor = isDarkTheme ? '#1b1d21' : '#ffffff'; + const textColor = isDarkTheme ? '#e0e0e0' : '#151515'; + const borderColor = isDarkTheme ? '#3c3f42' : '#d2d2d2'; + + return ` + + + + + + + +${html} + + +`; +}; + +const IFRAME_HEIGHT_MIN = 60; +const IFRAME_HEIGHT_MAX = 960; + +const MCPAppFrame: React.FC = ({ + resourceUri, + serverName, + status, + toolArgs, + toolContent, + toolName, +}) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const [isDarkTheme] = useIsDarkTheme(); + + const iframeRef = React.useRef(null); + const [content, setContent] = React.useState(null); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + const [iframeHeight, setIframeHeight] = React.useState(IFRAME_HEIGHT_MIN); + const [isExpanded, setIsExpanded] = React.useState(false); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [useExtApps, setUseExtApps] = React.useState(false); + const [isClosed, setIsClosed] = React.useState(false); + const [isMinimized, setIsMinimized] = React.useState(false); + + // Handle tool call from ext-apps iframe + const handleToolCall = React.useCallback( + async ( + requestedToolName: string, + args: Record, + ): Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + structuredContent?: unknown; + }> => { + const toolEndpoint = `/api/proxy/plugin/lightspeed-console-plugin/ols/v1/mcp-apps/tools/call`; + /* eslint-disable camelcase */ + const response = await consoleFetchJSON.post( + toolEndpoint, + { + server_name: serverName, + tool_name: requestedToolName, + arguments: args, + }, + getRequestInitWithAuthHeader(), + ); + /* eslint-enable camelcase */ + + return { + content: response.content || [{ type: 'text', text: JSON.stringify(response) }], + ...(response.is_error && { isError: true }), + ...(response.structured_content && { structuredContent: response.structured_content }), + }; + }, + [serverName], + ); + + // Send ext-apps response to iframe + const sendExtAppsResponse = React.useCallback((response: ExtAppsResponse) => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage(response, '*'); + } + }, []); + + // Handle refresh triggered from card header button + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await handleToolCall(toolName, toolArgs || {}); + + if (useExtApps) { + // Re-send tool input arguments so the app can update headers/context + iframeRef.current?.contentWindow?.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/tool-input', + params: { arguments: toolArgs || {} }, + }, + '*', + ); + // Push new data into the ext-apps iframe via the standard protocol + iframeRef.current?.contentWindow?.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: result, + }, + '*', + ); + } else { + // Fallback mode: render data generically + const data = result.structuredContent || result.content; + if (data && typeof data === 'object') { + setContent( + generateGenericDataHtml(data as Record, toolName, isDarkTheme), + ); + } + } + } catch (err) { + setError(t('Failed to refresh data: {{error}}', { error: String(err) })); + } finally { + setIsRefreshing(false); + } + }, [handleToolCall, toolName, toolArgs, useExtApps, isDarkTheme, t]); + + // Handle messages from iframe + React.useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) { + return; + } + + const message = event.data; + + // Handle simple resize message (our generated HTML or any iframe posting mcp-app-resize) + if (message?.type === 'mcp-app-resize' && typeof message.height === 'number') { + setIframeHeight(Math.min(Math.max(message.height, IFRAME_HEIGHT_MIN), IFRAME_HEIGHT_MAX)); + return; + } + + // Handle ext-apps JSON-RPC protocol + if (message?.jsonrpc === '2.0' && message?.method) { + const request = message as ExtAppsRequest; + // Ext-apps SDK uses ui/ prefix for methods + const method = request.method.replace(/^ui\//, ''); + + switch (method) { + case 'tools/call': { + const params = request.params || {}; + const reqToolName = (params.name as string) || toolName; + const reqArgs = (params.arguments as Record) || {}; + + try { + const result = await handleToolCall(reqToolName, reqArgs); + sendExtAppsResponse({ + jsonrpc: '2.0', + id: request.id, + result, + }); + } catch (err) { + sendExtAppsResponse({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32603, + message: String(err), + }, + }); + } + break; + } + + case 'initialize': { + // Respond to initialize request from ext-apps SDK + sendExtAppsResponse({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2024-11-05', + hostInfo: { + name: 'lightspeed-console', + version: '1.0.0', + }, + hostCapabilities: { + tools: { call: true, list: true }, + }, + hostContext: { + theme: isDarkTheme ? 'dark' : 'light', + toolName, + serverName, + }, + }, + }); + break; + } + + case 'notifications/initialized': { + // The ext-apps SDK completed initialization and is ready for data. + // Fetch the tool result directly from the MCP server via the proxy + // endpoint. The stream only provides metadata (ui_resource_uri, + // server_name, args); actual data comes from the MCP server. + if (!iframeRef.current?.contentWindow) { + break; + } + + // Send host context (including theme) so apps can apply it + // immediately. The hostContext in the initialize response is stored + // by the SDK but apps commonly rely on the + // onhostcontextchanged callback to apply the theme, which only + // fires for this notification. + iframeRef.current.contentWindow.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { + theme: isDarkTheme ? 'dark' : 'light', + }, + }, + '*', + ); + + // Send tool input arguments so the app can display them + // (e.g., the PromQL query string or chart title) + iframeRef.current.contentWindow.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/tool-input', + params: { arguments: toolArgs || {} }, + }, + '*', + ); + + try { + const result = await handleToolCall(toolName, toolArgs || {}); + iframeRef.current?.contentWindow?.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: result, + }, + '*', + ); + } catch { + // Proxy call failed - fall back to streamed text content + iframeRef.current?.contentWindow?.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + content: [{ type: 'text', text: toolContent || '' }], + ...(status === 'error' && { isError: true }), + }, + }, + '*', + ); + } + break; + } + + case 'tools/list': { + // Return available tool for this context + sendExtAppsResponse({ + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + { + name: toolName, + description: `Call ${toolName} tool`, + inputSchema: { type: 'object', properties: {} }, + }, + ], + }, + }); + break; + } + + case 'notifications/size-changed': { + // Ext-apps SDK auto-resize notification + const params = request.params || {}; + const height = params.height as number | undefined; + if (typeof height === 'number') { + setIframeHeight(Math.min(Math.max(height, IFRAME_HEIGHT_MIN), IFRAME_HEIGHT_MAX)); + } + break; + } + + default: + // Unknown method - only send error for requests (with id), not notifications + if (request.id !== undefined) { + sendExtAppsResponse({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32601, + message: `Method not found: ${request.method}`, + }, + }); + } + } + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [ + handleToolCall, + isDarkTheme, + sendExtAppsResponse, + serverName, + status, + toolArgs, + toolContent, + toolName, + ]); + + // Notify ext-apps iframe when the theme changes after initial load + React.useEffect(() => { + if (useExtApps && iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage( + { + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { + theme: isDarkTheme ? 'dark' : 'light', + }, + }, + '*', + ); + } + }, [isDarkTheme, useExtApps]); + + // Load content - try MCP resource first (decoupled approach), fall back to generated HTML + React.useEffect(() => { + const loadContent = async () => { + try { + setIsLoading(true); + setError(null); + + // APPROACH 1: Try to load HTML resource from MCP server (decoupled - server provides UI) + try { + const resourceEndpoint = `/api/proxy/plugin/lightspeed-console-plugin/ols/v1/mcp-apps/resources`; + /* eslint-disable camelcase */ + const resourceResponse = await consoleFetchJSON.post( + resourceEndpoint, + { + resource_uri: resourceUri, + }, + getRequestInitWithAuthHeader(), + ); + /* eslint-enable camelcase */ + + if (resourceResponse?.content) { + // Successfully loaded MCP app HTML - use ext-apps mode + // The HTML will use ext-apps SDK to call tools via postMessage + setUseExtApps(true); + // Pre-inject the theme attribute so CSS variables are correct from first render, + // avoiding a flash of the wrong theme before the ext-apps init handshake completes. + const themeAttr = `data-theme="${isDarkTheme ? 'dark' : 'light'}"`; + const themedHtml = resourceResponse.content.replace( + /]*)>/i, + ``, + ); + setContent(themedHtml); + return; + } + } catch (resourceErr) { + // Resource loading failed, fall back to generated HTML + // eslint-disable-next-line no-console + console.debug('MCP App resource loading failed, using fallback:', resourceErr); + } + + // APPROACH 2: Fall back to generic data view + // Call the tool directly via proxy and render the result generically + setUseExtApps(false); + + const toolEndpoint = `/api/proxy/plugin/lightspeed-console-plugin/ols/v1/mcp-apps/tools/call`; + /* eslint-disable camelcase */ + const toolResponse = await consoleFetchJSON.post( + toolEndpoint, + { + server_name: serverName, + tool_name: toolName, + arguments: toolArgs || {}, + }, + getRequestInitWithAuthHeader(), + ); + /* eslint-enable camelcase */ + + const data = toolResponse.structured_content || toolResponse.content || toolResponse; + if (data && typeof data === 'object' && Object.keys(data).length > 0) { + setContent(generateGenericDataHtml(data, toolName, isDarkTheme)); + } else { + setContent( + wrapHtmlContent( + `

Interactive view for ${toolName}. See detailed data in the chat response above.

`, + isDarkTheme, + ), + ); + } + } catch (err) { + setError(t('Failed to load MCP App: {{error}}', { error: String(err) })); + } finally { + setIsLoading(false); + } + }; + + loadContent(); + }, [resourceUri, serverName, toolArgs, isDarkTheme, t, toolName]); + + // Toggle expand + const handleToggleExpand = React.useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + // Minimize the card to a compact bar + const handleMinimize = React.useCallback(() => { + setIsMinimized(true); + setIsExpanded(false); + }, []); + + // Restore from minimized state + const handleRestore = React.useCallback(() => { + setIsMinimized(false); + }, []); + + // Close/dismiss the card entirely + const handleClose = React.useCallback(() => { + setIsClosed(true); + setIsExpanded(false); + }, []); + + if (isClosed) { + return null; + } + + if (isMinimized && content) { + return ( + + +