diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts index 5e6810451c..0e1500090a 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts @@ -28,7 +28,10 @@ const MOCK_TOOLS = [ export const mcpHandlers: HttpHandler[] = [ http.post(MOCK_MCP_ADDR, async ({ request }) => { const auth = request.headers.get('Authorization'); - if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) { + if ( + auth !== MOCK_MCP_VALID_TOKEN && + auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}` + ) { return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts new file mode 100644 index 0000000000..b996408f81 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { McpServerValidator } from './mcp-server-validator'; + +describe('McpServerValidator auth header behavior', () => { + const url = 'https://mcp.example.com'; + const childMock = jest.fn(); + const logger: ConstructorParameters[0] = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: childMock, + }; + childMock.mockImplementation(() => logger); + + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it('tries raw token first, then Bearer on 401/403', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue(new Response(null, { status: 401 })); + global.fetch = fetchMock; + + const validator = new McpServerValidator(logger); + const result = await validator.validate(url, 'raw-token'); + + expect(result).toMatchObject({ + valid: false, + toolCount: 0, + tools: [], + error: 'Invalid credentials — server returned 401/403', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'raw-token', + }); + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer raw-token', + }); + }); + + it('uses token as-is when it already has an auth scheme', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue(new Response(null, { status: 401 })); + global.fetch = fetchMock; + + const validator = new McpServerValidator(logger); + const result = await validator.validate(url, 'Basic abc123'); + + expect(result.valid).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'Basic abc123', + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts index bca6b27ce7..4c0e09a624 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts @@ -19,6 +19,82 @@ import type { LoggerService } from '@backstage/backend-plugin-api'; import { McpValidationResult } from './mcp-server-types'; const REQUEST_TIMEOUT_MS = 10_000; +const INVALID_CREDENTIALS_ERROR = + 'Invalid credentials — server returned 401/403'; + +const getEndpointLabel = (targetUrl: string): string => { + try { + const parsed = new URL(targetUrl); + return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname; + } catch { + return targetUrl; + } +}; + +const getNestedError = (error: unknown): Error | undefined => { + if ( + error && + typeof error === 'object' && + 'cause' in error && + (error as { cause?: unknown }).cause instanceof Error + ) { + return (error as { cause: Error }).cause; + } + return undefined; +}; + +const getNetworkErrorMessage = (url: string, error: unknown): string => { + const endpoint = getEndpointLabel(url); + const nestedError = getNestedError(error); + const fullMessage = [ + error instanceof Error ? error.message : '', + nestedError?.name ?? '', + nestedError?.message ?? '', + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + if ( + fullMessage.includes('timeout') || + fullMessage.includes('aborterror') || + fullMessage.includes('aborted') + ) { + return `Connection timed out while contacting ${endpoint}`; + } + if ( + fullMessage.includes('econnrefused') || + fullMessage.includes('connection refused') + ) { + return `Connection refused by ${endpoint}`; + } + if ( + fullMessage.includes('enotfound') || + fullMessage.includes('getaddrinfo') + ) { + return `Host not found for ${endpoint}`; + } + if ( + fullMessage.includes('econnreset') || + fullMessage.includes('socket hang up') + ) { + return `Connection reset by ${endpoint}`; + } + if ( + fullMessage.includes('ehostunreach') || + fullMessage.includes('enetunreach') + ) { + return `Host unreachable: ${endpoint}`; + } + if (fullMessage.includes('fetch failed')) { + return `Unable to connect to ${endpoint}`; + } + + return ( + nestedError?.message || + (error instanceof Error ? error.message : String(error)) + ); +}; /** * Validates MCP server credentials using the Streamable HTTP transport. @@ -32,13 +108,51 @@ export class McpServerValidator { constructor(private readonly logger: LoggerService) {} async validate(url: string, token: string): Promise { - // Bearer prefix is required here because the validator hits the MCP server - // directly (not through LCS). LCS handles its own auth scheme via - // MCP-HEADERS (see buildMcpHeaders in router.ts), but direct MCP - // Streamable HTTP endpoints expect standard Bearer authentication. + const trimmedToken = token.trim(); + const hasAuthScheme = /^[A-Za-z][A-Za-z0-9_-]*\s+/.test(trimmedToken); + const authorizationHeaders = hasAuthScheme + ? [trimmedToken] + : [trimmedToken, `Bearer ${trimmedToken}`]; + + let lastResult: McpValidationResult = { + valid: false, + toolCount: 0, + tools: [], + error: INVALID_CREDENTIALS_ERROR, + }; + + for (const [index, authorizationHeader] of authorizationHeaders.entries()) { + const result = await this.validateWithAuthorizationHeader( + url, + authorizationHeader, + ); + lastResult = result; + + const isLastAttempt = index === authorizationHeaders.length - 1; + const shouldRetryWithAlternativeAuth = + !isLastAttempt && + !result.valid && + result.error === INVALID_CREDENTIALS_ERROR; + + if (!shouldRetryWithAlternativeAuth) { + return result; + } + + this.logger.debug( + `MCP validation got 401/403 for ${url}; retrying with an alternate Authorization header format`, + ); + } + + return lastResult; + } + + private async validateWithAuthorizationHeader( + url: string, + authorizationHeader: string, + ): Promise { const headers: Record = { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: authorizationHeader, Accept: 'application/json, text/event-stream', }; @@ -65,7 +179,7 @@ export class McpServerValidator { valid: false, toolCount: 0, tools: [], - error: 'Invalid credentials — server returned 401/403', + error: INVALID_CREDENTIALS_ERROR, }; } @@ -150,20 +264,7 @@ export class McpServerValidator { ); return { valid: true, toolCount: 0, tools: [] }; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - - if ( - message.includes('TimeoutError') || - message.includes('AbortError') || - message.includes('abort') - ) { - return { - valid: false, - toolCount: 0, - tools: [], - error: 'Connection timed out', - }; - } + const message = getNetworkErrorMessage(url, error); this.logger.error(`MCP validation failed for ${url}: ${message}`); return { diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts index 90e5a2e2a5..b7d2d1b41d 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts @@ -400,7 +400,7 @@ describe('MCP server management endpoints', () => { expect(response.body.error).toContain('url and token are required'); }); - it('sends Bearer prefix when validating directly against MCP server', async () => { + it('sends raw token first when validating directly against MCP server', async () => { let capturedAuth = ''; server.use( http.post(MOCK_MCP_ADDR, ({ request: req }) => { @@ -422,7 +422,7 @@ describe('MCP server management endpoints', () => { .post('/api/lightspeed/mcp-servers/validate') .send({ url: MOCK_MCP_ADDR, token: 'my-raw-token' }); - expect(capturedAuth).toBe('Bearer my-raw-token'); + expect(capturedAuth).toBe('my-raw-token'); }); it('rejects unknown URL (SSRF protection)', async () => { diff --git a/workspaces/lightspeed/plugins/lightspeed/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index d10d6f5671..7f3d2b459f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -68,6 +68,7 @@ "@patternfly/chatbot": "6.5.0", "@patternfly/react-core": "6.4.1", "@patternfly/react-icons": "^6.3.1", + "@patternfly/react-table": "^6.4.1", "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^", "@red-hat-developer-hub/backstage-plugin-theme": "^0.12.0", "@tanstack/react-query": "^5.59.15", diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 08853f346c..03f14d7564 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -45,6 +45,7 @@ import { FileDropZone, MessageBar, MessageProps, + Settings, } from '@patternfly/chatbot'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; import { @@ -103,6 +104,7 @@ import { DeleteModal } from './DeleteModal'; import FilePreview from './FilePreview'; import { LightspeedChatBox } from './LightspeedChatBox'; import { LightspeedChatBoxHeader } from './LightspeedChatBoxHeader'; +import { McpServersSettings } from './McpServersSettings'; import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; import { NotebooksTab } from './notebooks/NotebooksTab'; import { RenameNotebookModal } from './notebooks/RenameNotebookModal'; @@ -331,6 +333,73 @@ const useStyles = makeStyles(theme => ({ flex: 1, minHeight: 0, }, + settingsFlat: { + height: '100%', + width: '100%', + backgroundColor: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + '&.pf-chatbot__settings-form-container': { + background: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + padding: 0, + margin: 0, + minHeight: '100%', + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: 'none', + }, + '& .pf-chatbot__settings-form': { + margin: 0, + padding: 0, + background: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + minHeight: '100%', + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: 'none', + }, + '& .pf-chatbot__settings-form-row': { + background: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + border: 0, + margin: 0, + padding: 0, + minHeight: '100%', + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: 'none', + }, + '& .pf-chatbot__settings-label': { + display: 'none', + }, + }, + mcpFullscreenLayout: { + display: 'flex', + minHeight: 0, + height: '100%', + flex: 1, + width: '100%', + }, + mcpChatPane: { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + flex: 1, + minWidth: 0, + }, + mcpSettingsPane: { + flex: 1, + minWidth: 0, + borderLeft: `1px solid ${theme.palette.divider}`, + backgroundColor: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + }, })); type LightspeedChatProps = { @@ -385,6 +454,7 @@ export const LightspeedChat = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isSortSelectOpen, setIsSortSelectOpen] = useState(false); + const [isMcpSettingsOpen, setIsMcpSettingsOpen] = useState(false); const contentScrollRef = useRef(null); const bottomSentinelRef = useRef(null); const [messageBarKey, setMessageBarKey] = useState(0); @@ -592,6 +662,7 @@ export const LightspeedChat = ({ const onNewChat = useCallback(() => { (async () => { + setIsMcpSettingsOpen(false); if (conversationId !== TEMP_CONVERSATION_ID) { setMessages([]); setFileContents([]); @@ -783,6 +854,7 @@ export const LightspeedChat = ({ const onSelectActiveItem = useCallback( (_: MouseEvent | undefined, selectedItem: string | number | undefined) => { + setIsMcpSettingsOpen(false); setNewChatCreated(false); const newConvId = String(selectedItem); setConversationId((c_id: string) => { @@ -804,6 +876,7 @@ export const LightspeedChat = ({ setDraftMessage, scrollToBottomRef, setCurrentConversationId, + setIsMcpSettingsOpen, ], ); @@ -1012,6 +1085,110 @@ export const LightspeedChat = ({ }); }; + const chatMainContent = ( + <> + +
+ {welcomePrompts.length > 0 && ( +
+ )} + + {welcomePrompts.length > 0 && ( +
+ )} +
+ + + + + + + + ); + + const mcpSettingsPanel = ( + setIsMcpSettingsOpen(false)} /> + ); + + const mainPanelContent = (() => { + if (!isMcpSettingsOpen) { + return <>{chatMainContent}; + } + + if (isFullscreenMode) { + return ( +
+
{chatMainContent}
+
{mcpSettingsPanel}
+
+ ); + } + + return ( + + ); + })(); + + let drawerPanelStyle: { [key: string]: string | number } | undefined; + if (!isFullscreenMode) { + drawerPanelStyle = { zIndex: 1300 }; + } else if (isMcpSettingsOpen) { + drawerPanelStyle = { width: 320, minWidth: 320, maxWidth: 320 }; + } + return ( <> {notebookAlerts.length > 0 && ( @@ -1101,6 +1278,7 @@ export const LightspeedChat = ({ { + setIsMcpSettingsOpen(false); onNewChat(); handleSelectedModel(item); }} @@ -1110,6 +1288,7 @@ export const LightspeedChat = ({ setDisplayMode={setDisplayMode} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} + onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} /> {isFullscreenMode && ( @@ -1131,7 +1310,7 @@ export const LightspeedChat = ({ drawerPanelContentProps={{ isResizable: isFullscreenMode, hasNoBorder: !isFullscreenMode, - style: isFullscreenMode ? undefined : { zIndex: 1300 }, + style: drawerPanelStyle, }} reverseButtonOrder displayMode={ChatbotDisplayMode.embedded} @@ -1192,74 +1371,7 @@ export const LightspeedChat = ({
)} - -
- {welcomePrompts.length > 0 && ( -
- )} - - {welcomePrompts.length > 0 && ( -
- )} -
- - - - - - + {mainPanelContent} } /> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 0ce0edf6cc..8dac49c2ea 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -40,6 +40,7 @@ import { } from '@patternfly/react-icons'; import { useTranslation } from '../hooks/useTranslation'; +import { McpSettingsIcon } from './McpSettingsIcon'; type LightspeedChatBoxHeaderProps = { displayMode: ChatbotDisplayMode; @@ -48,6 +49,7 @@ type LightspeedChatBoxHeaderProps = { models: { label: string; value: string; provider: string }[]; isPinningChatsEnabled: boolean; onPinnedChatsToggle: (state: boolean) => void; + onMcpSettingsClick: () => void; isModelSelectorDisabled?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; }; @@ -81,6 +83,7 @@ export const LightspeedChatBoxHeader = ({ models, isPinningChatsEnabled, onPinnedChatsToggle, + onMcpSettingsClick, isModelSelectorDisabled = false, setDisplayMode, }: LightspeedChatBoxHeaderProps) => { @@ -230,6 +233,14 @@ export const LightspeedChatBoxHeader = ({ {t('settings.pinned.enable')} )} + } + onClick={onMcpSettingsClick} + > + MCP settings + diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx new file mode 100644 index 0000000000..83fb1c7c1a --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -0,0 +1,609 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; + +import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; +import { usePermission } from '@backstage/plugin-permission-react'; + +import { makeStyles } from '@material-ui/core'; +import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; +import Typography from '@mui/material/Typography'; +import { Alert, Button, Switch, Title, Tooltip } from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationCircleIcon, + KeyIcon, + OffIcon, + SortAmountDownIcon, + SortAmountUpIcon, + TimesIcon, +} from '@patternfly/react-icons'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { lightspeedMcpManagePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; + +type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed' | 'unknown'; + +type McpServer = { + id: string; + name: string; + enabled: boolean; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; + validationError?: string; +}; + +type McpServersSettingsProps = { + onClose: () => void; +}; + +const useStyles = makeStyles(theme => ({ + root: { + padding: 0, + height: '100%', + minHeight: '100%', + width: '100%', + overflow: 'auto', + backgroundColor: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', + }, + headerRow: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + marginLeft: theme.spacing(3), + marginRight: theme.spacing(2), + }, + selectedCount: { + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.5), + fontSize: '0.75rem', + }, + title: { + fontSize: '1.125rem', + }, + closeButton: { + marginTop: -theme.spacing(1), + marginRight: -theme.spacing(1), + color: theme.palette.text.secondary, + }, + nameHeaderButton: { + paddingLeft: 0, + paddingTop: 0, + paddingBottom: 0, + marginLeft: '-0.85rem', + fontWeight: 600, + fontSize: '0.75rem', + lineHeight: '1.25rem', + minHeight: 'auto', + color: theme.palette.text.primary, + textDecoration: 'none !important', + display: 'inline-flex', + alignItems: 'center', + }, + nameHeaderText: { + paddingLeft: '7px', + fontSize: '0.75rem', + lineHeight: '1.25rem', + fontWeight: 600, + }, + nameCell: { + paddingLeft: '8px !important', + }, + statusHeader: { + paddingLeft: '0 !important', + }, + statusColumnCell: { + paddingLeft: '0 !important', + }, + rowName: { + fontSize: '1rem', + fontWeight: 500, + whiteSpace: 'nowrap', + }, + nameValue: { + fontSize: '0.875rem', + fontWeight: 500, + }, + statusCell: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + whiteSpace: 'nowrap', + }, + statusValue: { + fontSize: '0.875rem', + }, + statusOk: { + color: '#147878', + }, + statusToken: { + color: '#147878', + }, + statusWarn: { + color: '#B1380B', + }, + statusDisabled: { + color: theme.palette.text.secondary, + }, + actionButton: { + color: theme.palette.text.secondary, + }, + toggleCell: { + paddingRight: '0 !important', + }, + table: { + width: '100%', + '& th': { + borderBottom: 0, + fontSize: '0.75rem', + fontWeight: 600, + color: theme.palette.text.primary, + whiteSpace: 'nowrap', + textAlign: 'left', + }, + '& td': { + borderBottom: 0, + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1.5), + verticalAlign: 'middle', + }, + }, + alert: { + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + marginBottom: theme.spacing(2), + }, +})); + +type McpServerResponse = { + name: string; + enabled: boolean; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; +}; + +type McpServersListResponse = { + servers?: McpServerResponse[]; +}; + +type McpServersPatchResponse = { + server?: McpServerResponse; + validation?: { + error?: string; + }; +}; + +type McpServersValidateResponse = { + name: string; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + validation?: { + error?: string; + }; +}; + +const getStatusIcon = (status: ServerStatus, className: string) => { + if (status === 'tokenRequired') return ; + if (status === 'disabled') return ; + if (status === 'failed') + return ; + return ; +}; + +const getDisplayStatus = (server: McpServer): ServerStatus => { + if (!server.hasToken) return 'tokenRequired'; + if (!server.enabled) return 'disabled'; + if (server.status === 'error') return 'failed'; + if (server.status === 'connected') return 'ok'; + return 'unknown'; +}; + +const getDisplayDetail = ( + server: McpServer, + displayStatus: ServerStatus, +): string => { + if (displayStatus === 'disabled') return 'Disabled'; + if (displayStatus === 'tokenRequired') return 'Token required'; + if (displayStatus === 'failed') return 'Failed'; + if (displayStatus === 'ok') { + const suffix = server.toolCount === 1 ? 'tool' : 'tools'; + return `${server.toolCount} ${suffix}`; + } + return 'Unknown'; +}; + +const toUiServer = ( + server: McpServerResponse, + validationError?: string, +): McpServer => ({ + id: server.name, + name: server.name, + enabled: server.enabled, + status: server.status, + toolCount: server.toolCount, + hasToken: server.hasToken, + hasUserToken: server.hasUserToken, + validationError: server.status === 'error' ? validationError : undefined, +}); + +export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { + const classes = useStyles(); + const configApi = useApi(configApiRef); + const fetchApi = useApi(fetchApiRef); + const mcpManagePermission = usePermission({ + permission: lightspeedMcpManagePermission, + }); + const canManageMcp = mcpManagePermission.allowed; + const [servers, setServers] = useState([]); + const [sortAsc, setSortAsc] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState>({}); + const [error, setError] = useState(null); + + const getBaseUrl = useCallback(() => { + return `${configApi.getString('backend.baseUrl')}/api/lightspeed`; + }, [configApi]); + + const fetchJson = useCallback( + async (url: string, init?: RequestInit): Promise => { + const response = await fetchApi.fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + ...init, + }); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const bodyText = await response.text(); + if (bodyText) { + const parsed = JSON.parse(bodyText); + if (parsed?.error) { + message = parsed.error; + } + } + } catch { + // Keep default message when parsing fails. + } + throw new Error(message); + } + + const text = await response.text(); + return (text ? JSON.parse(text) : {}) as T; + }, + [fetchApi], + ); + + const validateServer = useCallback( + async (serverName: string) => { + const baseUrl = getBaseUrl(); + const data = await fetchJson( + `${baseUrl}/mcp-servers/${encodeURIComponent(serverName)}/validate`, + { + method: 'POST', + }, + ); + + setServers(prev => + prev.map(server => + server.name === serverName + ? { + ...server, + status: data.status, + toolCount: data.toolCount, + validationError: + data.status === 'error' + ? (data.validation?.error ?? 'Validation failed') + : undefined, + } + : server, + ), + ); + }, + [fetchJson, getBaseUrl], + ); + + const loadServers = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const baseUrl = getBaseUrl(); + const data = await fetchJson( + `${baseUrl}/mcp-servers`, + ); + const uiServers = (data.servers ?? []).map(server => toUiServer(server)); + setServers(uiServers); + + if (canManageMcp) { + const serversToValidate = uiServers.filter(server => server.hasToken); + void Promise.allSettled( + serversToValidate.map(async server => { + try { + await validateServer(server.name); + } catch (validationError) { + setError( + prev => + prev ?? + (validationError instanceof Error + ? validationError.message + : `Failed to validate ${server.name}`), + ); + } + }), + ); + } + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to load MCP server settings', + ); + } finally { + setIsLoading(false); + } + }, [canManageMcp, fetchJson, getBaseUrl, validateServer]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + + const patchServer = useCallback( + async ( + serverName: string, + body: { enabled?: boolean; token?: string | null }, + ) => { + if (!canManageMcp) { + return; + } + setError(null); + setIsSaving(prev => ({ ...prev, [serverName]: true })); + try { + const baseUrl = getBaseUrl(); + const data = await fetchJson( + `${baseUrl}/mcp-servers/${encodeURIComponent(serverName)}`, + { + method: 'PATCH', + body: JSON.stringify(body), + }, + ); + + if (data.server) { + setServers(prev => + prev.map(server => + server.name === serverName + ? toUiServer(data.server!, data.validation?.error) + : server, + ), + ); + } else { + await loadServers(); + } + } catch (e) { + setError( + e instanceof Error + ? e.message + : `Failed to update ${serverName} settings`, + ); + } finally { + setIsSaving(prev => ({ ...prev, [serverName]: false })); + } + }, + [canManageMcp, fetchJson, getBaseUrl, loadServers], + ); + + const selectedCount = useMemo( + () => + servers.filter(server => { + const displayStatus = getDisplayStatus(server); + const isUnavailable = + displayStatus === 'failed' || displayStatus === 'tokenRequired'; + return server.enabled && !isUnavailable; + }).length, + [servers], + ); + + const sortedServers = useMemo(() => { + const next = [...servers]; + next.sort((a, b) => + sortAsc ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name), + ); + return next; + }, [servers, sortAsc]); + + const onEditClick = useCallback((event: MouseEvent) => { + // Intentionally no-op in this branch; follow-up branch will wire edit flow. + event.preventDefault(); + }, []); + + return ( +
+
+
+ + MCP servers + +
+ {selectedCount} of {servers.length} selected +
+
+
+ {error && ( + + )} + {!mcpManagePermission.loading && !canManageMcp && ( + + )} + + + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && sortedServers.length === 0 && ( + + + + )} + {sortedServers.map(server => { + const displayStatus = getDisplayStatus(server); + const displayDetail = getDisplayDetail(server, displayStatus); + let statusClass = classes.statusWarn; + if (displayStatus === 'ok') { + statusClass = classes.statusOk; + } else if (displayStatus === 'tokenRequired') { + statusClass = classes.statusToken; + } else if (displayStatus === 'disabled') { + statusClass = classes.statusDisabled; + } + + return ( + + + + + + + ); + })} + +
+ + + Status +
Loading MCP servers...
No MCP servers available.
+ {(() => { + const isUnavailable = + displayStatus === 'failed' || + displayStatus === 'tokenRequired'; + const isChecked = isUnavailable ? false : server.enabled; + const isRowSaving = Boolean(isSaving[server.name]); + const isToggleDisabled = + isUnavailable || isRowSaving || !canManageMcp; + const switchControl = ( + { + patchServer(server.name, { enabled: checked }); + }} + /> + ); + + if (!isToggleDisabled) { + return switchControl; + } + + return ( + + + {switchControl} + + + ); + })()} + + + {server.name} + + +
+ {getStatusIcon(displayStatus, statusClass)} + {displayStatus === 'failed' ? ( + + + {displayDetail} + + + ) : ( + + {displayDetail} + + )} +
+
+
+
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpSettingsIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpSettingsIcon.tsx new file mode 100644 index 0000000000..ae80127128 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpSettingsIcon.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SvgIcon from '@mui/material/SvgIcon'; + +export const McpSettingsIcon = () => ( + + + + +); diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index a086430c1c..085a6691c6 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -8235,7 +8235,7 @@ __metadata: languageName: node linkType: hard -"@patternfly/react-core@npm:6.4.1, @patternfly/react-core@npm:^6.1.0": +"@patternfly/react-core@npm:6.4.1, @patternfly/react-core@npm:^6.1.0, @patternfly/react-core@npm:^6.4.1": version: 6.4.1 resolution: "@patternfly/react-core@npm:6.4.1" dependencies: @@ -8269,24 +8269,24 @@ __metadata: languageName: node linkType: hard -"@patternfly/react-table@npm:^6.1.0": - version: 6.1.0 - resolution: "@patternfly/react-table@npm:6.1.0" +"@patternfly/react-table@npm:^6.1.0, @patternfly/react-table@npm:^6.4.1": + version: 6.4.1 + resolution: "@patternfly/react-table@npm:6.4.1" dependencies: - "@patternfly/react-core": "npm:^6.1.0" - "@patternfly/react-icons": "npm:^6.1.0" - "@patternfly/react-styles": "npm:^6.1.0" - "@patternfly/react-tokens": "npm:^6.1.0" - lodash: "npm:^4.17.21" + "@patternfly/react-core": "npm:^6.4.1" + "@patternfly/react-icons": "npm:^6.4.0" + "@patternfly/react-styles": "npm:^6.4.0" + "@patternfly/react-tokens": "npm:^6.4.0" + lodash: "npm:^4.17.23" tslib: "npm:^2.8.1" peerDependencies: - react: ^17 || ^18 - react-dom: ^17 || ^18 - checksum: 10c0/ca09029a5b4973874991ce573f9ffaf4edfb04fd6183d0a3c70f3de2c1467a21bc4ae519c205ef4df85c3a8c47a898cef6e2d61160cb2ce475a948ac5ec67839 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + checksum: 10c0/9808a71211a70d7b5e6aead5eff49e06021bf621798e752e4c8490c1bc40a99292be877c8403fc89266aadd7299dcd3731b1354b806793b5d2e5a5db5e7d4706 languageName: node linkType: hard -"@patternfly/react-tokens@npm:^6.1.0, @patternfly/react-tokens@npm:^6.4.0": +"@patternfly/react-tokens@npm:^6.4.0": version: 6.4.0 resolution: "@patternfly/react-tokens@npm:6.4.0" checksum: 10c0/9b49ac8f1703de0e5b2b6d1154dbf83dbb40eea2850ce1f50ac173cd3d2cc9d4a1ca085c7c3db2a538aa6c137db186cbe783f1ab5985e843dfd455e9095f1a93 @@ -10906,6 +10906,7 @@ __metadata: "@patternfly/chatbot": "npm:6.5.0" "@patternfly/react-core": "npm:6.4.1" "@patternfly/react-icons": "npm:^6.3.1" + "@patternfly/react-table": "npm:^6.4.1" "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^" "@red-hat-developer-hub/backstage-plugin-theme": "npm:^0.12.0" "@spotify/prettier-config": "npm:^15.0.0" @@ -25246,7 +25247,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.21, lodash@npm:~4.17.23": +"lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.23, lodash@npm:^4.17.4, lodash@npm:~4.17.21, lodash@npm:~4.17.23": version: 4.17.23 resolution: "lodash@npm:4.17.23" checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6 @@ -29022,9 +29023,9 @@ __metadata: linkType: hard "picomatch@npm:^4.0.1, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard