From c926f2e1142a770b5771248e4ed8d28d2245f7f8 Mon Sep 17 00:00:00 2001
From: Ondrej Metelka
Date: Thu, 12 Feb 2026 10:10:33 +0100
Subject: [PATCH 1/4] Add truncated tool status and MCP app fields to types and
streaming
Extend the Tool type with a 'truncated' status and optional MCP app
metadata fields (uiResourceUri, serverName, structuredContent). Wire
the streaming parser in Prompt.tsx to extract these fields from
tool_result events and dispatch them into the Redux store.
Co-authored-by: Cursor
---
src/components/Prompt.tsx | 19 +++++++++++++++++--
src/types.ts | 6 +++++-
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 6781d7fe..3e534b5d 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -573,8 +573,23 @@ const Prompt: React.FC = ({ scrollIntoView }) => {
const { args, id, name: toolName } = json.data;
dispatch(chatHistoryUpdateTool(chatEntryID, id, { name: toolName, args }));
} else if (json.event === 'tool_result') {
- const { content, id, status } = json.data;
- dispatch(chatHistoryUpdateTool(chatEntryID, id, { content, status }));
+ const {
+ content,
+ id,
+ status,
+ ui_resource_uri: uiResourceUri,
+ server_name: serverName,
+ structured_content: structuredContent,
+ } = json.data;
+ dispatch(
+ chatHistoryUpdateTool(chatEntryID, id, {
+ content,
+ status,
+ ...(uiResourceUri && { uiResourceUri }),
+ ...(serverName && { serverName }),
+ ...(structuredContent && { structuredContent }),
+ }),
+ );
} else if (json.event === 'error') {
dispatch(
chatHistoryUpdateByID(chatEntryID, {
diff --git a/src/types.ts b/src/types.ts
index 3008eaba..ad9fd387 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -30,7 +30,11 @@ export type Tool = {
args: { [key: string]: Array };
content: string;
name: string;
- status: 'error' | 'success';
+ status: 'error' | 'success' | 'truncated';
+ // MCP app fields (optional - present when tool provides UI)
+ uiResourceUri?: string;
+ serverName?: string;
+ structuredContent?: Record;
};
type ChatEntryUser = {
From 132ae0cf911a9ef29129dc656ae972505ae8f14c Mon Sep 17 00:00:00 2001
From: Ondrej Metelka
Date: Thu, 12 Feb 2026 10:10:58 +0100
Subject: [PATCH 2/4] Color-code tool labels by status and type
Tool labels in the chat are now color-coded: red for errors,
yellow for truncated output, blue for tools with an MCP App UI,
and grey (default) for standard tools.
Co-authored-by: Cursor
---
src/components/ResponseTools.tsx | 27 ++++++++++++++++++---------
1 file changed, 18 insertions(+), 9 deletions(-)
diff --git a/src/components/ResponseTools.tsx b/src/components/ResponseTools.tsx
index a787af8e..0bf56df5 100644
--- a/src/components/ResponseTools.tsx
+++ b/src/components/ResponseTools.tsx
@@ -2,7 +2,7 @@ import { Map as ImmutableMap } from 'immutable';
import * as React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Label, LabelGroup } from '@patternfly/react-core';
-import { CodeIcon, InfoCircleIcon } from '@patternfly/react-icons';
+import { CodeIcon, ExternalLinkAltIcon, InfoCircleIcon } from '@patternfly/react-icons';
import { openToolSet } from '../redux-actions';
import { State } from '../redux-reducers';
@@ -23,16 +23,25 @@ const ToolLabel: React.FC = ({ entryIndex, toolID }) => {
dispatch(openToolSet(entryIndex, toolID));
}, [dispatch, entryIndex, toolID]);
- const isError = tool.get('status') === 'error';
+ const status = tool.get('status') as string | undefined;
+ const isError = status === 'error';
+ const isTruncated = status === 'truncated';
+ const hasUI = !!tool.get('uiResourceUri');
+
+ const color = isError ? 'red' : isTruncated ? 'yellow' : hasUI ? 'blue' : undefined;
+ const icon = isError ? (
+
+ ) : isTruncated ? (
+
+ ) : hasUI ? (
+
+ ) : (
+
+ );
return (
- : }
- onClick={onClick}
- textMaxWidth="16ch"
- >
- {tool.get('name')}
+
);
};
From 32323b58a6ee852d759d536abe95f248894325b5 Mon Sep 17 00:00:00 2001
From: Ondrej Metelka
Date: Thu, 12 Feb 2026 10:11:43 +0100
Subject: [PATCH 3/4] Enhance tool detail modal with metadata and structured
content
Show all MCP tool fields in the tool detail modal: a metadata section
with color-coded status label, MCP server name, and UI resource URI,
plus a dedicated structured content section with formatted, copyable
JSON.
Co-authored-by: Cursor
---
.../en/plugin__lightspeed-console-plugin.json | 5 +
src/components/ResponseToolModal.tsx | 112 +++++++++++++++---
src/components/general-page.css | 10 ++
3 files changed, 111 insertions(+), 16 deletions(-)
diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json
index 583d51d0..c7526454 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",
@@ -59,6 +60,7 @@
"Large prompt": "Large prompt",
"Leave": "Leave",
"Logs": "Logs",
+ "MCP server": "MCP server",
"Minimize": "Minimize",
"Most recent {{lines}} lines": "Most recent {{lines}} lines",
"Most recent {{numEvents}} events": "Most recent {{numEvents}} events",
@@ -80,13 +82,16 @@
"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}}2> with arguments <5>{{argsFormatted}}5>.": "The following output was generated when running <2>{{name}}2> with arguments <5>{{argsFormatted}}5>.",
"The following output was generated when running <2>{{name}}2> with no arguments.": "The following output was generated when running <2>{{name}}2> 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/ResponseToolModal.tsx b/src/components/ResponseToolModal.tsx
index 0aa6db71..de8d102c 100644
--- a/src/components/ResponseToolModal.tsx
+++ b/src/components/ResponseToolModal.tsx
@@ -2,7 +2,19 @@ import { Map as ImmutableMap } from 'immutable';
import * as React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
-import { Alert, CodeBlock, CodeBlockAction, CodeBlockCode, Icon } from '@patternfly/react-core';
+import {
+ Alert,
+ CodeBlock,
+ CodeBlockAction,
+ CodeBlockCode,
+ DescriptionList,
+ DescriptionListDescription,
+ DescriptionListGroup,
+ DescriptionListTerm,
+ Icon,
+ Label,
+ Title,
+} from '@patternfly/react-core';
import { InfoCircleIcon } from '@patternfly/react-icons';
import { openToolClear } from '../redux-actions';
@@ -32,12 +44,26 @@ const ToolModal: React.FC = () => {
if (!tool) {
return null;
}
- const { args, content, name, status } = tool.toJS();
- const argsFormatted = Object.entries(args)
+ const { args, content, name, serverName, status, structuredContent, uiResourceUri } =
+ tool.toJS() as {
+ args: Record;
+ content: string;
+ name: string;
+ serverName?: string;
+ status: string;
+ structuredContent?: Record;
+ uiResourceUri?: string;
+ };
+
+ const argsFormatted = Object.entries(args ?? {})
.map(([key, value]) => `${key}=${value}`)
.join(', ');
+ const structuredContentFormatted = structuredContent
+ ? JSON.stringify(structuredContent, null, 2)
+ : undefined;
+
return (
{
)}
+
+
+
+ {t('Status')}
+
+
+
+
+ {serverName && (
+
+ {t('MCP server')}
+ {serverName}
+
+ )}
+ {uiResourceUri && (
+
+ {t('UI resource')}
+
+ {uiResourceUri}
+
+
+ )}
+
+
{content ? (
-
-
-
-
-
- >
- }
- className="ols-plugin__code-block ols-plugin__code-block--attachment"
- >
- {content}
-
+ <>
+
+ {t('Content')}
+
+
+
+
+
+
+ >
+ }
+ className="ols-plugin__code-block ols-plugin__code-block--attachment"
+ >
+ {content}
+
+ >
) : (
{
variant="info"
/>
)}
+
+ {structuredContentFormatted && (
+ <>
+
+ {t('Structured content')}
+
+
+
+
+
+
+ >
+ }
+ className="ols-plugin__code-block ols-plugin__code-block--attachment"
+ >
+
+ {structuredContentFormatted}
+
+
+ >
+ )}
);
};
diff --git a/src/components/general-page.css b/src/components/general-page.css
index 6de49de7..f6d3b1bc 100644
--- a/src/components/general-page.css
+++ b/src/components/general-page.css
@@ -124,3 +124,13 @@
max-width: 30rem;
text-align: center;
}
+
+.ols-plugin__tool-metadata {
+ margin-bottom: var(--pf-t--global--spacer--md);
+ margin-top: var(--pf-t--global--spacer--md);
+}
+
+.ols-plugin__tool-section-title {
+ margin-bottom: var(--pf-t--global--spacer--sm);
+ margin-top: var(--pf-t--global--spacer--md);
+}
\ No newline at end of file
From 5d8d8def22bdac124f7a0c8dff28328dbf4c4bb3 Mon Sep 17 00:00:00 2001
From: Ondrej Metelka
Date: Thu, 12 Feb 2026 10:15:04 +0100
Subject: [PATCH 4/4] Add MCPAppFrame component for interactive MCP tool views
Render interactive MCP App iframes in the chat for tools that provide
a UI resource. The MCPAppFrame component acts as an ext-apps SDK host,
handling JSON-RPC over postMessage for initialization, tool calls,
tool results, and auto-resize. Supports refresh, expand/collapse,
minimize, close, and dark/light theme.
Co-authored-by: Cursor
---
.../en/plugin__lightspeed-console-plugin.json | 8 +
src/components/MCPAppFrame.tsx | 676 ++++++++++++++++++
src/components/ResponseTools.tsx | 64 +-
src/components/mcp-app-card.css | 52 ++
4 files changed, 795 insertions(+), 5 deletions(-)
create mode 100644 src/components/MCPAppFrame.tsx
create mode 100644 src/components/mcp-app-card.css
diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json
index c7526454..063ca4f5 100644
--- a/locales/en/plugin__lightspeed-console-plugin.json
+++ b/locales/en/plugin__lightspeed-console-plugin.json
@@ -46,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",
@@ -56,10 +58,14 @@
"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",
@@ -79,6 +85,8 @@
"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...",
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 (
+
+
+ }
+ onClick={handleRestore}
+ title={t('Restore')}
+ variant="plain"
+ />
+ }
+ onClick={handleClose}
+ title={t('Close')}
+ variant="plain"
+ />
+ >
+ ),
+ }}
+ >
+
+ {t('Interactive view from {{toolName}}', { toolName })}
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+ {t('Loading MCP App...')}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (!content) {
+ return null;
+ }
+
+ const cardClassName = isExpanded
+ ? 'ols-plugin__mcp-app-card ols-plugin__mcp-app-card--expanded'
+ : 'ols-plugin__mcp-app-card';
+
+ return (
+
+
+ : }
+ isDisabled={isRefreshing}
+ onClick={handleRefresh}
+ title={t('Refresh')}
+ variant="plain"
+ />
+ : }
+ onClick={handleToggleExpand}
+ title={isExpanded ? t('Collapse') : t('Expand')}
+ variant="plain"
+ />
+ }
+ onClick={handleMinimize}
+ title={t('Minimize')}
+ variant="plain"
+ />
+ }
+ onClick={handleClose}
+ title={t('Close')}
+ variant="plain"
+ />
+ >
+ ),
+ }}
+ >
+
+ {t('Interactive view from {{toolName}}', { toolName })}
+
+
+
+
+
+
+ );
+};
+
+export default MCPAppFrame;
diff --git a/src/components/ResponseTools.tsx b/src/components/ResponseTools.tsx
index 0bf56df5..f25c8869 100644
--- a/src/components/ResponseTools.tsx
+++ b/src/components/ResponseTools.tsx
@@ -6,6 +6,7 @@ import { CodeIcon, ExternalLinkAltIcon, InfoCircleIcon } from '@patternfly/react
import { openToolSet } from '../redux-actions';
import { State } from '../redux-reducers';
+import MCPAppFrame from './MCPAppFrame';
type ToolProps = {
entryIndex: number;
@@ -46,6 +47,46 @@ const ToolLabel: React.FC = ({ entryIndex, toolID }) => {
);
};
+type MCPAppToolProps = {
+ entryIndex: number;
+ toolID: string;
+};
+
+const MCPAppTool: React.FC = ({ entryIndex, toolID }) => {
+ const tool: ImmutableMap = useSelector((s: State) =>
+ s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools', toolID]),
+ );
+
+ const resourceUri = tool.get('uiResourceUri') as string | undefined;
+ const serverName = tool.get('serverName') as string | undefined;
+ const toolStatus = tool.get('status') as string | undefined;
+ const rawArgs = tool.get('args');
+ const toolArgs = React.useMemo(
+ () =>
+ rawArgs && typeof (rawArgs as ImmutableMap).toJS === 'function'
+ ? ((rawArgs as ImmutableMap).toJS() as Record)
+ : (rawArgs as Record | undefined),
+ [rawArgs],
+ );
+ const toolContent = tool.get('content') as string | undefined;
+ const toolName = tool.get('name') as string;
+
+ if (!resourceUri || !serverName) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
type ResponseToolsProps = {
entryIndex: number;
};
@@ -55,12 +96,25 @@ const ResponseTools: React.FC = ({ entryIndex }) => {
s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools']),
);
+ const toolsWithUI = tools.filter((tool) => !!tool.get('uiResourceUri'));
+
return (
-
- {tools.keySeq().map((toolID) => (
-
- ))}
-
+ <>
+
+ {tools
+ .keySeq()
+ .toArray()
+ .map((toolID) => (
+
+ ))}
+
+ {toolsWithUI
+ .keySeq()
+ .toArray()
+ .map((toolID) => (
+
+ ))}
+ >
);
};
diff --git a/src/components/mcp-app-card.css b/src/components/mcp-app-card.css
new file mode 100644
index 00000000..52fec67c
--- /dev/null
+++ b/src/components/mcp-app-card.css
@@ -0,0 +1,52 @@
+.ols-plugin__mcp-app-card {
+ margin-top: var(--pf-t--global--spacer--md);
+ transition: all 0.2s ease-in-out;
+}
+
+.ols-plugin__mcp-app-card--expanded {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ z-index: 1000;
+ border-radius: 0;
+}
+
+.ols-plugin__mcp-app-card--expanded .ols-plugin__mcp-app-body {
+ height: calc(100% - 48px);
+ overflow: auto;
+}
+
+.ols-plugin__mcp-app-card--expanded .ols-plugin__mcp-app-iframe {
+ height: 100% !important;
+ min-height: 400px;
+}
+
+.ols-plugin__mcp-app-card--minimized {
+ opacity: 0.75;
+}
+
+.ols-plugin__mcp-app-card--minimized:hover {
+ opacity: 1;
+}
+
+.ols-plugin__mcp-app-title {
+ color: var(--pf-t--global--text--color--subtle);
+ font-size: var(--pf-t--global--font--size--sm);
+ font-weight: var(--pf-t--global--font--weight--body--default);
+}
+
+.ols-plugin__mcp-app-body {
+ padding: 0;
+}
+
+.ols-plugin__mcp-app-iframe {
+ background: transparent;
+ border: none;
+ display: block;
+ min-height: 60px;
+ width: 100%;
+}