From 44855b51ee14c6c7629fc05bc238d9a793309d85 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Sat, 21 Mar 2026 00:40:52 -0400 Subject: [PATCH 01/12] feat(lightspeed): add MCP servers settings panel --- .../plugins/lightspeed/package.json | 1 + .../src/components/LightSpeedChat.tsx | 248 ++++++++---- .../components/LightspeedChatBoxHeader.tsx | 30 ++ .../src/components/McpServersSettings.tsx | 359 ++++++++++++++++++ workspaces/lightspeed/yarn.lock | 206 ++-------- 5 files changed, 596 insertions(+), 248 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx diff --git a/workspaces/lightspeed/plugins/lightspeed/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index d10d6f56711..7f3d2b459f4 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 08853f346c8..49ca13acc83 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 { @@ -107,6 +108,7 @@ import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; import { NotebooksTab } from './notebooks/NotebooksTab'; import { RenameNotebookModal } from './notebooks/RenameNotebookModal'; import PermissionRequiredState from './PermissionRequiredState'; +import { McpServersSettings } from './McpServersSettings'; import { RenameConversationModal } from './RenameConversationModal'; const useStyles = makeStyles(theme => ({ @@ -331,6 +333,56 @@ const useStyles = makeStyles(theme => ({ flex: 1, minHeight: 0, }, + settingsFlat: { + height: '100%', + width: '100%', + '&.pf-chatbot__settings-form-container': { + background: 'transparent', + padding: 0, + margin: 0, + width: '100%', + maxWidth: 'none', + }, + '& .pf-chatbot__settings-form': { + margin: 0, + padding: 0, + width: '100%', + maxWidth: 'none', + }, + '& .pf-chatbot__settings-form-row': { + background: 'transparent', + border: 0, + margin: 0, + padding: 0, + 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}`, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + }, })); type LightspeedChatProps = { @@ -385,6 +437,8 @@ export const LightspeedChat = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isSortSelectOpen, setIsSortSelectOpen] = useState(false); + const [isMcpSettingsOpen, setIsMcpSettingsOpen] = useState(false); + const [chatHeaderBgColor, setChatHeaderBgColor] = useState(); const contentScrollRef = useRef(null); const bottomSentinelRef = useRef(null); const [messageBarKey, setMessageBarKey] = useState(0); @@ -436,6 +490,16 @@ export const LightspeedChat = ({ } }, [isMobile, isFullscreenMode]); + useEffect(() => { + if (typeof window === 'undefined') return; + const headerElement = document.querySelector('.pf-chatbot__header'); + if (!headerElement) return; + const computedBg = window.getComputedStyle(headerElement).backgroundColor; + if (computedBg) { + setChatHeaderBgColor(computedBg); + } + }, [displayMode, isMcpSettingsOpen]); + const { isPinningChatsEnabled, pinnedChats, @@ -592,6 +656,7 @@ export const LightspeedChat = ({ const onNewChat = useCallback(() => { (async () => { + setIsMcpSettingsOpen(false); if (conversationId !== TEMP_CONVERSATION_ID) { setMessages([]); setFileContents([]); @@ -783,6 +848,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 +870,7 @@ export const LightspeedChat = ({ setDraftMessage, scrollToBottomRef, setCurrentConversationId, + setIsMcpSettingsOpen, ], ); @@ -1012,6 +1079,114 @@ export const LightspeedChat = ({ }); }; + const chatMainContent = ( + <> + +
+ {welcomePrompts.length > 0 && ( +
+ )} + + {welcomePrompts.length > 0 && ( +
+ )} +
+ + + + + + + + ); + + let mainPanelContent = <>{chatMainContent}; + + if (isMcpSettingsOpen && isFullscreenMode) { + mainPanelContent = ( +
+
{chatMainContent}
+
+ setIsMcpSettingsOpen(false)} + backgroundColor={chatHeaderBgColor} + /> +
+
+ ); + } else if (isMcpSettingsOpen) { + mainPanelContent = ( + setIsMcpSettingsOpen(false)} + backgroundColor={chatHeaderBgColor} + /> + ), + }, + ]} + /> + ); + } + + 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 +1276,7 @@ export const LightspeedChat = ({ { + setIsMcpSettingsOpen(false); onNewChat(); handleSelectedModel(item); }} @@ -1110,6 +1286,7 @@ export const LightspeedChat = ({ setDisplayMode={setDisplayMode} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} + onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} /> {isFullscreenMode && ( @@ -1131,7 +1308,7 @@ export const LightspeedChat = ({ drawerPanelContentProps={{ isResizable: isFullscreenMode, hasNoBorder: !isFullscreenMode, - style: isFullscreenMode ? undefined : { zIndex: 1300 }, + style: drawerPanelStyle, }} reverseButtonOrder displayMode={ChatbotDisplayMode.embedded} @@ -1192,74 +1369,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 0ce0edf6cc8..9c89249597c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -20,6 +20,7 @@ import { createStyles, makeStyles } from '@material-ui/core'; import ToggleOffOutlinedIcon from '@mui/icons-material/ToggleOffOutlined'; import ToggleOnOutlinedIcon from '@mui/icons-material/ToggleOnOutlined'; import Divider from '@mui/material/Divider'; +import SvgIcon from '@mui/material/SvgIcon'; import { ChatbotDisplayMode, ChatbotHeaderActions, @@ -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,33 @@ 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 00000000000..f7d6b3dcf1e --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -0,0 +1,359 @@ +/* + * 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 { useMemo, useState } from 'react'; + +import { makeStyles } from '@material-ui/core'; +import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; +import Typography from '@mui/material/Typography'; +import { Button, Switch, Title } 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'; + +type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed'; + +type McpServer = { + id: string; + name: string; + enabled: boolean; + status: ServerStatus; + detail: string; +}; + +type McpServersSettingsProps = { + onClose: () => void; + backgroundColor?: string; +}; + +const useStyles = makeStyles(theme => ({ + root: { + padding: 0, + height: '100%', + width: '100%', + overflow: 'auto', + backgroundColor: theme.palette.action.disabled, + }, + 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, + 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', + }, + }, +})); + +const INITIAL_SERVERS: McpServer[] = [ + { + id: 'github', + name: 'Github', + enabled: false, + status: 'tokenRequired', + detail: 'Token required', + }, + { + id: 'dynatrace', + name: 'Dynatrace', + enabled: false, + status: 'disabled', + detail: 'Disabled', + }, + { + id: 'openshift', + name: 'Openshift', + enabled: true, + status: 'ok', + detail: '7 tools', + }, + { + id: 'kubernetes', + name: 'Kubernetes', + enabled: false, + status: 'failed', + detail: '5 tools', + }, + { + id: 'developerhub', + name: 'Developer Hub', + enabled: true, + status: 'ok', + detail: '5 tools', + }, + { + id: 'jenkins', + name: 'Jenkins', + enabled: false, + status: 'disabled', + detail: 'Disabled', + }, + { + id: 'servicenow', + name: 'Servicenow', + enabled: true, + status: 'ok', + detail: '3 tools', + }, + { + id: 'figma', + name: 'Figma', + enabled: false, + status: 'failed', + detail: 'Failed', + }, +]; + +const getStatusIcon = (status: ServerStatus, className: string) => { + if (status === 'tokenRequired') return ; + if (status === 'disabled') return ; + if (status === 'failed') + return ; + return ; +}; + +export const McpServersSettings = ({ + onClose, + backgroundColor, +}: McpServersSettingsProps) => { + const classes = useStyles(); + const [servers, setServers] = useState(INITIAL_SERVERS); + const [sortAsc, setSortAsc] = useState(true); + + const selectedCount = useMemo( + () => servers.filter(server => server.enabled).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]); + + return ( +
+
+
+ + MCP servers + +
+ {selectedCount} of {servers.length} selected +
+
+
+ + + + + + + + + + {sortedServers.map(server => { + let statusClass = classes.statusWarn; + if (server.status === 'ok') { + statusClass = classes.statusOk; + } else if (server.status === 'tokenRequired') { + statusClass = classes.statusToken; + } else if (server.status === 'disabled') { + statusClass = classes.statusDisabled; + } + + return ( + + + + + + + ); + })} + +
+ + + Status +
+ + setServers(prev => + prev.map(item => + item.id === server.id + ? { ...item, enabled: checked } + : item, + ), + ) + } + /> + + + {server.name} + + +
+ {getStatusIcon(server.status, statusClass)} + + {server.detail} + +
+
+
+
+ ); +}; diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index a086430c1cb..b00bd0ef3aa 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -5876,7 +5876,6 @@ __metadata: "@ianvs/prettier-plugin-sort-imports": "npm:^4.4.0" "@playwright/test": "npm:1.58.2" "@spotify/prettier-config": "npm:^12.0.0" - "@types/jest": "npm:^30.0.0" knip: "npm:^5.27.4" node-gyp: "npm:^9.0.0" prettier: "npm:3.8.1" @@ -6039,29 +6038,6 @@ __metadata: languageName: node linkType: hard -"@jest/diff-sequences@npm:30.3.0": - version: 30.3.0 - resolution: "@jest/diff-sequences@npm:30.3.0" - checksum: 10c0/8922c16a869b839b6c05f677023b3e5a9aa1610ad78a9c5ec8bd6654e35e8136ea1c7b60ad561910e2ad964bfdb0b09b0254ff8dcfacd4562095766f60c63d76 - languageName: node - linkType: hard - -"@jest/expect-utils@npm:30.3.0": - version: 30.3.0 - resolution: "@jest/expect-utils@npm:30.3.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - checksum: 10c0/4bb60fb434cb8ed325735bd39171b61621e110502ecc502089805d203ecb17b9fc5a400aeffb83b41fabcc819628a9c38c955f90a716d6aaff193d10926fc854 - languageName: node - linkType: hard - -"@jest/get-type@npm:30.1.0": - version: 30.1.0 - resolution: "@jest/get-type@npm:30.1.0" - checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac - languageName: node - linkType: hard - "@jest/pattern@npm:30.0.1": version: 30.0.1 resolution: "@jest/pattern@npm:30.0.1" @@ -6105,21 +6081,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:30.3.0": - version: 30.3.0 - resolution: "@jest/types@npm:30.3.0" - dependencies: - "@jest/pattern": "npm:30.0.1" - "@jest/schemas": "npm:30.0.5" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10c0/c3e3f4de0b77a7ced345f47d3687b1094c1b6c1521529a7ca66a76f9a80194f79179a1dbc32d6761a5b67914a8f78be1e65d1408107efcb1f252c4a63b5ddd92 - languageName: node - linkType: hard - "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -8235,7 +8196,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: @@ -8286,6 +8247,23 @@ __metadata: languageName: node linkType: hard +"@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.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 || ^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": version: 6.4.0 resolution: "@patternfly/react-tokens@npm:6.4.0" @@ -10906,6 +10884,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" @@ -14173,16 +14152,6 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^30.0.0": - version: 30.0.0 - resolution: "@types/jest@npm:30.0.0" - dependencies: - expect: "npm:^30.0.0" - pretty-format: "npm:^30.0.0" - checksum: 10c0/20c6ce574154bc16f8dd6a97afacca4b8c4921a819496a3970382031c509ebe87a1b37b152a1b8475089b82d8ca951a9e95beb4b9bf78fbf579b1536f0b65969 - languageName: node - linkType: hard - "@types/js-cookie@npm:^2.2.6": version: 2.2.7 resolution: "@types/js-cookie@npm:2.2.7" @@ -14606,13 +14575,6 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.3": - version: 2.0.3 - resolution: "@types/stack-utils@npm:2.0.3" - checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c - languageName: node - linkType: hard - "@types/statuses@npm:^2.0.6": version: 2.0.6 resolution: "@types/statuses@npm:2.0.6" @@ -15583,7 +15545,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -17288,13 +17250,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.4.0 - resolution: "ci-info@npm:4.4.0" - checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a - languageName: node - linkType: hard - "cidr-regex@npm:^3.1.1": version: 3.1.1 resolution: "cidr-regex@npm:3.1.1" @@ -19969,13 +19924,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^2.0.0": - version: 2.0.0 - resolution: "escape-string-regexp@npm:2.0.0" - checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 - languageName: node - linkType: hard - "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -20463,20 +20411,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^30.0.0": - version: 30.3.0 - resolution: "expect@npm:30.3.0" - dependencies: - "@jest/expect-utils": "npm:30.3.0" - "@jest/get-type": "npm:30.1.0" - jest-matcher-utils: "npm:30.3.0" - jest-message-util: "npm:30.3.0" - jest-mock: "npm:30.3.0" - jest-util: "npm:30.3.0" - checksum: 10c0/a07a157a0c8b3f1e29bfe5ccbf03a3add2c69fe60d1af8a0980053bb6403d721d5f5e4616f1ea5833b747913f8c880c79ce4d98c23a71a2f0c27cf7273892576 - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -21898,7 +21832,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -23939,58 +23873,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:30.3.0": - version: 30.3.0 - resolution: "jest-diff@npm:30.3.0" - dependencies: - "@jest/diff-sequences": "npm:30.3.0" - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - pretty-format: "npm:30.3.0" - checksum: 10c0/573a2a1a155b95fbde547d8ee33a5375179a8d03d4586025478dac16d695e4614aef075c3afa57e0f3a96cea8f638fa68a55c1e625f6e86b4f5b9e5850311ffb - languageName: node - linkType: hard - -"jest-matcher-utils@npm:30.3.0": - version: 30.3.0 - resolution: "jest-matcher-utils@npm:30.3.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - jest-diff: "npm:30.3.0" - pretty-format: "npm:30.3.0" - checksum: 10c0/4c5f4b6435964110e64c4b5b42e3553fffe303ecdd68021147a7bcc72914aec3a899867c50db22b250c72aded53e3f7a9f64d83c9dca2e65ce27f36d23c6ca78 - languageName: node - linkType: hard - -"jest-message-util@npm:30.3.0": - version: 30.3.0 - resolution: "jest-message-util@npm:30.3.0" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.3.0" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.3" - pretty-format: "npm:30.3.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/6ce611caef76394872b23a111286b48e56f42655d14a5fbd0629d9b7437ed892e85ad96b15864bc22185c24ef670afb6665c57b9729458a36d50ffe8310f0926 - languageName: node - linkType: hard - -"jest-mock@npm:30.3.0": - version: 30.3.0 - resolution: "jest-mock@npm:30.3.0" - dependencies: - "@jest/types": "npm:30.3.0" - "@types/node": "npm:*" - jest-util: "npm:30.3.0" - checksum: 10c0/9d95d550c6c998a85887c48ff5ee26de4bca18be91462ea8a8135d6023d591132465756f74981ca39b60f8708dfe38213a55bd4b619798a7b9438ca10d718099 - languageName: node - linkType: hard - "jest-regex-util@npm:30.0.1": version: 30.0.1 resolution: "jest-regex-util@npm:30.0.1" @@ -23998,20 +23880,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.3.0": - version: 30.3.0 - resolution: "jest-util@npm:30.3.0" - dependencies: - "@jest/types": "npm:30.3.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.3" - checksum: 10c0/eea6f39e52a8cb2b1a28bb315a90dc6a8e450fffed73bb5ef4489d02d86f7d91be600d83f1dcba22956b8ac5fefa8f1b250e636c8402d3e8b50a5eec8b5963b2 - languageName: node - linkType: hard - "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -25246,7 +25114,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 @@ -29021,10 +28889,10 @@ __metadata: languageName: node 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 +"picomatch@npm:^4.0.1, picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc languageName: node linkType: hard @@ -29755,17 +29623,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.3.0, pretty-format@npm:^30.0.0": - version: 30.3.0 - resolution: "pretty-format@npm:30.3.0" - dependencies: - "@jest/schemas": "npm:30.0.5" - ansi-styles: "npm:^5.2.0" - react-is: "npm:^18.3.1" - checksum: 10c0/719b27d70cd8b01013485054c5d094e1fe85e093b09ee73553e3b19302da3cf54fbd6a7ea9577d6471aeff8d372200e56979ffc4c831e2133520bd18060895fb - languageName: node - linkType: hard - "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -30633,7 +30490,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": +"react-is@npm:^18.0.0, react-is@npm:^18.2.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 @@ -32924,15 +32781,6 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.6": - version: 2.0.6 - resolution: "stack-utils@npm:2.0.6" - dependencies: - escape-string-regexp: "npm:^2.0.0" - checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a - languageName: node - linkType: hard - "stackframe@npm:^1.3.4": version: 1.3.4 resolution: "stackframe@npm:1.3.4" From e8092dad948c2a05f3a4a1a5780eca0ed98bfae4 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 24 Mar 2026 23:03:04 -0400 Subject: [PATCH 02/12] improved UI Signed-off-by: Yi Cai --- .../src/components/McpServersSettings.tsx | 112 +++++++--- workspaces/lightspeed/yarn.lock | 207 +++++++++++++++--- 2 files changed, 261 insertions(+), 58 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index f7d6b3dcf1e..e9a280415b8 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -19,7 +19,7 @@ import { useMemo, useState } from 'react'; import { makeStyles } from '@material-ui/core'; import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; import Typography from '@mui/material/Typography'; -import { Button, Switch, Title } from '@patternfly/react-core'; +import { Button, Switch, Title, Tooltip } from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon, @@ -31,7 +31,7 @@ import { } from '@patternfly/react-icons'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed'; +type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed' | 'unknown'; type McpServer = { id: string; @@ -39,6 +39,7 @@ type McpServer = { enabled: boolean; status: ServerStatus; detail: string; + errorMessage?: string; }; type McpServersSettingsProps = { @@ -80,7 +81,7 @@ const useStyles = makeStyles(theme => ({ paddingLeft: 0, paddingTop: 0, paddingBottom: 0, - marginLeft: 0, + marginLeft: '-0.85rem', fontWeight: 600, fontSize: '0.75rem', lineHeight: '1.25rem', @@ -164,7 +165,7 @@ const INITIAL_SERVERS: McpServer[] = [ { id: 'github', name: 'Github', - enabled: false, + enabled: true, status: 'tokenRequired', detail: 'Token required', }, @@ -185,9 +186,11 @@ const INITIAL_SERVERS: McpServer[] = [ { id: 'kubernetes', name: 'Kubernetes', - enabled: false, + enabled: true, status: 'failed', - detail: '5 tools', + detail: '4 tools', + errorMessage: + 'Token authentication failed, click edit to configure it again', }, { id: 'developerhub', @@ -213,7 +216,7 @@ const INITIAL_SERVERS: McpServer[] = [ { id: 'figma', name: 'Figma', - enabled: false, + enabled: true, status: 'failed', detail: 'Failed', }, @@ -227,6 +230,24 @@ const getStatusIcon = (status: ServerStatus, className: string) => { return ; }; +const getDisplayStatus = (server: McpServer): ServerStatus => { + if (!server.enabled) return 'disabled'; + if (server.status === 'tokenRequired') return 'tokenRequired'; + if (server.status === 'failed') return 'failed'; + if (server.status === 'ok') 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 server.detail || 'Failed'; + return server.detail; +}; + export const McpServersSettings = ({ onClose, backgroundColor, @@ -295,32 +316,50 @@ export const McpServersSettings = ({ {sortedServers.map(server => { + const displayStatus = getDisplayStatus(server); + const displayDetail = getDisplayDetail(server, displayStatus); let statusClass = classes.statusWarn; - if (server.status === 'ok') { + if (displayStatus === 'ok') { statusClass = classes.statusOk; - } else if (server.status === 'tokenRequired') { + } else if (displayStatus === 'tokenRequired') { statusClass = classes.statusToken; - } else if (server.status === 'disabled') { + } else if (displayStatus === 'disabled') { statusClass = classes.statusDisabled; } return ( - - setServers(prev => - prev.map(item => - item.id === server.id - ? { ...item, enabled: checked } - : item, - ), - ) - } - /> + {(() => { + const isUnavailable = + server.status === 'failed' || + server.status === 'tokenRequired'; + const isChecked = isUnavailable ? false : server.enabled; + + return ( + + setServers(prev => + prev.map(item => + item.id === server.id + ? { + ...item, + enabled: checked, + ...(checked && item.status === 'disabled' + ? { status: 'ok', detail: '5 tools' } + : {}), + } + : item, + ), + ) + } + /> + ); + })()}
- {getStatusIcon(server.status, statusClass)} - - {server.detail} - + {getStatusIcon(displayStatus, statusClass)} + {displayStatus === 'failed' && server.errorMessage ? ( + + + {displayDetail} + + + ) : ( + + {displayDetail} + + )}
diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index b00bd0ef3aa..8529afe6267 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -5876,6 +5876,7 @@ __metadata: "@ianvs/prettier-plugin-sort-imports": "npm:^4.4.0" "@playwright/test": "npm:1.58.2" "@spotify/prettier-config": "npm:^12.0.0" + "@types/jest": "npm:^30.0.0" knip: "npm:^5.27.4" node-gyp: "npm:^9.0.0" prettier: "npm:3.8.1" @@ -6038,6 +6039,29 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.3.0": + version: 30.3.0 + resolution: "@jest/diff-sequences@npm:30.3.0" + checksum: 10c0/8922c16a869b839b6c05f677023b3e5a9aa1610ad78a9c5ec8bd6654e35e8136ea1c7b60ad561910e2ad964bfdb0b09b0254ff8dcfacd4562095766f60c63d76 + languageName: node + linkType: hard + +"@jest/expect-utils@npm:30.3.0": + version: 30.3.0 + resolution: "@jest/expect-utils@npm:30.3.0" + dependencies: + "@jest/get-type": "npm:30.1.0" + checksum: 10c0/4bb60fb434cb8ed325735bd39171b61621e110502ecc502089805d203ecb17b9fc5a400aeffb83b41fabcc819628a9c38c955f90a716d6aaff193d10926fc854 + languageName: node + linkType: hard + +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac + languageName: node + linkType: hard + "@jest/pattern@npm:30.0.1": version: 30.0.1 resolution: "@jest/pattern@npm:30.0.1" @@ -6081,6 +6105,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.3.0": + version: 30.3.0 + resolution: "@jest/types@npm:30.3.0" + dependencies: + "@jest/pattern": "npm:30.0.1" + "@jest/schemas": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10c0/c3e3f4de0b77a7ced345f47d3687b1094c1b6c1521529a7ca66a76f9a80194f79179a1dbc32d6761a5b67914a8f78be1e65d1408107efcb1f252c4a63b5ddd92 + languageName: node + linkType: hard + "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -8230,24 +8269,7 @@ __metadata: languageName: node linkType: hard -"@patternfly/react-table@npm:^6.1.0": - version: 6.1.0 - resolution: "@patternfly/react-table@npm:6.1.0" - 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" - tslib: "npm:^2.8.1" - peerDependencies: - react: ^17 || ^18 - react-dom: ^17 || ^18 - checksum: 10c0/ca09029a5b4973874991ce573f9ffaf4edfb04fd6183d0a3c70f3de2c1467a21bc4ae519c205ef4df85c3a8c47a898cef6e2d61160cb2ce475a948ac5ec67839 - languageName: node - linkType: hard - -"@patternfly/react-table@npm:^6.4.1": +"@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: @@ -8264,7 +8286,7 @@ __metadata: 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 @@ -14152,6 +14174,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^30.0.0": + version: 30.0.0 + resolution: "@types/jest@npm:30.0.0" + dependencies: + expect: "npm:^30.0.0" + pretty-format: "npm:^30.0.0" + checksum: 10c0/20c6ce574154bc16f8dd6a97afacca4b8c4921a819496a3970382031c509ebe87a1b37b152a1b8475089b82d8ca951a9e95beb4b9bf78fbf579b1536f0b65969 + languageName: node + linkType: hard + "@types/js-cookie@npm:^2.2.6": version: 2.2.7 resolution: "@types/js-cookie@npm:2.2.7" @@ -14575,6 +14607,13 @@ __metadata: languageName: node linkType: hard +"@types/stack-utils@npm:^2.0.3": + version: 2.0.3 + resolution: "@types/stack-utils@npm:2.0.3" + checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c + languageName: node + linkType: hard + "@types/statuses@npm:^2.0.6": version: 2.0.6 resolution: "@types/statuses@npm:2.0.6" @@ -15545,7 +15584,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -17250,6 +17289,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^4.2.0": + version: 4.4.0 + resolution: "ci-info@npm:4.4.0" + checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a + languageName: node + linkType: hard + "cidr-regex@npm:^3.1.1": version: 3.1.1 resolution: "cidr-regex@npm:3.1.1" @@ -19924,6 +19970,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -20411,6 +20464,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:^30.0.0": + version: 30.3.0 + resolution: "expect@npm:30.3.0" + dependencies: + "@jest/expect-utils": "npm:30.3.0" + "@jest/get-type": "npm:30.1.0" + jest-matcher-utils: "npm:30.3.0" + jest-message-util: "npm:30.3.0" + jest-mock: "npm:30.3.0" + jest-util: "npm:30.3.0" + checksum: 10c0/a07a157a0c8b3f1e29bfe5ccbf03a3add2c69fe60d1af8a0980053bb6403d721d5f5e4616f1ea5833b747913f8c880c79ce4d98c23a71a2f0c27cf7273892576 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -21832,7 +21899,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -23873,6 +23940,58 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.3.0": + version: 30.3.0 + resolution: "jest-diff@npm:30.3.0" + dependencies: + "@jest/diff-sequences": "npm:30.3.0" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.3.0" + checksum: 10c0/573a2a1a155b95fbde547d8ee33a5375179a8d03d4586025478dac16d695e4614aef075c3afa57e0f3a96cea8f638fa68a55c1e625f6e86b4f5b9e5850311ffb + languageName: node + linkType: hard + +"jest-matcher-utils@npm:30.3.0": + version: 30.3.0 + resolution: "jest-matcher-utils@npm:30.3.0" + dependencies: + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.3.0" + pretty-format: "npm:30.3.0" + checksum: 10c0/4c5f4b6435964110e64c4b5b42e3553fffe303ecdd68021147a7bcc72914aec3a899867c50db22b250c72aded53e3f7a9f64d83c9dca2e65ce27f36d23c6ca78 + languageName: node + linkType: hard + +"jest-message-util@npm:30.3.0": + version: 30.3.0 + resolution: "jest-message-util@npm:30.3.0" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.3.0" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.3.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10c0/6ce611caef76394872b23a111286b48e56f42655d14a5fbd0629d9b7437ed892e85ad96b15864bc22185c24ef670afb6665c57b9729458a36d50ffe8310f0926 + languageName: node + linkType: hard + +"jest-mock@npm:30.3.0": + version: 30.3.0 + resolution: "jest-mock@npm:30.3.0" + dependencies: + "@jest/types": "npm:30.3.0" + "@types/node": "npm:*" + jest-util: "npm:30.3.0" + checksum: 10c0/9d95d550c6c998a85887c48ff5ee26de4bca18be91462ea8a8135d6023d591132465756f74981ca39b60f8708dfe38213a55bd4b619798a7b9438ca10d718099 + languageName: node + linkType: hard + "jest-regex-util@npm:30.0.1": version: 30.0.1 resolution: "jest-regex-util@npm:30.0.1" @@ -23880,6 +23999,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.3.0": + version: 30.3.0 + resolution: "jest-util@npm:30.3.0" + dependencies: + "@jest/types": "npm:30.3.0" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10c0/eea6f39e52a8cb2b1a28bb315a90dc6a8e450fffed73bb5ef4489d02d86f7d91be600d83f1dcba22956b8ac5fefa8f1b250e636c8402d3e8b50a5eec8b5963b2 + languageName: node + linkType: hard + "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -25114,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.23, 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.15, lodash@npm:~4.17.21, lodash@npm:~4.17.23": version: 4.17.23 resolution: "lodash@npm:4.17.23" checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6 @@ -28889,10 +29022,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.1, picomatch@npm:^4.0.2": - version: 4.0.2 - resolution: "picomatch@npm:4.0.2" - checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc +"picomatch@npm:^4.0.1, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard @@ -29623,6 +29756,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.3.0, pretty-format@npm:^30.0.0": + version: 30.3.0 + resolution: "pretty-format@npm:30.3.0" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10c0/719b27d70cd8b01013485054c5d094e1fe85e093b09ee73553e3b19302da3cf54fbd6a7ea9577d6471aeff8d372200e56979ffc4c831e2133520bd18060895fb + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -30490,7 +30634,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.2.0": +"react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 @@ -32781,6 +32925,15 @@ __metadata: languageName: node linkType: hard +"stack-utils@npm:^2.0.6": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a + languageName: node + linkType: hard + "stackframe@npm:^1.3.4": version: 1.3.4 resolution: "stackframe@npm:1.3.4" From 34f60c566fdf72bdc35b4f7585ba525ed013c297 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 30 Mar 2026 15:18:43 -0400 Subject: [PATCH 03/12] backend integration Signed-off-by: Yi Cai --- .../src/components/LightSpeedChat.tsx | 42 +-- .../src/components/McpServersSettings.tsx | 275 +++++++++++------- 2 files changed, 198 insertions(+), 119 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 49ca13acc83..17ceac4b9c6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -336,24 +336,39 @@ const useStyles = makeStyles(theme => ({ 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: 'transparent', + 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: 'transparent', + 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', }, @@ -379,6 +394,8 @@ const useStyles = makeStyles(theme => ({ 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, @@ -438,7 +455,6 @@ export const LightspeedChat = ({ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isSortSelectOpen, setIsSortSelectOpen] = useState(false); const [isMcpSettingsOpen, setIsMcpSettingsOpen] = useState(false); - const [chatHeaderBgColor, setChatHeaderBgColor] = useState(); const contentScrollRef = useRef(null); const bottomSentinelRef = useRef(null); const [messageBarKey, setMessageBarKey] = useState(0); @@ -490,16 +506,6 @@ export const LightspeedChat = ({ } }, [isMobile, isFullscreenMode]); - useEffect(() => { - if (typeof window === 'undefined') return; - const headerElement = document.querySelector('.pf-chatbot__header'); - if (!headerElement) return; - const computedBg = window.getComputedStyle(headerElement).backgroundColor; - if (computedBg) { - setChatHeaderBgColor(computedBg); - } - }, [displayMode, isMcpSettingsOpen]); - const { isPinningChatsEnabled, pinnedChats, @@ -1153,10 +1159,7 @@ export const LightspeedChat = ({
{chatMainContent}
- setIsMcpSettingsOpen(false)} - backgroundColor={chatHeaderBgColor} - /> + setIsMcpSettingsOpen(false)} />
); @@ -1169,10 +1172,7 @@ export const LightspeedChat = ({ id: 'mcp-servers-settings', label: '', field: ( - setIsMcpSettingsOpen(false)} - backgroundColor={chatHeaderBgColor} - /> + setIsMcpSettingsOpen(false)} /> ), }, ]} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index e9a280415b8..b041d0e3c58 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ -import { useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; import { makeStyles } from '@material-ui/core'; import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; import Typography from '@mui/material/Typography'; -import { Button, Switch, Title, Tooltip } from '@patternfly/react-core'; +import { Alert, Button, Switch, Title, Tooltip } from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon, @@ -37,23 +39,25 @@ type McpServer = { id: string; name: string; enabled: boolean; - status: ServerStatus; - detail: string; - errorMessage?: string; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; }; type McpServersSettingsProps = { onClose: () => void; - backgroundColor?: string; }; const useStyles = makeStyles(theme => ({ root: { padding: 0, height: '100%', + minHeight: '100%', width: '100%', overflow: 'auto', - backgroundColor: theme.palette.action.disabled, + backgroundColor: + 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', }, headerRow: { display: 'flex', @@ -159,68 +163,29 @@ const useStyles = makeStyles(theme => ({ verticalAlign: 'middle', }, }, + alert: { + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + marginBottom: theme.spacing(2), + }, })); -const INITIAL_SERVERS: McpServer[] = [ - { - id: 'github', - name: 'Github', - enabled: true, - status: 'tokenRequired', - detail: 'Token required', - }, - { - id: 'dynatrace', - name: 'Dynatrace', - enabled: false, - status: 'disabled', - detail: 'Disabled', - }, - { - id: 'openshift', - name: 'Openshift', - enabled: true, - status: 'ok', - detail: '7 tools', - }, - { - id: 'kubernetes', - name: 'Kubernetes', - enabled: true, - status: 'failed', - detail: '4 tools', - errorMessage: - 'Token authentication failed, click edit to configure it again', - }, - { - id: 'developerhub', - name: 'Developer Hub', - enabled: true, - status: 'ok', - detail: '5 tools', - }, - { - id: 'jenkins', - name: 'Jenkins', - enabled: false, - status: 'disabled', - detail: 'Disabled', - }, - { - id: 'servicenow', - name: 'Servicenow', - enabled: true, - status: 'ok', - detail: '3 tools', - }, - { - id: 'figma', - name: 'Figma', - enabled: true, - status: 'failed', - detail: 'Failed', - }, -]; +type McpServerResponse = { + name: string; + enabled: boolean; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; +}; + +type McpServersListResponse = { + servers?: McpServerResponse[]; +}; + +type McpServersPatchResponse = { + server?: McpServerResponse; +}; const getStatusIcon = (status: ServerStatus, className: string) => { if (status === 'tokenRequired') return ; @@ -232,9 +197,9 @@ const getStatusIcon = (status: ServerStatus, className: string) => { const getDisplayStatus = (server: McpServer): ServerStatus => { if (!server.enabled) return 'disabled'; - if (server.status === 'tokenRequired') return 'tokenRequired'; - if (server.status === 'failed') return 'failed'; - if (server.status === 'ok') return 'ok'; + if (!server.hasToken) return 'tokenRequired'; + if (server.status === 'error') return 'failed'; + if (server.status === 'connected') return 'ok'; return 'unknown'; }; @@ -244,17 +209,128 @@ const getDisplayDetail = ( ): string => { if (displayStatus === 'disabled') return 'Disabled'; if (displayStatus === 'tokenRequired') return 'Token required'; - if (displayStatus === 'failed') return server.detail || 'Failed'; - return server.detail; + if (displayStatus === 'failed') return 'Failed'; + if (displayStatus === 'ok') { + const suffix = server.toolCount === 1 ? 'tool' : 'tools'; + return `${server.toolCount} ${suffix}`; + } + return 'Unknown'; }; -export const McpServersSettings = ({ - onClose, - backgroundColor, -}: McpServersSettingsProps) => { +const toUiServer = (server: McpServerResponse): McpServer => ({ + id: server.name, + name: server.name, + enabled: server.enabled, + status: server.status, + toolCount: server.toolCount, + hasToken: server.hasToken, + hasUserToken: server.hasUserToken, +}); + +export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { const classes = useStyles(); - const [servers, setServers] = useState(INITIAL_SERVERS); + const configApi = useApi(configApiRef); + const fetchApi = useApi(fetchApiRef); + 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 loadServers = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const baseUrl = getBaseUrl(); + const data = await fetchJson( + `${baseUrl}/mcp-servers`, + ); + setServers((data.servers ?? []).map(toUiServer)); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to load MCP server settings', + ); + } finally { + setIsLoading(false); + } + }, [fetchJson, getBaseUrl]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + + const patchServer = useCallback( + async ( + serverName: string, + body: { enabled?: boolean; token?: string | null }, + ) => { + 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!) : server, + ), + ); + } else { + await loadServers(); + } + } catch (e) { + setError( + e instanceof Error + ? e.message + : `Failed to update ${serverName} settings`, + ); + } finally { + setIsSaving(prev => ({ ...prev, [serverName]: false })); + } + }, + [fetchJson, getBaseUrl, loadServers], + ); const selectedCount = useMemo( () => servers.filter(server => server.enabled).length, @@ -270,7 +346,7 @@ export const McpServersSettings = ({ }, [servers, sortAsc]); return ( -
+
@@ -288,6 +364,14 @@ export const McpServersSettings = ({ onClick={onClose} /> </div> + {error && ( + <Alert + variant="danger" + isInline + title={error} + className={classes.alert} + /> + )} <Table variant="compact" @@ -315,6 +399,11 @@ export const McpServersSettings = ({ </Tr> </Thead> <Tbody> + {isLoading && ( + <Tr> + <Td colSpan={4}>Loading MCP servers...</Td> + </Tr> + )} {sortedServers.map(server => { const displayStatus = getDisplayStatus(server); const displayDetail = getDisplayDetail(server, displayStatus); @@ -332,31 +421,20 @@ export const McpServersSettings = ({ <Td width={10} className={classes.toggleCell}> {(() => { const isUnavailable = - server.status === 'failed' || - server.status === 'tokenRequired'; + displayStatus === 'failed' || + displayStatus === 'tokenRequired'; const isChecked = isUnavailable ? false : server.enabled; + const isRowSaving = Boolean(isSaving[server.name]); return ( <Switch id={`mcp-switch-${server.id}`} aria-label={`Toggle ${server.name}`} isChecked={isChecked} - isDisabled={isUnavailable} - onChange={(_event, checked) => - setServers(prev => - prev.map(item => - item.id === server.id - ? { - ...item, - enabled: checked, - ...(checked && item.status === 'disabled' - ? { status: 'ok', detail: '5 tools' } - : {}), - } - : item, - ), - ) - } + isDisabled={isUnavailable || isRowSaving} + onChange={(_event, checked) => { + patchServer(server.name, { enabled: checked }); + }} /> ); })()} @@ -372,8 +450,8 @@ export const McpServersSettings = ({ <Td width={40} className={classes.statusColumnCell}> <div className={classes.statusCell}> {getStatusIcon(displayStatus, statusClass)} - {displayStatus === 'failed' && server.errorMessage ? ( - <Tooltip content={server.errorMessage}> + {displayStatus === 'failed' ? ( + <Tooltip content="Token authentication failed, click edit to configure it again"> <Typography component="span" className={classes.statusValue} @@ -397,6 +475,7 @@ export const McpServersSettings = ({ icon={<ModeEditOutlineOutlinedIcon fontSize="small" />} variant="plain" className={classes.actionButton} + isDisabled /> </Td> </Tr> From c685a1b8822ca72c7cae380801867c28582baf98 Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Mon, 30 Mar 2026 15:28:24 -0400 Subject: [PATCH 04/12] code improvement Signed-off-by: Yi Cai <yicai@redhat.com> --- .../src/components/LightSpeedChat.tsx | 36 ++++++++++--------- .../src/components/McpServersSettings.tsx | 9 +++-- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 17ceac4b9c6..fbde54d12fa 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -1152,33 +1152,37 @@ export const LightspeedChat = ({ </> ); - let mainPanelContent = <>{chatMainContent}</>; - - if (isMcpSettingsOpen && isFullscreenMode) { - mainPanelContent = ( - <div className={classes.mcpFullscreenLayout}> - <div className={classes.mcpChatPane}>{chatMainContent}</div> - <div className={classes.mcpSettingsPane}> - <McpServersSettings onClose={() => setIsMcpSettingsOpen(false)} /> + const mcpSettingsPanel = ( + <McpServersSettings onClose={() => setIsMcpSettingsOpen(false)} /> + ); + + const mainPanelContent = (() => { + if (!isMcpSettingsOpen) { + return <>{chatMainContent}</>; + } + + if (isFullscreenMode) { + return ( + <div className={classes.mcpFullscreenLayout}> + <div className={classes.mcpChatPane}>{chatMainContent}</div> + <div className={classes.mcpSettingsPane}>{mcpSettingsPanel}</div> </div> - </div> - ); - } else if (isMcpSettingsOpen) { - mainPanelContent = ( + ); + } + + return ( <Settings className={classes.settingsFlat} fields={[ { id: 'mcp-servers-settings', label: '', - field: ( - <McpServersSettings onClose={() => setIsMcpSettingsOpen(false)} /> - ), + field: mcpSettingsPanel, }, ]} /> ); - } + })(); let drawerPanelStyle: { [key: string]: string | number } | undefined; if (!isFullscreenMode) { diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index b041d0e3c58..04f935429e2 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; @@ -345,6 +345,11 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { 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 ( <div className={classes.root}> <div className={classes.headerRow}> @@ -475,7 +480,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { icon={<ModeEditOutlineOutlinedIcon fontSize="small" />} variant="plain" className={classes.actionButton} - isDisabled + onClick={onEditClick} /> </Td> </Tr> From 3627613b6eba04ac9e96f37f4608947d6ec7ec29 Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Tue, 31 Mar 2026 14:38:45 -0400 Subject: [PATCH 05/12] error messages improvements Signed-off-by: Yi Cai <yicai@redhat.com> --- .../src/service/mcp-server-validator.ts | 99 +++++++++++++++---- .../src/components/McpServersSettings.tsx | 89 +++++++++++++++-- 2 files changed, 163 insertions(+), 25 deletions(-) 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 bca6b27ce78..b0762c3c649 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 @@ -20,6 +20,80 @@ import { McpValidationResult } from './mcp-server-types'; const REQUEST_TIMEOUT_MS = 10_000; +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 +106,13 @@ export class McpServerValidator { constructor(private readonly logger: LoggerService) {} async validate(url: string, token: string): Promise<McpValidationResult> { - // 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 authorizationHeader = /^Bearer\s+/i.test(trimmedToken) + ? trimmedToken + : `Bearer ${trimmedToken}`; const headers: Record<string, string> = { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: authorizationHeader, Accept: 'application/json, text/event-stream', }; @@ -150,20 +224,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/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index 04f935429e2..74b7fc0b791 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -43,6 +43,7 @@ type McpServer = { toolCount: number; hasToken: boolean; hasUserToken: boolean; + validationError?: string; }; type McpServersSettingsProps = { @@ -185,6 +186,18 @@ type McpServersListResponse = { 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) => { @@ -217,7 +230,10 @@ const getDisplayDetail = ( return 'Unknown'; }; -const toUiServer = (server: McpServerResponse): McpServer => ({ +const toUiServer = ( + server: McpServerResponse, + validationError?: string, +): McpServer => ({ id: server.name, name: server.name, enabled: server.enabled, @@ -225,6 +241,7 @@ const toUiServer = (server: McpServerResponse): McpServer => ({ toolCount: server.toolCount, hasToken: server.hasToken, hasUserToken: server.hasUserToken, + validationError: server.status === 'error' ? validationError : undefined, }); export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { @@ -271,6 +288,35 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { [fetchApi], ); + const validateServer = useCallback( + async (serverName: string) => { + const baseUrl = getBaseUrl(); + const data = await fetchJson<McpServersValidateResponse>( + `${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); @@ -279,7 +325,25 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { const data = await fetchJson<McpServersListResponse>( `${baseUrl}/mcp-servers`, ); - setServers((data.servers ?? []).map(toUiServer)); + const uiServers = (data.servers ?? []).map(server => toUiServer(server)); + setServers(uiServers); + + 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', @@ -287,7 +351,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { } finally { setIsLoading(false); } - }, [fetchJson, getBaseUrl]); + }, [fetchJson, getBaseUrl, validateServer]); useEffect(() => { loadServers(); @@ -313,7 +377,9 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { if (data.server) { setServers(prev => prev.map(server => - server.name === serverName ? toUiServer(data.server!) : server, + server.name === serverName + ? toUiServer(data.server!, data.validation?.error) + : server, ), ); } else { @@ -333,7 +399,13 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { ); const selectedCount = useMemo( - () => servers.filter(server => server.enabled).length, + () => + servers.filter(server => { + const displayStatus = getDisplayStatus(server); + const isUnavailable = + displayStatus === 'failed' || displayStatus === 'tokenRequired'; + return server.enabled && !isUnavailable; + }).length, [servers], ); @@ -456,7 +528,12 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { <div className={classes.statusCell}> {getStatusIcon(displayStatus, statusClass)} {displayStatus === 'failed' ? ( - <Tooltip content="Token authentication failed, click edit to configure it again"> + <Tooltip + content={ + server.validationError ?? + 'Validation failed. Check server URL and token.' + } + > <Typography component="span" className={classes.statusValue} From 46116e5f950af08ed1f260312d5713384e3b5248 Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Tue, 31 Mar 2026 15:16:17 -0400 Subject: [PATCH 06/12] fixed failed test Signed-off-by: Yi Cai <yicai@redhat.com> --- .../plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts index 5e6810451ce..0e1500090a3 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 }); } From bed98ca9c7fbc555cd02adb88f3bd9f3cba1a7c3 Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Tue, 31 Mar 2026 15:30:46 -0400 Subject: [PATCH 07/12] resovled qoto comments Signed-off-by: Yi Cai <yicai@redhat.com> --- .../src/service/mcp-server-validator.test.ts | 99 +++++++++++++++++++ .../src/service/mcp-server-validator.ts | 48 ++++++++- .../src/components/McpServersSettings.tsx | 68 +++++++++---- 3 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts 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 00000000000..042da736a11 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts @@ -0,0 +1,99 @@ +/* + * 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<typeof McpServerValidator>[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 falls back to Bearer on 401/403', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + result: { capabilities: { tools: {} } }, + id: 1, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + result: { tools: [{ name: 'tool-1' }] }, + id: 2, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + global.fetch = fetchMock; + + const validator = new McpServerValidator(logger); + const result = await validator.validate(url, 'raw-token'); + + expect(result.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'raw-token', + }); + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer raw-token', + }); + }); + + it('does not rewrite tokens that already include 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 b0762c3c649..4c0e09a6248 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,8 @@ 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 { @@ -107,9 +109,47 @@ export class McpServerValidator { async validate(url: string, token: string): Promise<McpValidationResult> { const trimmedToken = token.trim(); - const authorizationHeader = /^Bearer\s+/i.test(trimmedToken) - ? trimmedToken - : `Bearer ${trimmedToken}`; + 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<McpValidationResult> { const headers: Record<string, string> = { 'Content-Type': 'application/json', Authorization: authorizationHeader, @@ -139,7 +179,7 @@ export class McpServerValidator { valid: false, toolCount: 0, tools: [], - error: 'Invalid credentials — server returned 401/403', + error: INVALID_CREDENTIALS_ERROR, }; } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index 74b7fc0b791..46057362982 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -17,6 +17,7 @@ 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'; @@ -33,6 +34,8 @@ import { } 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 = { @@ -209,8 +212,8 @@ const getStatusIcon = (status: ServerStatus, className: string) => { }; const getDisplayStatus = (server: McpServer): ServerStatus => { - if (!server.enabled) return 'disabled'; if (!server.hasToken) return 'tokenRequired'; + if (!server.enabled) return 'disabled'; if (server.status === 'error') return 'failed'; if (server.status === 'connected') return 'ok'; return 'unknown'; @@ -248,6 +251,10 @@ 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<McpServer[]>([]); const [sortAsc, setSortAsc] = useState(true); const [isLoading, setIsLoading] = useState(true); @@ -328,22 +335,24 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { const uiServers = (data.servers ?? []).map(server => toUiServer(server)); setServers(uiServers); - 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}`), - ); - } - }), - ); + 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', @@ -351,7 +360,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { } finally { setIsLoading(false); } - }, [fetchJson, getBaseUrl, validateServer]); + }, [canManageMcp, fetchJson, getBaseUrl, validateServer]); useEffect(() => { loadServers(); @@ -362,6 +371,9 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { serverName: string, body: { enabled?: boolean; token?: string | null }, ) => { + if (!canManageMcp) { + return; + } setError(null); setIsSaving(prev => ({ ...prev, [serverName]: true })); try { @@ -395,7 +407,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { setIsSaving(prev => ({ ...prev, [serverName]: false })); } }, - [fetchJson, getBaseUrl, loadServers], + [canManageMcp, fetchJson, getBaseUrl, loadServers], ); const selectedCount = useMemo( @@ -449,6 +461,14 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { className={classes.alert} /> )} + {!mcpManagePermission.loading && !canManageMcp && ( + <Alert + variant="info" + isInline + title="You have read-only access to MCP servers." + className={classes.alert} + /> + )} <Table variant="compact" @@ -481,6 +501,11 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { <Td colSpan={4}>Loading MCP servers...</Td> </Tr> )} + {!isLoading && sortedServers.length === 0 && ( + <Tr> + <Td colSpan={4}>No MCP servers available.</Td> + </Tr> + )} {sortedServers.map(server => { const displayStatus = getDisplayStatus(server); const displayDetail = getDisplayDetail(server, displayStatus); @@ -508,7 +533,9 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { id={`mcp-switch-${server.id}`} aria-label={`Toggle ${server.name}`} isChecked={isChecked} - isDisabled={isUnavailable || isRowSaving} + isDisabled={ + isUnavailable || isRowSaving || !canManageMcp + } onChange={(_event, checked) => { patchServer(server.name, { enabled: checked }); }} @@ -557,6 +584,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { icon={<ModeEditOutlineOutlinedIcon fontSize="small" />} variant="plain" className={classes.actionButton} + isDisabled={!canManageMcp} onClick={onEditClick} /> </Td> From 8154707e72b7013043d24e24ad94d0d30df786ae Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Wed, 1 Apr 2026 12:49:24 -0400 Subject: [PATCH 08/12] resolved failed e2e tests Signed-off-by: Yi Cai <yicai@redhat.com> --- workspaces/lightspeed/yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index 8529afe6267..085a6691c6f 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -25247,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.23, lodash@npm:^4.17.4, lodash@npm:~4.17.15, 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 From 89b0bbfab18688df77f59c0eab59850ef1dceb4c Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Wed, 1 Apr 2026 14:36:01 -0400 Subject: [PATCH 09/12] fixed failed ci check Signed-off-by: Yi Cai <yicai@redhat.com> --- .../plugins/lightspeed/src/components/LightSpeedChat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index fbde54d12fa..1fcd25d251d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -104,11 +104,11 @@ 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'; import PermissionRequiredState from './PermissionRequiredState'; -import { McpServersSettings } from './McpServersSettings'; import { RenameConversationModal } from './RenameConversationModal'; const useStyles = makeStyles(theme => ({ From 05b4026600bbdf01acff816b725e5c4d2a5a1bcd Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Thu, 2 Apr 2026 23:32:45 -0400 Subject: [PATCH 10/12] addressed review points Signed-off-by: Yi Cai <yicai@redhat.com> --- .../src/service/mcp-server-validator.test.ts | 47 +++++-------------- .../components/LightspeedChatBoxHeader.tsx | 23 +-------- .../src/components/McpServersSettings.tsx | 21 +++++++-- .../src/components/McpSettingsIcon.tsx | 34 ++++++++++++++ 4 files changed, 63 insertions(+), 62 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/McpSettingsIcon.tsx 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 index 042da736a11..411e577422b 100644 --- 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 @@ -35,53 +35,28 @@ describe('McpServerValidator auth header behavior', () => { jest.clearAllMocks(); }); - it('tries raw token first, then falls back to Bearer on 401/403', async () => { + it('always prepends Bearer and returns invalid credentials on 401/403', async () => { const fetchMock = jest .fn() - .mockResolvedValueOnce(new Response(null, { status: 401 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - jsonrpc: '2.0', - result: { capabilities: { tools: {} } }, - id: 1, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - jsonrpc: '2.0', - result: { tools: [{ name: 'tool-1' }] }, - id: 2, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); + .mockResolvedValue(new Response(null, { status: 401 })); global.fetch = fetchMock; const validator = new McpServerValidator(logger); const result = await validator.validate(url, 'raw-token'); - expect(result.valid).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ - Authorization: 'raw-token', + expect(result).toMatchObject({ + valid: false, + toolCount: 0, + tools: [], + error: 'Invalid credentials — server returned 401/403', }); - expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Bearer raw-token', }); }); - it('does not rewrite tokens that already include an auth scheme', async () => { + it('still prepends Bearer when token already has an auth scheme', async () => { const fetchMock = jest .fn() .mockResolvedValue(new Response(null, { status: 401 })); @@ -93,7 +68,7 @@ describe('McpServerValidator auth header behavior', () => { expect(result.valid).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ - Authorization: 'Basic abc123', + Authorization: 'Bearer Basic abc123', }); }); }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 9c89249597c..8dac49c2ea9 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -20,7 +20,6 @@ import { createStyles, makeStyles } from '@material-ui/core'; import ToggleOffOutlinedIcon from '@mui/icons-material/ToggleOffOutlined'; import ToggleOnOutlinedIcon from '@mui/icons-material/ToggleOnOutlined'; import Divider from '@mui/material/Divider'; -import SvgIcon from '@mui/material/SvgIcon'; import { ChatbotDisplayMode, ChatbotHeaderActions, @@ -41,6 +40,7 @@ import { } from '@patternfly/react-icons'; import { useTranslation } from '../hooks/useTranslation'; +import { McpSettingsIcon } from './McpSettingsIcon'; type LightspeedChatBoxHeaderProps = { displayMode: ChatbotDisplayMode; @@ -236,26 +236,7 @@ export const LightspeedChatBoxHeader = ({ <DropdownItem value="mcpSettings" key="mcpSettings" - icon={ - <SvgIcon - sx={{ marginTop: '8px' }} - viewBox="0 0 12 12" - fontSize="small" - > - <path - fillRule="evenodd" - clipRule="evenodd" - d="M7.84399 1.17149C7.6024 0.936371 7.2786 0.804813 6.94149 0.804813C6.60437 0.804813 6.28057 0.936371 6.03899 1.17149L1.22599 5.89149C1.14544 5.96977 1.03755 6.01357 0.925236 6.01357C0.812918 6.01357 0.705027 5.96977 0.624485 5.89149C0.585054 5.85314 0.55371 5.80728 0.532308 5.75661C0.510905 5.70594 0.499878 5.65149 0.499878 5.59649C0.499878 5.54149 0.510905 5.48704 0.532308 5.43637C0.55371 5.3857 0.585054 5.33984 0.624485 5.30149L5.43749 0.581489C5.84009 0.189686 6.3797 -0.029541 6.94149 -0.029541C7.50327 -0.029541 8.04288 0.189686 8.44549 0.581489C8.67844 0.808041 8.85444 1.08654 8.95908 1.39418C9.06371 1.70183 9.09401 2.02988 9.04749 2.35149C9.37336 2.30514 9.70553 2.33423 10.0184 2.43653C10.3312 2.53882 10.6164 2.71158 10.852 2.94149L10.877 2.96649C11.0741 3.15823 11.2309 3.38755 11.3379 3.6409C11.4449 3.89424 11.5 4.16647 11.5 4.44149C11.5 4.71651 11.4449 4.98874 11.3379 5.24208C11.2309 5.49543 11.0741 5.72475 10.877 5.91649L6.52399 10.185C6.51085 10.1978 6.5004 10.213 6.49327 10.2299C6.48614 10.2468 6.48246 10.2649 6.48246 10.2832C6.48246 10.3016 6.48614 10.3197 6.49327 10.3366C6.5004 10.3534 6.51085 10.3687 6.52399 10.3815L7.41799 11.2585C7.45742 11.2968 7.48876 11.3427 7.51016 11.3934C7.53157 11.444 7.54259 11.4985 7.54259 11.5535C7.54259 11.6085 7.53157 11.6629 7.51016 11.7136C7.48876 11.7643 7.45742 11.8101 7.41799 11.8485C7.33744 11.9268 7.22955 11.9706 7.11724 11.9706C7.00492 11.9706 6.89703 11.9268 6.81649 11.8485L5.92249 10.972C5.83041 10.8825 5.75721 10.7755 5.70723 10.6572C5.65724 10.539 5.63149 10.4119 5.63149 10.2835C5.63149 10.1551 5.65724 10.028 5.70723 9.90975C5.75721 9.79149 5.83041 9.68446 5.92249 9.59499L10.2755 5.32599C10.3937 5.21091 10.4877 5.07331 10.5519 4.9213C10.616 4.7693 10.6491 4.60598 10.6491 4.44099C10.6491 4.276 10.616 4.11268 10.5519 3.96068C10.4877 3.80867 10.3937 3.67107 10.2755 3.55599L10.2505 3.53149C10.0092 3.29662 9.68579 3.16507 9.34904 3.16479C9.01229 3.16451 8.6887 3.29552 8.44699 3.52999L4.86099 7.04699L4.85999 7.04799L4.81099 7.09649C4.73042 7.17492 4.62242 7.21881 4.50999 7.21881C4.39755 7.21881 4.28955 7.17492 4.20899 7.09649C4.16955 7.05814 4.13821 7.01228 4.11681 6.96161C4.09541 6.91094 4.08438 6.85649 4.08438 6.80149C4.08438 6.74649 4.09541 6.69204 4.11681 6.64137C4.13821 6.5907 4.16955 6.54484 4.20899 6.50649L7.84549 2.93999C7.96339 2.82483 8.05706 2.68724 8.12096 2.53532C8.18486 2.38341 8.21771 2.22023 8.21757 2.05542C8.21743 1.89061 8.18431 1.7275 8.12014 1.57569C8.05598 1.42388 7.96209 1.28645 7.84399 1.17149Z" - fill="currentColor" - /> - <path - fillRule="evenodd" - clipRule="evenodd" - d="M7.24248 2.35148C7.28192 2.31313 7.31326 2.26727 7.33466 2.2166C7.35606 2.16593 7.36709 2.11149 7.36709 2.05648C7.36709 2.00148 7.35606 1.94703 7.33466 1.89636C7.31326 1.84569 7.28192 1.79983 7.24248 1.76148C7.16192 1.68305 7.05392 1.63916 6.94148 1.63916C6.82905 1.63916 6.72105 1.68305 6.64048 1.76148L3.08098 5.25248C2.88383 5.44422 2.72711 5.67354 2.6201 5.92689C2.51308 6.18023 2.45795 6.45246 2.45795 6.72748C2.45795 7.0025 2.51308 7.27473 2.6201 7.52808C2.72711 7.78142 2.88383 8.01074 3.08098 8.20248C3.48365 8.59417 4.02324 8.81331 4.58498 8.81331C5.14673 8.81331 5.68632 8.59417 6.08898 8.20248L9.64898 4.71148C9.68842 4.67313 9.71976 4.62727 9.74116 4.5766C9.76256 4.52593 9.77359 4.47149 9.77359 4.41648C9.77359 4.36148 9.76256 4.30703 9.74116 4.25636C9.71976 4.20569 9.68842 4.15983 9.64898 4.12148C9.56842 4.04305 9.46042 3.99916 9.34798 3.99916C9.23555 3.99916 9.12755 4.04305 9.04698 4.12148L5.48748 7.61248C5.2459 7.8476 4.9221 7.97916 4.58498 7.97916C4.24787 7.97916 3.92407 7.8476 3.68248 7.61248C3.56425 7.4974 3.47028 7.3598 3.40611 7.2078C3.34194 7.05579 3.30888 6.89247 3.30888 6.72748C3.30888 6.56249 3.34194 6.39917 3.40611 6.24717C3.47028 6.09517 3.56425 5.95756 3.68248 5.84248L7.24248 2.35148Z" - fill="currentColor" - /> - </SvgIcon> - } + icon={<McpSettingsIcon />} onClick={onMcpSettingsClick} > MCP settings diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index 46057362982..83fb1c7c1a0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -527,20 +527,31 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { displayStatus === 'tokenRequired'; const isChecked = isUnavailable ? false : server.enabled; const isRowSaving = Boolean(isSaving[server.name]); - - return ( + const isToggleDisabled = + isUnavailable || isRowSaving || !canManageMcp; + const switchControl = ( <Switch id={`mcp-switch-${server.id}`} aria-label={`Toggle ${server.name}`} isChecked={isChecked} - isDisabled={ - isUnavailable || isRowSaving || !canManageMcp - } + isDisabled={isToggleDisabled} onChange={(_event, checked) => { patchServer(server.name, { enabled: checked }); }} /> ); + + if (!isToggleDisabled) { + return switchControl; + } + + return ( + <Tooltip content={displayDetail}> + <Typography component="span"> + {switchControl} + </Typography> + </Tooltip> + ); })()} </Td> <Td 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 00000000000..ae80127128a --- /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 = () => ( + <SvgIcon sx={{ marginTop: '8px' }} viewBox="0 0 12 12" fontSize="small"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.84399 1.17149C7.6024 0.936371 7.2786 0.804813 6.94149 0.804813C6.60437 0.804813 6.28057 0.936371 6.03899 1.17149L1.22599 5.89149C1.14544 5.96977 1.03755 6.01357 0.925236 6.01357C0.812918 6.01357 0.705027 5.96977 0.624485 5.89149C0.585054 5.85314 0.55371 5.80728 0.532308 5.75661C0.510905 5.70594 0.499878 5.65149 0.499878 5.59649C0.499878 5.54149 0.510905 5.48704 0.532308 5.43637C0.55371 5.3857 0.585054 5.33984 0.624485 5.30149L5.43749 0.581489C5.84009 0.189686 6.3797 -0.029541 6.94149 -0.029541C7.50327 -0.029541 8.04288 0.189686 8.44549 0.581489C8.67844 0.808041 8.85444 1.08654 8.95908 1.39418C9.06371 1.70183 9.09401 2.02988 9.04749 2.35149C9.37336 2.30514 9.70553 2.33423 10.0184 2.43653C10.3312 2.53882 10.6164 2.71158 10.852 2.94149L10.877 2.96649C11.0741 3.15823 11.2309 3.38755 11.3379 3.6409C11.4449 3.89424 11.5 4.16647 11.5 4.44149C11.5 4.71651 11.4449 4.98874 11.3379 5.24208C11.2309 5.49543 11.0741 5.72475 10.877 5.91649L6.52399 10.185C6.51085 10.1978 6.5004 10.213 6.49327 10.2299C6.48614 10.2468 6.48246 10.2649 6.48246 10.2832C6.48246 10.3016 6.48614 10.3197 6.49327 10.3366C6.5004 10.3534 6.51085 10.3687 6.52399 10.3815L7.41799 11.2585C7.45742 11.2968 7.48876 11.3427 7.51016 11.3934C7.53157 11.444 7.54259 11.4985 7.54259 11.5535C7.54259 11.6085 7.53157 11.6629 7.51016 11.7136C7.48876 11.7643 7.45742 11.8101 7.41799 11.8485C7.33744 11.9268 7.22955 11.9706 7.11724 11.9706C7.00492 11.9706 6.89703 11.9268 6.81649 11.8485L5.92249 10.972C5.83041 10.8825 5.75721 10.7755 5.70723 10.6572C5.65724 10.539 5.63149 10.4119 5.63149 10.2835C5.63149 10.1551 5.65724 10.028 5.70723 9.90975C5.75721 9.79149 5.83041 9.68446 5.92249 9.59499L10.2755 5.32599C10.3937 5.21091 10.4877 5.07331 10.5519 4.9213C10.616 4.7693 10.6491 4.60598 10.6491 4.44099C10.6491 4.276 10.616 4.11268 10.5519 3.96068C10.4877 3.80867 10.3937 3.67107 10.2755 3.55599L10.2505 3.53149C10.0092 3.29662 9.68579 3.16507 9.34904 3.16479C9.01229 3.16451 8.6887 3.29552 8.44699 3.52999L4.86099 7.04699L4.85999 7.04799L4.81099 7.09649C4.73042 7.17492 4.62242 7.21881 4.50999 7.21881C4.39755 7.21881 4.28955 7.17492 4.20899 7.09649C4.16955 7.05814 4.13821 7.01228 4.11681 6.96161C4.09541 6.91094 4.08438 6.85649 4.08438 6.80149C4.08438 6.74649 4.09541 6.69204 4.11681 6.64137C4.13821 6.5907 4.16955 6.54484 4.20899 6.50649L7.84549 2.93999C7.96339 2.82483 8.05706 2.68724 8.12096 2.53532C8.18486 2.38341 8.21771 2.22023 8.21757 2.05542C8.21743 1.89061 8.18431 1.7275 8.12014 1.57569C8.05598 1.42388 7.96209 1.28645 7.84399 1.17149Z" + fill="currentColor" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.24248 2.35148C7.28192 2.31313 7.31326 2.26727 7.33466 2.2166C7.35606 2.16593 7.36709 2.11149 7.36709 2.05648C7.36709 2.00148 7.35606 1.94703 7.33466 1.89636C7.31326 1.84569 7.28192 1.79983 7.24248 1.76148C7.16192 1.68305 7.05392 1.63916 6.94148 1.63916C6.82905 1.63916 6.72105 1.68305 6.64048 1.76148L3.08098 5.25248C2.88383 5.44422 2.72711 5.67354 2.6201 5.92689C2.51308 6.18023 2.45795 6.45246 2.45795 6.72748C2.45795 7.0025 2.51308 7.27473 2.6201 7.52808C2.72711 7.78142 2.88383 8.01074 3.08098 8.20248C3.48365 8.59417 4.02324 8.81331 4.58498 8.81331C5.14673 8.81331 5.68632 8.59417 6.08898 8.20248L9.64898 4.71148C9.68842 4.67313 9.71976 4.62727 9.74116 4.5766C9.76256 4.52593 9.77359 4.47149 9.77359 4.41648C9.77359 4.36148 9.76256 4.30703 9.74116 4.25636C9.71976 4.20569 9.68842 4.15983 9.64898 4.12148C9.56842 4.04305 9.46042 3.99916 9.34798 3.99916C9.23555 3.99916 9.12755 4.04305 9.04698 4.12148L5.48748 7.61248C5.2459 7.8476 4.9221 7.97916 4.58498 7.97916C4.24787 7.97916 3.92407 7.8476 3.68248 7.61248C3.56425 7.4974 3.47028 7.3598 3.40611 7.2078C3.34194 7.05579 3.30888 6.89247 3.30888 6.72748C3.30888 6.56249 3.34194 6.39917 3.40611 6.24717C3.47028 6.09517 3.56425 5.95756 3.68248 5.84248L7.24248 2.35148Z" + fill="currentColor" + /> + </SvgIcon> +); From df80fe53d86ae4f8fd656c65f0d1ba3413a7cd28 Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Thu, 2 Apr 2026 23:34:43 -0400 Subject: [PATCH 11/12] prettier fix Signed-off-by: Yi Cai <yicai@redhat.com> --- .../plugins/lightspeed/src/components/LightSpeedChat.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 1fcd25d251d..03f14d75649 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -1125,9 +1125,7 @@ export const LightspeedChat = ({ value={draftMessage} onChange={handleDraftMessage} hasStopButton={isSendButtonDisabled} - handleStopButton={ - isSendButtonDisabled ? handleStopButton : undefined - } + handleStopButton={isSendButtonDisabled ? handleStopButton : undefined} buttonProps={{ attach: { inputTestId: 'attachment-input', From 98a7a232433129827f3c3774c7e27037d8fc350c Mon Sep 17 00:00:00 2001 From: Yi Cai <yicai@redhat.com> Date: Fri, 3 Apr 2026 11:56:38 -0400 Subject: [PATCH 12/12] fixed failed tests Signed-off-by: Yi Cai <yicai@redhat.com> --- .../src/service/mcp-server-validator.test.ts | 11 +++++++---- .../lightspeed-backend/src/service/mcp-server.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) 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 index 411e577422b..b996408f810 100644 --- 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 @@ -35,7 +35,7 @@ describe('McpServerValidator auth header behavior', () => { jest.clearAllMocks(); }); - it('always prepends Bearer and returns invalid credentials on 401/403', async () => { + it('tries raw token first, then Bearer on 401/403', async () => { const fetchMock = jest .fn() .mockResolvedValue(new Response(null, { status: 401 })); @@ -50,13 +50,16 @@ describe('McpServerValidator auth header behavior', () => { tools: [], error: 'Invalid credentials — server returned 401/403', }); - expect(fetchMock).toHaveBeenCalledTimes(1); + 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('still prepends Bearer when token already has an auth scheme', async () => { + it('uses token as-is when it already has an auth scheme', async () => { const fetchMock = jest .fn() .mockResolvedValue(new Response(null, { status: 401 })); @@ -68,7 +71,7 @@ describe('McpServerValidator auth header behavior', () => { expect(result.valid).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ - Authorization: 'Bearer Basic abc123', + Authorization: 'Basic abc123', }); }); }); 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 90e5a2e2a53..b7d2d1b41d8 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 () => {