From 56a3c8012bade906db56c98db5336b4b5d3fa1f3 Mon Sep 17 00:00:00 2001 From: Jason Madigan Date: Mon, 17 Aug 2026 11:56:05 +0100 Subject: [PATCH 1/4] feat: add throwaway MCP inspector page (PoC) proves the direct browser-to-gateway design: watch MCPGatewayExtension resources, speak MCP Streamable HTTP directly to status.mcpEndpoint, and render tools/list + tools/call. dependency-free json-rpc client handling json + sse responses; session id and token kept in react state only. Refs #671 Signed-off-by: Jason Madigan --- console-extensions.json | 8 + .../en/plugin__kuadrant-console-plugin.json | 31 +- package.json | 3 +- src/components/mcp/MCPInspectorPage.tsx | 330 ++++++++++++++++++ src/components/mcp/types.ts | 1 + src/utils/mcp/client.ts | 218 ++++++++++++ 6 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 src/components/mcp/MCPInspectorPage.tsx create mode 100644 src/utils/mcp/client.ts diff --git a/console-extensions.json b/console-extensions.json index 79318263..05ce9bcf 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -701,5 +701,13 @@ "path": "/kuadrant/mcp/setup-wizard", "component": { "$codeRef": "MCPSetupWizard" } } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": "/mcp-inspector", + "component": { "$codeRef": "MCPInspectorPage" } + } } ] diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index 3da8d99c..7277e557 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -845,5 +845,32 @@ "You do not have permission to view Policies": "You do not have permission to view Policies", "You do not have permission to view Policy Topology": "You do not have permission to view Policy Topology", "You do not have permission to view Reference grants": "You do not have permission to view Reference grants", - "You do not have permission to view this resource": "You do not have permission to view this resource" -} \ No newline at end of file + "You do not have permission to view this resource": "You do not have permission to view this resource", + "MCP Inspector": "MCP Inspector", + "Throwaway proof-of-concept: the browser speaks MCP Streamable HTTP directly to a gateway endpoint. Calls are cross-origin.": "Throwaway proof-of-concept: the browser speaks MCP Streamable HTTP directly to a gateway endpoint. Calls are cross-origin.", + "Browser origin": "Browser origin", + "MCP gateway extension": "MCP gateway extension", + "Select an MCP gateway extension": "Select an MCP gateway extension", + "Loading extensions...": "Loading extensions...", + "Select an extension...": "Select an extension...", + "not reachable": "not reachable", + "Resolved endpoint": "Resolved endpoint", + "none": "none", + "Bearer token (optional)": "Bearer token (optional)", + "Held in memory only": "Held in memory only", + "Connect + list tools": "Connect + list tools", + "Session ID": "Session ID", + "Session expired, reconnect": "Session expired, reconnect", + "The MCP session is no longer valid. Connect again to start a new session.": "The MCP session is no longer valid. Connect again to start a new session.", + "initialize": "initialize", + "tools/list": "tools/list", + "Call a tool": "Call a tool", + "Tool": "Tool", + "Select a tool": "Select a tool", + "Select a tool...": "Select a tool...", + "Arguments (JSON)": "Arguments (JSON)", + "Tool call arguments as JSON": "Tool call arguments as JSON", + "Call": "Call", + "tools/call result": "tools/call result", + "Arguments must be valid JSON": "Arguments must be valid JSON" +} diff --git a/package.json b/package.json index febc765f..2d25ed7d 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,8 @@ "GatewaySingleOverview": "./components/gateway/GatewaySingleOverview", "HTTPRouteSingleOverview": "./components/httproute/HTTPRouteSingleOverview", "MCPOverviewPage": "./components/mcp/MCPOverviewPage", - "MCPSetupWizard": "./components/mcp/MCPSetupWizard" + "MCPSetupWizard": "./components/mcp/MCPSetupWizard", + "MCPInspectorPage": "./components/mcp/MCPInspectorPage" }, "dependencies": { "@console/pluginAPI": ">=4.22.0-0" diff --git a/src/components/mcp/MCPInspectorPage.tsx b/src/components/mcp/MCPInspectorPage.tsx new file mode 100644 index 00000000..4b9d713f --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.tsx @@ -0,0 +1,330 @@ +import * as React from 'react'; +import Helmet from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { + PageSection, + Title, + Content, + Form, + FormGroup, + FormSelect, + FormSelectOption, + TextInput, + TextArea, + Button, + Alert, + Stack, + StackItem, + Divider, +} from '@patternfly/react-core'; +import { + NamespaceBar, + useActiveNamespace, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { RESOURCES } from '../../utils/resources'; +import { MCPGatewayExtension } from './types'; +import { + MCPClient, + MCPSessionExpiredError, + MCPTool, + InitializeResult, + ToolsListResult, + ToolsCallResult, +} from '../../utils/mcp/client'; + +const ALL_NS = '#ALL_NS#'; + +const preStyle: React.CSSProperties = { + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflow: 'auto', + maxHeight: '20rem', + padding: 'var(--pf-t--global--spacer--md, 1rem)', + background: 'var(--pf-t--global--background--color--secondary--default, #f5f5f5)', + borderRadius: 'var(--pf-t--global--border--radius--small, 4px)', +}; + +const isReady = (ext: MCPGatewayExtension): boolean => + (ext.status?.conditions ?? []).some((c) => c.type === 'Ready' && c.status === 'True'); + +const extKey = (ext: MCPGatewayExtension): string => + `${ext.metadata?.namespace}/${ext.metadata?.name}`; + +const MCPInspectorPage: React.FC = () => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [activeNamespace] = useActiveNamespace(); + const resolvedNamespace = activeNamespace === ALL_NS ? undefined : activeNamespace; + + const [extensions, extensionsLoaded] = useK8sWatchResource({ + groupVersionKind: RESOURCES.MCPGatewayExtension.gvk, + isList: true, + namespace: resolvedNamespace, + }); + + const [selectedKey, setSelectedKey] = React.useState(''); + const [token, setToken] = React.useState(''); + + // session id lives in react state and on the client instance only, never localStorage. + const clientRef = React.useRef(null); + const [sessionId, setSessionId] = React.useState(null); + + const [initResult, setInitResult] = React.useState(null); + const [toolsResult, setToolsResult] = React.useState(null); + const [tools, setTools] = React.useState([]); + + const [selectedTool, setSelectedTool] = React.useState(''); + const [argsText, setArgsText] = React.useState('{}'); + const [callResult, setCallResult] = React.useState(null); + + const [connecting, setConnecting] = React.useState(false); + const [calling, setCalling] = React.useState(false); + const [error, setError] = React.useState(''); + const [sessionExpired, setSessionExpired] = React.useState(false); + + const list = React.useMemo(() => extensions ?? [], [extensions]); + const selected = React.useMemo( + () => list.find((ext) => extKey(ext) === selectedKey), + [list, selectedKey], + ); + const endpoint = selected?.status?.mcpEndpoint ?? ''; + const origin = window.location.origin; + + const handleConnect = async () => { + if (!endpoint) { + return; + } + setError(''); + setSessionExpired(false); + setConnecting(true); + setInitResult(null); + setToolsResult(null); + setTools([]); + setSelectedTool(''); + setCallResult(null); + try { + const client = new MCPClient(endpoint, { token: token || undefined }); + const { result, sessionId: id } = await client.initialize(); + await client.sendInitialized(); + const listed = await client.toolsList(); + clientRef.current = client; + setSessionId(id); + setInitResult(result); + setToolsResult(listed); + setTools(listed.tools ?? []); + } catch (err) { + clientRef.current = null; + setSessionId(null); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setConnecting(false); + } + }; + + const handleCall = async () => { + const client = clientRef.current; + if (!client || !selectedTool) { + return; + } + let parsedArgs: Record; + try { + parsedArgs = argsText.trim() === '' ? {} : JSON.parse(argsText); + } catch { + setError(t('Arguments must be valid JSON')); + return; + } + setError(''); + setSessionExpired(false); + setCalling(true); + setCallResult(null); + try { + const result = await client.toolsCall(selectedTool, parsedArgs); + setCallResult(result); + } catch (err) { + if (err instanceof MCPSessionExpiredError) { + setSessionExpired(true); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setCalling(false); + } + }; + + return ( + <> + + {t('MCP Inspector')} + + + + + + {t('MCP Inspector')} + + {t( + 'Throwaway proof-of-concept: the browser speaks MCP Streamable HTTP directly to a gateway endpoint. Calls are cross-origin.', + )} + + + {t('Browser origin')}: {origin} + + + + +
+ + setSelectedKey(value)} + aria-label={t('Select an MCP gateway extension')} + isDisabled={!extensionsLoaded} + > + + {list.map((ext) => { + const reachable = !!ext.status?.mcpEndpoint && isReady(ext); + const name = `${ext.metadata?.name} (${ext.metadata?.namespace})`; + return ( + + ); + })} + + + + + + {endpoint || t('none')} + + + + + setToken(value)} + placeholder={t('Held in memory only')} + /> + + + + + +
+
+ + {sessionId && ( + + + {t('Session ID')}: {sessionId} + + + )} + + {error && ( + + + {error} + + + )} + + {sessionExpired && ( + + + {t('The MCP session is no longer valid. Connect again to start a new session.')} + + + )} + + {initResult && ( + + {t('initialize')} +
{JSON.stringify(initResult, null, 2)}
+
+ )} + + {toolsResult && ( + + {t('tools/list')} +
{JSON.stringify(toolsResult, null, 2)}
+
+ )} + + {tools.length > 0 && ( + <> + + + + + {t('Call a tool')} +
+ + setSelectedTool(value)} + aria-label={t('Select a tool')} + > + + {tools.map((tool) => ( + + ))} + + + +