From 9925df6a171ad3f8cb3f761c2d1a1655c96fd5fc Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 6 Jul 2026 22:22:17 -0400 Subject: [PATCH 1/4] fix(lightspeed-mcp): refactored mcp config modal Signed-off-by: Yi Cai --- .../mcp-configure-modal-enhancements.md | 5 + .../lightspeed/packages/app/knip-report.md | 31 +- .../packages/backend/knip-report.md | 3 +- .../plugins/lightspeed/knip-report.md | 15 - .../plugins/lightspeed/package.json | 5 - .../src/components/McpServersSettings.tsx | 849 ++++++++++++------ .../__tests__/McpServersSettings.test.tsx | 312 +++++++ .../__tests__/mcpServersDisplayUtils.test.ts | 205 +++++ .../src/components/mcpServersDisplayUtils.ts | 92 ++ .../plugins/lightspeed/src/translations/de.ts | 3 +- .../plugins/lightspeed/src/translations/es.ts | 3 +- .../plugins/lightspeed/src/translations/fr.ts | 3 +- .../plugins/lightspeed/src/translations/it.ts | 3 +- .../plugins/lightspeed/src/translations/ja.ts | 2 +- .../lightspeed/src/translations/ref.ts | 16 +- workspaces/lightspeed/yarn.lock | 176 +--- 16 files changed, 1218 insertions(+), 505 deletions(-) create mode 100644 workspaces/lightspeed/.changeset/mcp-configure-modal-enhancements.md create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/mcpServersDisplayUtils.test.ts create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/mcpServersDisplayUtils.ts diff --git a/workspaces/lightspeed/.changeset/mcp-configure-modal-enhancements.md b/workspaces/lightspeed/.changeset/mcp-configure-modal-enhancements.md new file mode 100644 index 0000000000..fdfad4a8fc --- /dev/null +++ b/workspaces/lightspeed/.changeset/mcp-configure-modal-enhancements.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +--- + +Enhance the MCP server configure modal with PatternFly layout, server status and tools list, aligned enabled toggle behavior, personal token save/remove flows (including disconnecting state and warnings), auto-enable on successful token validation, and unit tests. diff --git a/workspaces/lightspeed/packages/app/knip-report.md b/workspaces/lightspeed/packages/app/knip-report.md index b9f559fca6..069b1c27bb 100644 --- a/workspaces/lightspeed/packages/app/knip-report.md +++ b/workspaces/lightspeed/packages/app/knip-report.md @@ -1,23 +1,24 @@ # Knip report -## Unused dependencies (7) +## Unused dependencies (8) -| Name | Location | Severity | -| :------------------------------ | :---------------- | :------- | -| @backstage/plugin-user-settings | package.json:33:6 | error | -| @backstage/integration-react | package.json:28:6 | error | -| @backstage/plugin-scaffolder | package.json:31:6 | error | -| @backstage/core-compat-api | package.json:23:6 | error | -| @backstage/plugin-catalog | package.json:30:6 | error | -| @backstage/plugin-search | package.json:32:6 | error | -| react-router | package.json:42:6 | error | +| Name | Location | Severity | +| :------------------------------- | :---------------- | :------- | +| @backstage-community/plugin-rbac | package.json:22:6 | error | +| @backstage/plugin-user-settings | package.json:35:6 | error | +| @backstage/integration-react | package.json:29:6 | error | +| @backstage/plugin-scaffolder | package.json:33:6 | error | +| @backstage/core-compat-api | package.json:24:6 | error | +| @backstage/plugin-catalog | package.json:32:6 | error | +| @backstage/plugin-search | package.json:34:6 | error | +| react-router | package.json:44:6 | error | ## Unused devDependencies (5) | Name | Location | Severity | | :----------------------------- | :---------------- | :------- | -| @backstage/frontend-test-utils | package.json:46:6 | error | -| @testing-library/user-event | package.json:51:6 | error | -| @testing-library/dom | package.json:48:6 | error | -| @playwright/test | package.json:47:6 | error | -| cross-env | package.json:53:6 | error | +| @backstage/frontend-test-utils | package.json:48:6 | error | +| @testing-library/user-event | package.json:53:6 | error | +| @testing-library/dom | package.json:50:6 | error | +| @playwright/test | package.json:49:6 | error | +| cross-env | package.json:55:6 | error | diff --git a/workspaces/lightspeed/packages/backend/knip-report.md b/workspaces/lightspeed/packages/backend/knip-report.md index 3a0daf286c..9a5b33dae1 100644 --- a/workspaces/lightspeed/packages/backend/knip-report.md +++ b/workspaces/lightspeed/packages/backend/knip-report.md @@ -1,12 +1,11 @@ # Knip report -## Unused dependencies (10) +## Unused dependencies (9) | Name | Location | Severity | | :----------------------------------------------------------- | :---------------- | :------- | | @backstage/plugin-permission-backend-module-allow-all-policy | package.json:37:6 | error | | @backstage/plugin-search-backend-node | package.json:46:6 | error | -| @backstage/plugin-permission-backend | package.json:36:6 | error | | @backstage/plugin-permission-common | package.json:38:6 | error | | @backstage/plugin-permission-node | package.json:39:6 | error | | @backstage/plugin-auth-node | package.json:31:6 | error | diff --git a/workspaces/lightspeed/plugins/lightspeed/knip-report.md b/workspaces/lightspeed/plugins/lightspeed/knip-report.md index 209aadc0f6..28921c328d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/knip-report.md +++ b/workspaces/lightspeed/plugins/lightspeed/knip-report.md @@ -1,16 +1 @@ # Knip report - -## Unused dependencies (2) - -| Name | Location | Severity | -| :--------------- | :---------------- | :------- | -| @material-ui/lab | package.json:63:6 | error | -| react-markdown | package.json:78:6 | error | - -## Unused devDependencies (3) - -| Name | Location | Severity | -| :---------------------- | :---------------- | :------- | -| @backstage/core-app-api | package.json:87:6 | error | -| @emotion/is-prop-valid | package.json:90:6 | error | -| msw | package.json:97:6 | error | diff --git a/workspaces/lightspeed/plugins/lightspeed/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index 3a3517da10..8438c9c7ab 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -72,7 +72,6 @@ "@backstage/plugin-permission-react": "^0.4.41", "@backstage/theme": "^0.7.2", "@material-ui/core": "^4.9.13", - "@material-ui/lab": "^4.0.0-alpha.61", "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^6.1.8", "@mui/material": "^5.12.2", @@ -87,7 +86,6 @@ "@tanstack/react-query": "^5.59.15", "monaco-editor": "^0.55.0", "react-dropzone": "^14.4.0", - "react-markdown": "^9.0.1", "react-use": "^17.2.4" }, "peerDependencies": { @@ -96,17 +94,14 @@ }, "devDependencies": { "@backstage/cli": "^0.36.0", - "@backstage/core-app-api": "^1.19.6", "@backstage/dev-utils": "^1.1.21", "@backstage/test-utils": "^1.7.16", - "@emotion/is-prop-valid": "^1.3.1", "@ianvs/prettier-plugin-sort-imports": "^4.4.0", "@spotify/prettier-config": "^15.0.0", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^15.0.0", "@testing-library/user-event": "14.6.1", - "msw": "1.3.5", "prettier": "3.8.4", "react": "16.13.1 || ^17.0.0 || ^18.0.0", "react-dom": "16.13.1 || ^17.0.0 || ^18.0.0", diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index d3cdc442cc..4aeae288b7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -14,23 +14,35 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useState, + type FormEvent, +} 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 CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; -import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; -import IconButton from '@mui/material/IconButton'; -import InputAdornment from '@mui/material/InputAdornment'; -import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { Alert, Button, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, Modal, + ModalBody, + ModalFooter, + ModalHeader, + Spinner, Switch, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, Title, Tooltip, } from '@patternfly/react-core'; @@ -39,7 +51,6 @@ import { ExclamationCircleIcon, InfoCircleIcon, KeyIcon, - LockIcon, PencilAltIcon, SortAmountDownIcon, SortAmountUpIcon, @@ -50,8 +61,15 @@ import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { lightspeedMcpManagePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; import { useTranslation } from '../hooks/useTranslation'; - -type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed' | 'unknown'; +import { + formatApiError, + getDisplayStatus, + getEnabledToggleChecked, + isModalEnabledChecked as getModalEnabledChecked, + isSaveTokenDisabled as getSaveTokenDisabled, + isEnabledToggleUnavailable, + type ServerStatus, +} from './mcpServersDisplayUtils'; type McpServer = { id: string; @@ -183,141 +201,119 @@ const useStyles = makeStyles(theme => ({ opacity: 1, }, }, - modalDescription: { - color: theme.palette.text.secondary, - fontSize: '0.875rem', - marginTop: theme.spacing(2), + modalInfoAlert: { + marginTop: 0, marginBottom: theme.spacing(2), - }, - modalContent: { - position: 'relative', - padding: theme.spacing(3, 0, 3, 3), - marginRight: theme.spacing(3), - }, - modalCustomCloseButton: { - position: 'absolute', - top: theme.spacing(2), - right: theme.spacing(-0.5), - color: theme.palette.text.primary, - }, - modalHeading: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.75), - marginBottom: theme.spacing(1), - fontSize: '1.25rem', - lineHeight: 1.4, - fontWeight: 500, - '& .v5-MuiTypography-root': { - fontSize: '1.25rem', + '--pf-v6-c-alert--BorderColor': '#5e40be', + '--pf-v5-c-alert--BorderColor': '#5e40be', + borderColor: '#5e40be', + '& .pf-v6-c-alert, & .pf-v5-c-alert': { + alignItems: 'flex-start', + }, + '& .pf-v6-c-alert__icon, & .pf-v5-c-alert__icon': { + color: '#5e40be', + alignSelf: 'flex-start', + paddingBlockStart: 0, + marginBlockStart: 0, + lineHeight: 1, + '& svg': { + display: 'block', + }, + }, + '& .pf-v6-c-alert__title, & .pf-v5-c-alert__title': { + margin: '-0.125rem 0 0 0.5rem', lineHeight: 1.4, - fontWeight: 500, }, }, - tokenRow: { - position: 'relative', + modalSection: { + marginBottom: theme.spacing(2), }, - tokenClearButton: { - position: 'absolute', - right: theme.spacing(1), - top: '50%', - transform: 'translateY(-50%)', - zIndex: 1, - color: theme.palette.action.active, + modalSectionTitle: { + fontSize: '0.875rem', + fontWeight: 600, + marginBottom: theme.spacing(0.75), }, - tokenHelper: { + modalSectionDescription: { color: theme.palette.text.secondary, - fontSize: '0.75rem', - marginTop: theme.spacing(0.5), - }, - tokenInput: { - marginTop: '1rem !important', - '& .MuiOutlinedInput-root': { - height: '3.5rem', - }, - '& .MuiOutlinedInput-input': { - padding: '0 0.875rem', - }, - '& .MuiInputLabel-root': { - fontSize: '0.875rem', - }, + fontSize: '0.875rem', + marginTop: theme.spacing(0.25), }, - tokenInputSuccess: { - '& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline': { - borderColor: '#3E8635', - borderWidth: 1, - }, - '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { - borderColor: '#3E8635', - }, - '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: '#3E8635', - }, - '& .MuiInputLabel-root.Mui-focused': { - color: '#3E8635', - }, + modalStatusRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + fontSize: '0.875rem', }, - tokenInputError: { - '& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline': { - borderColor: '#C9190B', - borderWidth: 1, - }, - '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { - borderColor: '#C9190B', - }, - '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: '#C9190B', - }, - '& .MuiInputLabel-root.Mui-focused': { - color: '#C9190B', - }, + modalToolsList: { + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), }, - modalActions: { - marginTop: theme.spacing(3), + modalToolItem: { display: 'flex', + alignItems: 'center', gap: theme.spacing(1), + fontSize: '0.875rem', }, - modalActionButton: { - fontSize: '1rem', + modalEnabledRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: theme.spacing(2), }, - modalCancelButton: { - fontSize: '1rem', + modalEnabledLabel: { + flex: 1, }, - forgetTokenButton: { - fontSize: '1rem', - border: '1px solid #B1380B', + modalCloseButton: { + position: 'absolute', + top: 'var(--pf-v6-c-modal-box__close--InsetBlockStart, 1.5rem)', + right: 'var(--pf-v6-c-modal-box__close--InsetInlineEnd, 1.5rem)', + zIndex: 1, + }, + removePersonalTokenButton: { borderRadius: '1.25rem', - padding: '0.375rem 1rem', - color: '#B1380B', - backgroundColor: 'transparent', - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), boxShadow: 'none', - '&:hover': { - backgroundColor: 'rgba(201, 25, 11, 0.08)', + '&:not(:disabled):not(.pf-m-disabled)': { + '--pf-v6-c-button--BorderColor': '#B1380B', + '--pf-v6-c-button--Color': '#B1380B', + '--pf-v6-c-button--BackgroundColor': 'transparent', + '--pf-v5-c-button--BorderColor': '#B1380B', + '--pf-v5-c-button--Color': '#B1380B', + '--pf-v5-c-button--BackgroundColor': 'transparent', + borderColor: '#B1380B', + color: '#B1380B', + backgroundColor: 'transparent', + '&:hover': { + '--pf-v6-c-button--BackgroundColor': 'rgba(201, 25, 11, 0.08)', + '--pf-v5-c-button--BackgroundColor': 'rgba(201, 25, 11, 0.08)', + backgroundColor: 'rgba(201, 25, 11, 0.08)', + }, }, }, configureModal: { - '& .pf-v6-c-modal-box': { + '& .pf-v6-c-modal-box, & .pf-v5-c-modal-box': { width: '608px', maxWidth: '608px', - height: '326px', - minHeight: '326px', }, - '& .pf-v6-c-modal-box__title, & .pf-v6-c-modal-box__title-text, & .pf-v5-c-modal-box__title, & .pf-v5-c-modal-box__title-text': - { - fontSize: '1.25rem !important', - lineHeight: '1.4 !important', - }, - '& .pf-v6-c-modal-box__close': { + '& .pf-v6-c-modal-box__close, & .pf-v5-c-modal-box__close': { display: 'none', }, - '& .pf-v5-c-modal-box__close': { - display: 'none', + '& .pf-v6-c-modal-box__header, & .pf-v5-c-modal-box__header': { + '--pf-v6-c-modal-box__header--PaddingBlockEnd': '0.25rem', + '--pf-v5-c-modal-box__header--PaddingBlockEnd': '0.25rem', + paddingBlockEnd: '0.25rem', }, - '& .pf-v6-c-button__icon': { - paddingTop: '5px !important', - fontSize: '1.25rem !important', + '& .pf-v6-c-modal-box__title, & .pf-v5-c-modal-box__title': { + marginBlockEnd: 0, + }, + '& .pf-v6-c-modal-box__body, & .pf-v5-c-modal-box__body': { + '--pf-v6-c-modal-box__body--PaddingBlockStart': '0.5rem', + '--pf-v6-c-modal-box__header--body--PaddingBlockStart': '0.5rem', + '--pf-v5-c-modal-box__body--PaddingBlockStart': '0.5rem', + paddingBlockStart: '0.5rem', }, }, toggleCell: { @@ -375,19 +371,26 @@ type McpServersPatchResponse = { }; }; +type McpToolInfo = { + name: string; + description?: string; +}; + type McpServersValidateResponse = { name: string; status: 'connected' | 'error' | 'unknown'; toolCount: number; validation?: { - error?: string; + error?: unknown; + tools?: McpToolInfo[]; }; }; type McpCredentialsValidateResponse = { valid: boolean; - error?: string; + error?: unknown; toolCount: number; + tools?: McpToolInfo[]; }; const getStatusIcon = (status: ServerStatus, className: string) => { @@ -398,14 +401,6 @@ const getStatusIcon = (status: ServerStatus, className: string) => { return ; }; -const getDisplayStatus = (server: McpServer): ServerStatus => { - if (!server.hasToken) return 'tokenRequired'; - if (!server.enabled) return 'disabled'; - if (server.status === 'error') return 'failed'; - if (server.status === 'connected') return 'ok'; - return 'unknown'; -}; - const toUiServer = ( server: McpServerResponse, validationError?: string, @@ -445,6 +440,12 @@ export const McpServersSettings = ({ const [tokenValidationState, setTokenValidationState] = useState('idle'); const [tokenValidationMessage, setTokenValidationMessage] = useState(''); + const [modalEnabled, setModalEnabled] = useState(true); + const [modalTools, setModalTools] = useState([]); + const [isLoadingModalTools, setIsLoadingModalTools] = useState(false); + const [modalToolsError, setModalToolsError] = useState(null); + const [isUpdatingModalStatus, setIsUpdatingModalStatus] = useState(false); + const [hasRemovedPersonalToken, setHasRemovedPersonalToken] = useState(false); const getBaseUrl = useCallback(() => { return `${configApi.getString('backend.baseUrl')}/api/lightspeed`; @@ -480,15 +481,22 @@ export const McpServersSettings = ({ [fetchApi], ); - const validateServer = useCallback( + const fetchServerValidation = useCallback( async (serverName: string) => { const baseUrl = getBaseUrl(); - const data = await fetchJson( + return fetchJson( `${baseUrl}/mcp-servers/${encodeURIComponent(serverName)}/validate`, { method: 'POST', }, ); + }, + [fetchJson, getBaseUrl], + ); + + const validateServer = useCallback( + async (serverName: string) => { + const data = await fetchServerValidation(serverName); setServers(prev => prev.map(server => @@ -499,7 +507,8 @@ export const McpServersSettings = ({ toolCount: data.toolCount, validationError: data.status === 'error' - ? (data.validation?.error ?? 'Validation failed') + ? formatApiError(data.validation?.error) || + 'Validation failed' : undefined, } : server, @@ -507,7 +516,7 @@ export const McpServersSettings = ({ ); return data; }, - [fetchJson, getBaseUrl], + [fetchServerValidation], ); const validateCredentials = useCallback( @@ -663,6 +672,12 @@ export const McpServersSettings = ({ setCanRemovePersonalToken(false); setTokenValidationState('idle'); setTokenValidationMessage(''); + setModalEnabled(true); + setModalTools([]); + setIsLoadingModalTools(false); + setModalToolsError(null); + setIsUpdatingModalStatus(false); + setHasRemovedPersonalToken(false); }, []); const openConfigureModal = (server: McpServer) => { @@ -671,15 +686,77 @@ export const McpServersSettings = ({ setHasSavedTokenInModal(hasSavedToken); setCanRemovePersonalToken(server.hasUserToken); setTokenInputValue(hasSavedToken ? SAVED_TOKEN_MASK : ''); + setModalEnabled(server.enabled); + setModalTools([]); + setModalToolsError(null); + setIsUpdatingModalStatus(false); + setHasRemovedPersonalToken(false); if (server.status === 'error' && server.validationError) { setTokenValidationState('error'); - setTokenValidationMessage(server.validationError); + setTokenValidationMessage( + formatApiError(server.validationError) || + t('mcp.settings.token.validationFailed'), + ); } else { setTokenValidationState('idle'); setTokenValidationMessage(''); } }; + useEffect(() => { + if (!editingServer) { + return undefined; + } + + if (editingServer.auth !== 'dcr' && !editingServer.hasToken) { + setModalTools([]); + setModalToolsError(null); + setIsLoadingModalTools(false); + return undefined; + } + + let cancelled = false; + setIsLoadingModalTools(true); + setModalToolsError(null); + + void fetchServerValidation(editingServer.name) + .then(data => { + if (cancelled) { + return; + } + const tools = data.validation?.tools?.map(tool => tool.name) ?? []; + setModalTools(tools); + if (data.status === 'error') { + setModalToolsError( + formatApiError(data.validation?.error) || + t('mcp.settings.token.validationFailed'), + ); + } else { + setModalToolsError(null); + } + }) + .catch(e => { + if (cancelled) { + return; + } + setModalTools([]); + setModalToolsError( + e instanceof Error + ? e.message + : t('mcp.settings.modal.toolsLoadFailed'), + ); + }) + .finally(() => { + if (!cancelled) { + setIsLoadingModalTools(false); + } + }); + + return () => { + cancelled = true; + }; + }, [editingServer, fetchServerValidation, t]); + const onTokenInputChange = (value: string) => { if (hasSavedTokenInModal && value !== SAVED_TOKEN_MASK) { setHasSavedTokenInModal(false); @@ -698,61 +775,45 @@ export const McpServersSettings = ({ setTokenValidationMessage(''); }; - let tokenInputStateClass = ''; - let tokenHelperColor: string | undefined; + const isUsingOrganizationCredentialInModal = + hasSavedTokenInModal && !canRemovePersonalToken; + + let tokenInputValidated: 'success' | 'error' | undefined; if (tokenValidationState === 'success') { - tokenInputStateClass = classes.tokenInputSuccess; - tokenHelperColor = '#3E8635'; + tokenInputValidated = 'success'; } else if (tokenValidationState === 'error') { - tokenInputStateClass = classes.tokenInputError; - tokenHelperColor = '#C9190B'; + tokenInputValidated = 'error'; } - let tokenInputAdornment = ( - - - - ); - + let tokenHelperVariant: 'success' | 'error' | 'default' = 'default'; if (tokenValidationState === 'success') { - tokenInputAdornment = ( - - ); + tokenHelperVariant = 'success'; } else if (tokenValidationState === 'error') { - tokenInputAdornment = ( - - ); + tokenHelperVariant = 'error'; } - const isUsingOrganizationCredentialInModal = - hasSavedTokenInModal && !canRemovePersonalToken; + const showTokenHelperText = + isUsingOrganizationCredentialInModal || + !hasSavedTokenInModal || + tokenValidationState !== 'idle'; + + const tokenHelperText = + tokenValidationMessage || + (isUsingOrganizationCredentialInModal + ? t('mcp.settings.usingAdminCredential') + : t('mcp.settings.enterToken')); + + const configureModalTitle = t('mcp.settings.configureServerTitle' as any, { + serverName: editingServer?.name ?? '', + }); + + const isConfigureModalSaving = Boolean(isSaving[editingServer?.name ?? '']); + const isSaveTokenButtonDisabled = getSaveTokenDisabled({ + hasSavedTokenInModal, + tokenInputValue, + savedTokenMask: SAVED_TOKEN_MASK, + isUpdatingModalStatus, + }); const saveServerToken = useCallback(async () => { if (!editingServer || !canManageMcp) return; @@ -785,11 +846,12 @@ export const McpServersSettings = ({ if (!credentialValidation.valid) { setTokenValidationState('error'); setTokenValidationMessage( - credentialValidation.error ?? + formatApiError(credentialValidation.error) || t('mcp.settings.token.invalidCredentials'), ); return; } + setModalTools(credentialValidation.tools?.map(tool => tool.name) ?? []); } setTokenValidationMessage(t('mcp.settings.token.savingAndValidating')); @@ -801,11 +863,15 @@ export const McpServersSettings = ({ if (validationResult.status === 'error') { setTokenValidationState('error'); setTokenValidationMessage( - validationResult.validation?.error ?? + formatApiError(validationResult.validation?.error) || t('mcp.settings.token.validationFailed'), ); return; } + if (hasToken && validationResult.status === 'connected') { + await patchServer(editingServer.name, { enabled: true }); + setModalEnabled(true); + } setTokenValidationState('success'); setTokenValidationMessage(t('mcp.settings.token.connectionSuccessful')); closeConfigureModal(); @@ -829,14 +895,229 @@ export const McpServersSettings = ({ validateServer, ]); - const removePersonalToken = () => { - setHasSavedTokenInModal(false); - setCanRemovePersonalToken(false); + const removePersonalToken = useCallback(async () => { + if (!editingServer || !canManageMcp || isUpdatingModalStatus) { + return; + } + + setIsUpdatingModalStatus(true); setTokenInputValue(''); setTokenValidationState('idle'); setTokenValidationMessage(''); + setModalTools([]); + setModalToolsError(null); + setIsLoadingModalTools(false); + + try { + await patchServer(editingServer.name, { token: null }); + setHasSavedTokenInModal(false); + setModalEnabled(false); + setHasRemovedPersonalToken(true); + } catch { + const server = servers.find(item => item.id === editingServerId); + if (server?.hasUserToken && server.hasToken) { + setHasSavedTokenInModal(true); + setCanRemovePersonalToken(true); + setTokenInputValue(SAVED_TOKEN_MASK); + } + setHasRemovedPersonalToken(false); + } finally { + setIsUpdatingModalStatus(false); + } + }, [ + canManageMcp, + editingServer, + editingServerId, + isUpdatingModalStatus, + patchServer, + servers, + ]); + + const onModalEnabledChange = (_event: FormEvent, checked: boolean) => { + if (!editingServer || !canManageMcp) { + return; + } + setModalEnabled(checked); + void patchServer(editingServer.name, { enabled: checked }).catch(() => { + setModalEnabled(!checked); + }); }; + const editingDisplayStatus = editingServer + ? getDisplayStatus(editingServer) + : 'unknown'; + const isModalEnabledToggleDisabled = + !canManageMcp || + !editingServer || + isUpdatingModalStatus || + isEnabledToggleUnavailable(editingDisplayStatus) || + Boolean(isSaving[editingServer?.name ?? '']); + const isModalEnabledChecked = getModalEnabledChecked({ + displayStatus: editingDisplayStatus, + modalEnabled, + }); + + const modalStatusDetail = editingServer + ? getDisplayDetail(editingServer, editingDisplayStatus) + : ''; + + const modalToolCount = modalTools.length || editingServer?.toolCount || 0; + + const renderModalStatusIcon = () => { + if (isUpdatingModalStatus || isLoadingModalTools) { + return ( + + ); + } + if (modalTools.length > 0) { + return ; + } + if (modalToolsError || editingDisplayStatus === 'failed') { + return ; + } + if (editingDisplayStatus === 'tokenRequired') { + return ; + } + if (editingDisplayStatus === 'disabled') { + return ; + } + return ; + }; + + const renderModalStatusText = () => { + if (isUpdatingModalStatus) { + return t('mcp.settings.modal.loadingStatus'); + } + if (isLoadingModalTools) { + return t('mcp.settings.modal.loadingTools'); + } + if (modalTools.length > 0) { + if (modalTools.length === 1) { + return t('mcp.settings.status.oneTool' as any, { + count: String(modalTools.length), + }); + } + return t('mcp.settings.status.manyTools' as any, { + count: String(modalTools.length), + }); + } + if (modalToolsError) { + return modalToolsError; + } + return modalStatusDetail; + }; + + const renderModalToolsContent = () => { + if (isLoadingModalTools) { + return ( + + ); + } + if (modalTools.length > 0) { + return ( +
    + {modalTools.map(toolName => ( +
  • + + {toolName} +
  • + ))} +
+ ); + } + return ( +
+ {modalToolsError ?? t('mcp.settings.modal.noToolsAvailable')} +
+ ); + }; + + const renderConfigureModalDetails = () => ( + <> + } + title={t('mcp.settings.modalDescription')} + className={classes.modalInfoAlert} + isInline + /> + {hasRemovedPersonalToken && ( + } + title={t('mcp.settings.modal.tokenRemovedWarning')} + className={classes.modalInfoAlert} + isInline + /> + )} +
+
+ {t('mcp.settings.status')} +
+
+ {renderModalStatusIcon()} + {renderModalStatusText()} +
+
+ {(editingServer?.hasToken || editingServer?.auth === 'dcr') && ( +
+
+ {t('mcp.settings.modal.toolsHeading' as any, { + count: String(modalToolCount), + })} +
+ {renderModalToolsContent()} +
+ )} +
+
+
+
+ {t('mcp.settings.enabled')} +
+
+ {isModalEnabledChecked + ? t('mcp.settings.modal.enabledDescription') + : t('mcp.settings.modal.enabledDescriptionOff')} +
+
+ {isModalEnabledToggleDisabled ? ( + + + + + + ) : ( + + )} +
+
+ + ); + return (
{(() => { const isUnavailable = - displayStatus === 'failed' || - displayStatus === 'tokenRequired'; - const isChecked = isUnavailable ? false : server.enabled; + isEnabledToggleUnavailable(displayStatus); + const isChecked = getEnabledToggleChecked( + server, + displayStatus, + ); const isRowSaving = Boolean(isSaving[server.name]); const isToggleDisabled = isUnavailable || isRowSaving || !canManageMcp; @@ -1026,128 +1309,114 @@ export const McpServersSettings = ({ -
- - - -
- - - {t('mcp.settings.configureServerTitle' as any, { - serverName: editingServer?.name ?? '', - })} - -
+ + -
) : ( <> -
- {t('mcp.settings.modalDescription')} -
-
- onTokenInputChange(event.target.value)} - className={`${classes.tokenInput} ${tokenInputStateClass}`} - label={ - hasSavedTokenInModal - ? t('mcp.settings.savedToken') - : t('mcp.settings.personalAccessToken') - } - InputProps={{ - endAdornment: ( - - {tokenInputAdornment} - - ), - }} - /> - {(isUsingOrganizationCredentialInModal || - !hasSavedTokenInModal || - tokenValidationState !== 'idle') && ( -
- {tokenValidationMessage || - (isUsingOrganizationCredentialInModal - ? t('mcp.settings.usingAdminCredential') - : t('mcp.settings.enterToken'))} -
+ {renderConfigureModalDetails()} + + + onTokenInputChange(value)} + /> + {(tokenValidationState === 'idle' || + tokenValidationState === 'validating') && ( + +
-
+ + + )} + + + {editingServer?.auth === 'dcr' ? ( + + ) : ( + <> + + {canRemovePersonalToken && ( - {canRemovePersonalToken && hasSavedTokenInModal && ( - - )} - -
+ )} + )} -
+ ); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx new file mode 100644 index 0000000000..9a3de2a083 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx @@ -0,0 +1,312 @@ +/* + * 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 { configApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; + +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; + +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import { McpServersSettings } from '../McpServersSettings'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +jest.mock('@backstage/plugin-permission-react', () => ({ + usePermission: jest.fn(() => ({ loading: false, allowed: true })), +})); + +type McpServerResponse = { + name: string; + url?: string; + enabled: boolean; + status: 'connected' | 'error' | 'unknown'; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; +}; + +const BASE_URL = 'http://localhost:7007/api/lightspeed'; + +const jsonResponse = (body: unknown, ok = true) => ({ + ok, + status: ok ? 200 : 500, + statusText: ok ? 'OK' : 'Error', + text: async () => JSON.stringify(body), +}); + +const connectedServer = ( + name: string, + overrides: Partial = {}, +): McpServerResponse => ({ + name, + url: `http://localhost/${name}`, + enabled: true, + status: 'connected', + toolCount: 2, + hasToken: true, + hasUserToken: false, + ...overrides, +}); + +const resolveTokenFieldAfterPatch = ( + token: string | null | undefined, + currentValue: boolean, +): boolean => { + if (token === null) { + return false; + } + if (token) { + return true; + } + return currentValue; +}; + +describe('McpServersSettings', () => { + const onClose = jest.fn(); + let servers: McpServerResponse[]; + const mockFetch = jest.fn(); + + const renderSettings = () => + render( + { + if (key === 'backend.baseUrl') { + return 'http://localhost:7007'; + } + throw new Error(`Unexpected config key: ${key}`); + }, + }, + ], + [fetchApiRef, { fetch: mockFetch }], + ]} + > + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + servers = [ + connectedServer('personal-server', { + hasUserToken: true, + toolCount: 3, + }), + connectedServer('test-mcp-server', { + url: 'http://localhost:8888/mcp', + enabled: true, + hasToken: false, + hasUserToken: false, + status: 'unknown', + toolCount: 0, + }), + ]; + + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + + if (url === `${BASE_URL}/mcp-servers` && method === 'GET') { + return jsonResponse({ servers }); + } + + if (method === 'POST' && url === `${BASE_URL}/mcp-servers/validate`) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + url: string; + token: string; + }; + return jsonResponse({ + valid: body.token === 'valid-token', + toolCount: 2, + tools: [{ name: 'tool.one' }, { name: 'tool.two' }], + }); + } + + if (method === 'POST' && url.endsWith('/validate')) { + return jsonResponse({ + name: url.split('/').slice(-2, -1)[0], + status: 'connected', + toolCount: 2, + validation: { + tools: [{ name: 'tool.one' }, { name: 'tool.two' }], + }, + }); + } + + if (method === 'PATCH') { + const serverName = decodeURIComponent(url.split('/').pop() ?? ''); + const body = JSON.parse(String(init?.body ?? '{}')) as { + enabled?: boolean; + token?: string | null; + }; + const current = servers.find(server => server.name === serverName); + if (!current) { + return jsonResponse({ error: 'Not found' }, false); + } + + const updated: McpServerResponse = { + ...current, + enabled: body.enabled ?? current.enabled, + hasToken: resolveTokenFieldAfterPatch(body.token, current.hasToken), + hasUserToken: resolveTokenFieldAfterPatch( + body.token, + current.hasUserToken, + ), + status: + body.token === null + ? 'unknown' + : body.enabled === true || current.status, + toolCount: body.token === null ? 0 : current.toolCount, + }; + servers = servers.map(server => + server.name === serverName ? updated : server, + ); + return jsonResponse({ server: updated }); + } + + throw new Error(`Unhandled fetch: ${method} ${url}`); + }); + }); + + const waitForServersLoaded = async () => { + await waitFor(() => { + expect(screen.getByText('personal-server')).toBeInTheDocument(); + }); + }; + + const openConfigureModal = async (serverName: string) => { + fireEvent.click(screen.getByRole('button', { name: `Edit ${serverName}` })); + await waitFor(() => { + expect( + screen.getByRole('heading', { + name: `${serverName} MCP server settings`, + }), + ).toBeInTheDocument(); + }); + }; + + const getModalDialog = () => screen.getByRole('dialog'); + + it('shows enabled toggle off in table when token is required', async () => { + renderSettings(); + await waitForServersLoaded(); + + const tableToggle = screen.getByRole('switch', { + name: 'Toggle test-mcp-server', + }); + expect(tableToggle).not.toBeChecked(); + }); + + it('disables Save when opening modal with unchanged personal token', async () => { + renderSettings(); + await waitForServersLoaded(); + await openConfigureModal('personal-server'); + + const dialog = getModalDialog(); + expect(within(dialog).getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('shows modal enabled toggle off when token is required', async () => { + renderSettings(); + await waitForServersLoaded(); + await openConfigureModal('test-mcp-server'); + + const dialog = getModalDialog(); + expect( + within(dialog).getByRole('switch', { + name: 'Toggle test-mcp-server', + }), + ).not.toBeChecked(); + }); + + it('removes personal token, shows disconnecting state, warning, and disables actions', async () => { + renderSettings(); + await waitForServersLoaded(); + await openConfigureModal('personal-server'); + + const dialog = getModalDialog(); + fireEvent.click( + within(dialog).getByRole('button', { name: 'Remove personal token' }), + ); + + expect(within(dialog).getByText('Disconnecting...')).toBeInTheDocument(); + + await waitFor(() => { + expect(within(dialog).getByText('Token required')).toBeInTheDocument(); + }); + + expect( + within(dialog).getByText( + 'Token has been removed. To use this MCP server again, provide a new token.', + ), + ).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Save' })).toBeDisabled(); + expect( + within(dialog).getByRole('button', { name: 'Remove personal token' }), + ).toBeDisabled(); + expect( + within(dialog).getByRole('switch', { + name: 'Toggle personal-server', + }), + ).not.toBeChecked(); + }); + + it('auto-enables server after saving a valid token', async () => { + servers = [ + connectedServer('credential-test-mcp', { + url: 'http://127.0.0.1:7777/mcp', + enabled: false, + hasToken: false, + hasUserToken: false, + status: 'unknown', + toolCount: 0, + }), + ]; + + renderSettings(); + await waitFor(() => { + expect(screen.getByText('credential-test-mcp')).toBeInTheDocument(); + }); + await openConfigureModal('credential-test-mcp'); + + const dialog = getModalDialog(); + fireEvent.change(within(dialog).getByLabelText('Type to filter'), { + target: { value: 'valid-token' }, + }); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + `${BASE_URL}/mcp-servers/credential-test-mcp`, + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ enabled: true }), + }), + ); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/mcpServersDisplayUtils.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/mcpServersDisplayUtils.test.ts new file mode 100644 index 0000000000..a184ec6aa6 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/mcpServersDisplayUtils.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { + formatApiError, + getDisplayStatus, + getEnabledToggleChecked, + isEnabledToggleUnavailable, + isModalEnabledChecked, + isSaveTokenDisabled, +} from '../mcpServersDisplayUtils'; + +describe('mcpServersDisplayUtils', () => { + describe('formatApiError', () => { + it('returns strings as-is', () => { + expect(formatApiError('Token required')).toBe('Token required'); + }); + + it('returns Error message', () => { + expect(formatApiError(new Error('Validation failed'))).toBe( + 'Validation failed', + ); + }); + + it('extracts nested message objects', () => { + expect(formatApiError({ message: 'Invalid token' })).toBe( + 'Invalid token', + ); + }); + + it('extracts nested error fields', () => { + expect(formatApiError({ error: 'Connection refused' })).toBe( + 'Connection refused', + ); + }); + + it('returns empty string for unknown values', () => { + expect(formatApiError({ code: 500 })).toBe(''); + }); + }); + + describe('getDisplayStatus', () => { + it('returns tokenRequired when no token is saved', () => { + expect( + getDisplayStatus({ + hasToken: false, + enabled: true, + status: 'connected', + }), + ).toBe('tokenRequired'); + }); + + it('returns disabled when server is disabled', () => { + expect( + getDisplayStatus({ + hasToken: true, + enabled: false, + status: 'connected', + }), + ).toBe('disabled'); + }); + + it('returns failed when validation status is error', () => { + expect( + getDisplayStatus({ + hasToken: true, + enabled: true, + status: 'error', + }), + ).toBe('failed'); + }); + + it('returns ok when connected with token and enabled', () => { + expect( + getDisplayStatus({ + hasToken: true, + enabled: true, + status: 'connected', + }), + ).toBe('ok'); + }); + }); + + describe('getEnabledToggleChecked', () => { + it('shows off when token is required even if enabled in settings', () => { + expect( + getEnabledToggleChecked( + { hasToken: false, enabled: true, status: 'unknown' }, + 'tokenRequired', + ), + ).toBe(false); + }); + + it('shows off when validation failed even if enabled in settings', () => { + expect( + getEnabledToggleChecked( + { hasToken: true, enabled: true, status: 'error' }, + 'failed', + ), + ).toBe(false); + }); + + it('reflects enabled state when server is available', () => { + expect( + getEnabledToggleChecked( + { hasToken: true, enabled: true, status: 'connected' }, + 'ok', + ), + ).toBe(true); + }); + }); + + describe('isEnabledToggleUnavailable', () => { + it('returns true for tokenRequired and failed statuses', () => { + expect(isEnabledToggleUnavailable('tokenRequired')).toBe(true); + expect(isEnabledToggleUnavailable('failed')).toBe(true); + }); + + it('returns false for ok and disabled statuses', () => { + expect(isEnabledToggleUnavailable('ok')).toBe(false); + expect(isEnabledToggleUnavailable('disabled')).toBe(false); + }); + }); + + describe('isModalEnabledChecked', () => { + it('shows off in modal when token is required', () => { + expect( + isModalEnabledChecked({ + displayStatus: 'tokenRequired', + modalEnabled: true, + }), + ).toBe(false); + }); + + it('uses modalEnabled when server is available', () => { + expect( + isModalEnabledChecked({ + displayStatus: 'ok', + modalEnabled: false, + }), + ).toBe(false); + }); + }); + + describe('isSaveTokenDisabled', () => { + const savedTokenMask = '********************'; + + it('disables save for unchanged masked token', () => { + expect( + isSaveTokenDisabled({ + hasSavedTokenInModal: true, + tokenInputValue: savedTokenMask, + savedTokenMask, + isUpdatingModalStatus: false, + }), + ).toBe(true); + }); + + it('disables save for empty token input', () => { + expect( + isSaveTokenDisabled({ + hasSavedTokenInModal: false, + tokenInputValue: '', + savedTokenMask, + isUpdatingModalStatus: false, + }), + ).toBe(true); + }); + + it('disables save while status is updating', () => { + expect( + isSaveTokenDisabled({ + hasSavedTokenInModal: false, + tokenInputValue: 'new-token', + savedTokenMask, + isUpdatingModalStatus: true, + }), + ).toBe(true); + }); + + it('enables save when user entered a new token', () => { + expect( + isSaveTokenDisabled({ + hasSavedTokenInModal: false, + tokenInputValue: 'new-token', + savedTokenMask, + isUpdatingModalStatus: false, + }), + ).toBe(false); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/mcpServersDisplayUtils.ts b/workspaces/lightspeed/plugins/lightspeed/src/components/mcpServersDisplayUtils.ts new file mode 100644 index 0000000000..b0d3866ba2 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/mcpServersDisplayUtils.ts @@ -0,0 +1,92 @@ +/* + * 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. + */ + +export type ServerStatus = + | 'tokenRequired' + | 'disabled' + | 'ok' + | 'failed' + | 'unknown'; + +export type McpServerDisplayInput = { + enabled: boolean; + status: 'connected' | 'error' | 'unknown'; + hasToken: boolean; +}; + +export const formatApiError = (error: unknown): string => { + if (typeof error === 'string') { + return error; + } + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === 'object') { + if ('message' in error && typeof error.message === 'string') { + return error.message; + } + if ('error' in error) { + return formatApiError((error as { error: unknown }).error); + } + } + return ''; +}; + +export const getDisplayStatus = ( + server: McpServerDisplayInput, +): ServerStatus => { + if (!server.hasToken) return 'tokenRequired'; + if (!server.enabled) return 'disabled'; + if (server.status === 'error') return 'failed'; + if (server.status === 'connected') return 'ok'; + return 'unknown'; +}; + +export const isEnabledToggleUnavailable = ( + displayStatus: ServerStatus, +): boolean => displayStatus === 'failed' || displayStatus === 'tokenRequired'; + +export const getEnabledToggleChecked = ( + server: McpServerDisplayInput, + displayStatus: ServerStatus, +): boolean => + isEnabledToggleUnavailable(displayStatus) ? false : server.enabled; + +export const isSaveTokenDisabled = ({ + hasSavedTokenInModal, + tokenInputValue, + savedTokenMask, + isUpdatingModalStatus, +}: { + hasSavedTokenInModal: boolean; + tokenInputValue: string; + savedTokenMask: string; + isUpdatingModalStatus: boolean; +}): boolean => { + const isTokenInputUnchanged = + hasSavedTokenInModal && tokenInputValue === savedTokenMask; + const isTokenInputEmpty = tokenInputValue.trim() === ''; + return isTokenInputUnchanged || isTokenInputEmpty || isUpdatingModalStatus; +}; + +export const isModalEnabledChecked = ({ + displayStatus, + modalEnabled, +}: { + displayStatus: ServerStatus; + modalEnabled: boolean; +}): boolean => + isEnabledToggleUnavailable(displayStatus) ? false : modalEnabled; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index b54e746011..508a26e6ad 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -129,7 +129,8 @@ const lightspeedTranslationDe = createTranslationMessages({ 'mcp.settings.closeAriaLabel': 'MCP-Einstellungen schließen', 'mcp.settings.closeConfigureModalAriaLabel': 'Konfigurationsdialog schließen', - 'mcp.settings.configureServerTitle': 'Server {{serverName}} konfigurieren', + 'mcp.settings.configureServerTitle': + '{{serverName}} MCP-Server-Einstellungen', 'mcp.settings.edit': 'Bearbeiten', 'mcp.settings.editServerAriaLabel': '{{serverName}} bearbeiten', 'mcp.settings.enabled': 'Aktiviert', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 4d700483ca..419b4f3a0b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -126,7 +126,8 @@ const lightspeedTranslationEs = createTranslationMessages({ 'mcp.settings.closeAriaLabel': 'Cerrar configuración de MCP', 'mcp.settings.closeConfigureModalAriaLabel': 'Cerrar modal de configuración', - 'mcp.settings.configureServerTitle': 'Configurar servidor {{serverName}}', + 'mcp.settings.configureServerTitle': + '{{serverName}} — configuración del servidor MCP', 'mcp.settings.edit': 'Editar', 'mcp.settings.editServerAriaLabel': 'Editar {{serverName}}', 'mcp.settings.enabled': 'Habilitado', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index 926b0996bd..63cc4150cb 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -129,7 +129,8 @@ const lightspeedTranslationFr = createTranslationMessages({ 'mcp.settings.closeAriaLabel': 'Fermer les paramètres MCP', 'mcp.settings.closeConfigureModalAriaLabel': 'Fermer la fenêtre de configuration', - 'mcp.settings.configureServerTitle': 'Configurer le serveur {{serverName}}', + 'mcp.settings.configureServerTitle': + '{{serverName}} — paramètres du serveur MCP', 'mcp.settings.edit': 'Modifier', 'mcp.settings.editServerAriaLabel': 'Modifier {{serverName}}', 'mcp.settings.enabled': 'Activé', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index a41ebaed9d..eec7edb9ba 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -127,7 +127,8 @@ const lightspeedTranslationIt = createTranslationMessages({ 'mcp.settings.closeAriaLabel': 'Chiudi impostazioni MCP', 'mcp.settings.closeConfigureModalAriaLabel': 'Chiudi finestra di configurazione', - 'mcp.settings.configureServerTitle': 'Configura server {{serverName}}', + 'mcp.settings.configureServerTitle': + '{{serverName}} — impostazioni server MCP', 'mcp.settings.edit': 'Modifica', 'mcp.settings.editServerAriaLabel': 'Modifica {{serverName}}', 'mcp.settings.enabled': 'Abilitato', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index 09e417baae..ce4c72967c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -125,7 +125,7 @@ const lightspeedTranslationJa = createTranslationMessages({ 'lcore.notConfigured.title': 'LLM に接続して始める', 'mcp.settings.closeAriaLabel': 'MCP 設定を閉じる', 'mcp.settings.closeConfigureModalAriaLabel': '設定モーダルを閉じる', - 'mcp.settings.configureServerTitle': '{{serverName}} サーバーを設定', + 'mcp.settings.configureServerTitle': '{{serverName}} MCP サーバー設定', 'mcp.settings.edit': '編集', 'mcp.settings.editServerAriaLabel': '{{serverName}} を編集', 'mcp.settings.enabled': '有効', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 513e511663..4e82e05f0e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -349,10 +349,22 @@ export const lightspeedMessages = { 'This server uses Dynamic Client Registration (DCR). Tokens are minted automatically using your Backstage identity — no manual token is needed.', 'mcp.settings.toggleServerAriaLabel': 'Toggle {{serverName}}', 'mcp.settings.editServerAriaLabel': 'Edit {{serverName}}', - 'mcp.settings.configureServerTitle': 'Configure {{serverName}} server', + 'mcp.settings.configureServerTitle': '{{serverName}} MCP server settings', 'mcp.settings.closeConfigureModalAriaLabel': 'Close configure modal', 'mcp.settings.modalDescription': - 'Credentials are encrypted at rest and scoped to your profile. The intelligent assistant will operate with your exact permissions.', + 'Credentials are encrypted and operations use your exact permissions.', + 'mcp.settings.authenticationToken': 'Authentication token', + 'mcp.settings.modal.toolsHeading': 'Tools ({{count}})', + 'mcp.settings.modal.loadingTools': 'Loading tools...', + 'mcp.settings.modal.loadingStatus': 'Disconnecting...', + 'mcp.settings.modal.tokenRemovedWarning': + 'Token has been removed. To use this MCP server again, provide a new token.', + 'mcp.settings.modal.noToolsAvailable': 'No tools available.', + 'mcp.settings.modal.toolsLoadFailed': 'Failed to load tools.', + 'mcp.settings.modal.enabledDescription': + 'This server is active and available in chat.', + 'mcp.settings.modal.enabledDescriptionOff': + 'This server is disabled and unavailable in chat.', 'mcp.settings.savedToken': 'Saved token', 'mcp.settings.personalAccessToken': 'Personal Access Token', 'mcp.settings.usingAdminCredential': diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index b5230c4b8a..54c6b20667 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -5282,7 +5282,7 @@ __metadata: languageName: node linkType: hard -"@emotion/is-prop-valid@npm:^1.3.0, @emotion/is-prop-valid@npm:^1.3.1": +"@emotion/is-prop-valid@npm:^1.3.0": version: 1.3.1 resolution: "@emotion/is-prop-valid@npm:1.3.1" dependencies: @@ -7645,32 +7645,6 @@ __metadata: languageName: node linkType: hard -"@mswjs/cookies@npm:^0.2.2": - version: 0.2.2 - resolution: "@mswjs/cookies@npm:0.2.2" - dependencies: - "@types/set-cookie-parser": "npm:^2.4.0" - set-cookie-parser: "npm:^2.4.6" - checksum: 10c0/f950062538d431674d581309cf19884fc4d3f57e2a276164cac0c9a3250071d42464ba7825d13be14c703ca5a912d62a62626f4a068d8f36d1629dbb63bde740 - languageName: node - linkType: hard - -"@mswjs/interceptors@npm:^0.17.10": - version: 0.17.10 - resolution: "@mswjs/interceptors@npm:0.17.10" - dependencies: - "@open-draft/until": "npm:^1.0.3" - "@types/debug": "npm:^4.1.7" - "@xmldom/xmldom": "npm:^0.8.3" - debug: "npm:^4.3.3" - headers-polyfill: "npm:3.2.5" - outvariant: "npm:^1.2.1" - strict-event-emitter: "npm:^0.2.4" - web-encoding: "npm:^1.1.5" - checksum: 10c0/0343a93711b60c321c40733d6bf2720a736d8e0730f5d0d9916ee4a24abfcfca4a83d1e4b2e21c3affef4fc61f04588104be002fbc8258dc4b0d202c384ade33 - languageName: node - linkType: hard - "@mswjs/interceptors@npm:^0.41.2": version: 0.41.2 resolution: "@mswjs/interceptors@npm:0.41.2" @@ -8558,13 +8532,6 @@ __metadata: languageName: node linkType: hard -"@open-draft/until@npm:^1.0.3": - version: 1.0.3 - resolution: "@open-draft/until@npm:1.0.3" - checksum: 10c0/f88bcd774b55359d14a4fa80f7bfe7d9d6d26a5995e94e823e43b211656daae3663e983f0a996937da286d22f6f5da2087b661845302f236ba27f8529dcd14fb - languageName: node - linkType: hard - "@open-draft/until@npm:^2.0.0": version: 2.1.0 resolution: "@open-draft/until@npm:2.1.0" @@ -9942,7 +9909,6 @@ __metadata: resolution: "@red-hat-developer-hub/backstage-plugin-lightspeed@workspace:plugins/lightspeed" dependencies: "@backstage/cli": "npm:^0.36.0" - "@backstage/core-app-api": "npm:^1.19.6" "@backstage/core-components": "npm:^0.18.8" "@backstage/core-plugin-api": "npm:^1.12.4" "@backstage/dev-utils": "npm:^1.1.21" @@ -9951,10 +9917,8 @@ __metadata: "@backstage/plugin-permission-react": "npm:^0.4.41" "@backstage/test-utils": "npm:^1.7.16" "@backstage/theme": "npm:^0.7.2" - "@emotion/is-prop-valid": "npm:^1.3.1" "@ianvs/prettier-plugin-sort-imports": "npm:^4.4.0" "@material-ui/core": "npm:^4.9.13" - "@material-ui/lab": "npm:^4.0.0-alpha.61" "@monaco-editor/react": "npm:^4.7.0" "@mui/icons-material": "npm:^6.1.8" "@mui/material": "npm:^5.12.2" @@ -9973,12 +9937,10 @@ __metadata: "@testing-library/react": "npm:^15.0.0" "@testing-library/user-event": "npm:14.6.1" monaco-editor: "npm:^0.55.0" - msw: "npm:1.3.5" prettier: "npm:3.8.4" react: "npm:16.13.1 || ^17.0.0 || ^18.0.0" react-dom: "npm:16.13.1 || ^17.0.0 || ^18.0.0" react-dropzone: "npm:^14.4.0" - react-markdown: "npm:^9.0.1" react-router-dom: "npm:^6.0.0" react-use: "npm:^17.2.4" peerDependencies: @@ -13045,13 +13007,6 @@ __metadata: languageName: node linkType: hard -"@types/cookie@npm:^0.4.1": - version: 0.4.1 - resolution: "@types/cookie@npm:0.4.1" - checksum: 10c0/f96afe12bd51be1ec61410b0641243d93fa3a494702407c787a4c872b5c8bcd39b224471452055e44a9ce42af1a636e87d161994226eaf4c2be9c30f60418409 - languageName: node - linkType: hard - "@types/cookiejar@npm:^2.1.5": version: 2.1.5 resolution: "@types/cookiejar@npm:2.1.5" @@ -13068,7 +13023,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.7": +"@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -13320,13 +13275,6 @@ __metadata: languageName: node linkType: hard -"@types/js-levenshtein@npm:^1.1.1": - version: 1.1.3 - resolution: "@types/js-levenshtein@npm:1.1.3" - checksum: 10c0/025f2bd8d865cfa7a996799a1a2f2a77fa2fc74a28971aa035a103de35d7c1e3d949721a88f57fdb532815bbcb2bf7019196a608ed0a8bbd1023d64c52bb251b - languageName: node - linkType: hard - "@types/js-yaml@npm:^4, @types/js-yaml@npm:^4.0.1": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" @@ -13727,15 +13675,6 @@ __metadata: languageName: node linkType: hard -"@types/set-cookie-parser@npm:^2.4.0": - version: 2.4.10 - resolution: "@types/set-cookie-parser@npm:2.4.10" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/010b0c582ea70a2088618b4725808e80c30cce296c19ec58e51d94e0fd1038201b7b99238bf3ea74e1894163c8037d10a4f1729de62b2801ce240ff070f43e76 - languageName: node - linkType: hard - "@types/sockjs@npm:^0.3.36": version: 0.3.36 resolution: "@types/sockjs@npm:0.3.36" @@ -14502,13 +14441,6 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:^0.8.3": - version: 0.8.12 - resolution: "@xmldom/xmldom@npm:0.8.12" - checksum: 10c0/b733c84292d1bee32ef21a05aba8f9063456b51a54068d0b4a1abf5545156ee0b9894b7ae23775b5881b11c35a8a03871d1b514fb7e1b11654cdbee57e1c2707 - languageName: node - linkType: hard - "@xobotyi/scrollbar-width@npm:^1.9.5": version: 1.9.5 resolution: "@xobotyi/scrollbar-width@npm:1.9.5" @@ -14568,13 +14500,6 @@ __metadata: languageName: node linkType: hard -"@zxing/text-encoding@npm:0.9.0": - version: 0.9.0 - resolution: "@zxing/text-encoding@npm:0.9.0" - checksum: 10c0/d15bff181d46c2ab709e7242801a8d40408aa8c19b44462e5f60e766bf59105b44957914ab6baab60d10d466a5e965f21fe890c67dfdb7d5c7f940df457b4d0d - languageName: node - linkType: hard - "a-sync-waterfall@npm:^1.0.0": version: 1.0.1 resolution: "a-sync-waterfall@npm:1.0.1" @@ -17367,13 +17292,6 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.4.2": - version: 0.4.2 - resolution: "cookie@npm:0.4.2" - checksum: 10c0/beab41fbd7c20175e3a2799ba948c1dcc71ef69f23fe14eeeff59fc09f50c517b0f77098db87dbb4c55da802f9d86ee86cdc1cd3efd87760341551838d53fca2 - languageName: node - linkType: hard - "cookie@npm:^1.0.2": version: 1.0.2 resolution: "cookie@npm:1.0.2" @@ -21376,7 +21294,7 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^16.0.0, graphql@npm:^16.12.0, graphql@npm:^16.8.1": +"graphql@npm:^16.0.0, graphql@npm:^16.12.0": version: 16.12.0 resolution: "graphql@npm:16.12.0" checksum: 10c0/b6fffa4e8a4e4a9933ebe85e7470b346dbf49050c1a482fac5e03e4a1a7bed2ecd3a4c97e29f04457af929464bc5e4f2aac991090c2f320111eef26e902a5c75 @@ -21756,13 +21674,6 @@ __metadata: languageName: node linkType: hard -"headers-polyfill@npm:3.2.5": - version: 3.2.5 - resolution: "headers-polyfill@npm:3.2.5" - checksum: 10c0/10202f4ebfaecd6aa31305f29664f876ac01d9174a3fb8fcc5a0df3eaf9c1767fb0d6cf6f961484f2bfd2101b6768090976f146bd88aeedd07af4e741cb2dcb7 - languageName: node - linkType: hard - "headers-polyfill@npm:^4.0.2": version: 4.0.3 resolution: "headers-polyfill@npm:4.0.3" @@ -23910,13 +23821,6 @@ __metadata: languageName: node linkType: hard -"js-levenshtein@npm:^1.1.6": - version: 1.1.6 - resolution: "js-levenshtein@npm:1.1.6" - checksum: 10c0/14045735325ea1fd87f434a74b11d8a14380f090f154747e613529c7cff68b5ee607f5230fa40665d5fb6125a3791f4c223f73b9feca754f989b059f5c05864f - languageName: node - linkType: hard - "js-tiktoken@npm:^1.0.12": version: 1.0.15 resolution: "js-tiktoken@npm:1.0.15" @@ -27135,40 +27039,6 @@ __metadata: languageName: node linkType: hard -"msw@npm:1.3.5": - version: 1.3.5 - resolution: "msw@npm:1.3.5" - dependencies: - "@mswjs/cookies": "npm:^0.2.2" - "@mswjs/interceptors": "npm:^0.17.10" - "@open-draft/until": "npm:^1.0.3" - "@types/cookie": "npm:^0.4.1" - "@types/js-levenshtein": "npm:^1.1.1" - chalk: "npm:^4.1.1" - chokidar: "npm:^3.4.2" - cookie: "npm:^0.4.2" - graphql: "npm:^16.8.1" - headers-polyfill: "npm:3.2.5" - inquirer: "npm:^8.2.0" - is-node-process: "npm:^1.2.0" - js-levenshtein: "npm:^1.1.6" - node-fetch: "npm:^2.6.7" - outvariant: "npm:^1.4.0" - path-to-regexp: "npm:^6.3.0" - strict-event-emitter: "npm:^0.4.3" - type-fest: "npm:^2.19.0" - yargs: "npm:^17.3.1" - peerDependencies: - typescript: ">= 4.4.x" - peerDependenciesMeta: - typescript: - optional: true - bin: - msw: cli/index.js - checksum: 10c0/bb0b3625b68f1750bfe90ade6e9e98c64f509138ce7b09c8a53af19f8f662ac79881dad64fbc74c9426247b725e3ec5e6d45eea2f6b71ddc02184e0ddf743e4d - languageName: node - linkType: hard - "msw@npm:2.12.10": version: 2.12.10 resolution: "msw@npm:2.12.10" @@ -28168,7 +28038,7 @@ __metadata: languageName: node linkType: hard -"outvariant@npm:^1.2.1, outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": version: 1.4.3 resolution: "outvariant@npm:1.4.3" checksum: 10c0/5976ca7740349cb8c71bd3382e2a762b1aeca6f33dc984d9d896acdf3c61f78c3afcf1bfe9cc633a7b3c4b295ec94d292048f83ea2b2594fae4496656eba992c @@ -32112,13 +31982,6 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.4.6": - version: 2.7.1 - resolution: "set-cookie-parser@npm:2.7.1" - checksum: 10c0/060c198c4c92547ac15988256f445eae523f57f2ceefeccf52d30d75dedf6bff22b9c26f756bd44e8e560d44ff4ab2130b178bd2e52ef5571bf7be3bd7632d9a - languageName: node - linkType: hard - "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -32847,22 +32710,6 @@ __metadata: languageName: node linkType: hard -"strict-event-emitter@npm:^0.2.4": - version: 0.2.8 - resolution: "strict-event-emitter@npm:0.2.8" - dependencies: - events: "npm:^3.3.0" - checksum: 10c0/6891e19fea4f0289e4da2fe7050d85906eaca7f774aa38fe674f0e58fdece1b63b868614fa23974c4cb862aa99358caa987523b705fdfff4639231c62e384394 - languageName: node - linkType: hard - -"strict-event-emitter@npm:^0.4.3": - version: 0.4.6 - resolution: "strict-event-emitter@npm:0.4.6" - checksum: 10c0/d0231ef081cb1937b1445da59a1ec202d1c097d825c504f398600532490a4104e200b0dce4137467a8eaac5f8f9718d01c99869687afad78cad3b14c4b2e6a39 - languageName: node - linkType: hard - "strict-event-emitter@npm:^0.5.1": version: 0.5.1 resolution: "strict-event-emitter@npm:0.5.1" @@ -35447,19 +35294,6 @@ __metadata: languageName: node linkType: hard -"web-encoding@npm:^1.1.5": - version: 1.1.5 - resolution: "web-encoding@npm:1.1.5" - dependencies: - "@zxing/text-encoding": "npm:0.9.0" - util: "npm:^0.12.3" - dependenciesMeta: - "@zxing/text-encoding": - optional: true - checksum: 10c0/59d5413338ec0894c690006f5d8508b0c88cae1d8c78606c3f326e351c672196461ed808b849fe08d0900fa56a61fcacb9ff576499068d2ead0a7bc04afa7d34 - languageName: node - linkType: hard - "web-namespaces@npm:^2.0.0": version: 2.0.1 resolution: "web-namespaces@npm:2.0.1" @@ -36125,7 +35959,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.1.1, yargs@npm:^17.3.1, yargs@npm:^17.7.2": +"yargs@npm:^17.1.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From 7c3c9ba629c5db11ce6b2ed4ecf7e29e70fe49df Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 7 Jul 2026 10:12:04 -0400 Subject: [PATCH 2/4] resolved tsc issue Signed-off-by: Yi Cai --- .../__tests__/McpServersSettings.test.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx index 9a3de2a083..6e45d79338 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/McpServersSettings.test.tsx @@ -82,6 +82,20 @@ const resolveTokenFieldAfterPatch = ( return currentValue; }; +const resolveStatusAfterPatch = ( + token: string | null | undefined, + enabled: boolean | undefined, + currentStatus: McpServerResponse['status'], +): McpServerResponse['status'] => { + if (token === null) { + return 'unknown'; + } + if (enabled === true) { + return 'connected'; + } + return currentStatus; +}; + describe('McpServersSettings', () => { const onClose = jest.fn(); let servers: McpServerResponse[]; @@ -175,10 +189,11 @@ describe('McpServersSettings', () => { body.token, current.hasUserToken, ), - status: - body.token === null - ? 'unknown' - : body.enabled === true || current.status, + status: resolveStatusAfterPatch( + body.token, + body.enabled, + current.status, + ), toolCount: body.token === null ? 0 : current.toolCount, }; servers = servers.map(server => From a0fe73997b54b426bec203a780a35d2e63f4e440 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 7 Jul 2026 10:19:32 -0400 Subject: [PATCH 3/4] updated report api Signed-off-by: Yi Cai --- .../lightspeed/plugins/lightspeed/report-alpha.api.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 0e942cc6ed..aec556e1bd 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -240,6 +240,15 @@ export const lightspeedTranslationRef: TranslationRef< readonly 'mcp.settings.configureServerTitle': string; readonly 'mcp.settings.closeConfigureModalAriaLabel': string; readonly 'mcp.settings.modalDescription': string; + readonly 'mcp.settings.authenticationToken': string; + readonly 'mcp.settings.modal.toolsHeading': string; + readonly 'mcp.settings.modal.loadingTools': string; + readonly 'mcp.settings.modal.loadingStatus': string; + readonly 'mcp.settings.modal.tokenRemovedWarning': string; + readonly 'mcp.settings.modal.noToolsAvailable': string; + readonly 'mcp.settings.modal.toolsLoadFailed': string; + readonly 'mcp.settings.modal.enabledDescription': string; + readonly 'mcp.settings.modal.enabledDescriptionOff': string; readonly 'mcp.settings.savedToken': string; readonly 'mcp.settings.personalAccessToken': string; readonly 'mcp.settings.usingAdminCredential': string; From b8b000b02001520027d2adf1f95c7569372f441c Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 7 Jul 2026 11:13:58 -0400 Subject: [PATCH 4/4] fixed failed ci tests Signed-off-by: Yi Cai --- workspaces/lightspeed/e2e-tests/pages/LightspeedPage.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workspaces/lightspeed/e2e-tests/pages/LightspeedPage.ts b/workspaces/lightspeed/e2e-tests/pages/LightspeedPage.ts index 7b3ae4269c..340644e699 100644 --- a/workspaces/lightspeed/e2e-tests/pages/LightspeedPage.ts +++ b/workspaces/lightspeed/e2e-tests/pages/LightspeedPage.ts @@ -247,9 +247,9 @@ export function mcpConfigureModalMessage( page: Page, exactText: string, ): Locator { - return mcpCredentialConfigureModal(page).getByText(exactText, { - exact: true, - }); + // PatternFly HelperTextItem appends ": error status;" for error variants; validation + // errors may also appear in the tools section. Substring match + first() avoids both. + return mcpCredentialConfigureModal(page).getByText(exactText).first(); } /**