Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
102212b
feat(lightspeed): add MCP servers settings panel
ciiay Mar 21, 2026
40c1e55
improved UI
ciiay Mar 25, 2026
3ab10a3
backend integration
ciiay Mar 30, 2026
fdd6f2b
code improvement
ciiay Mar 30, 2026
0f792e8
error messages improvements
ciiay Mar 31, 2026
b915bf5
resovled qoto comments
ciiay Mar 31, 2026
e930911
addressed review points
ciiay Apr 3, 2026
59554c4
fixed failed tests
ciiay Apr 3, 2026
6764680
feat(lightspeed): add MCP servers settings panel
ciiay Mar 21, 2026
e7e5a04
feat(lightspeed): add MCP token configure modal UX states
ciiay Mar 26, 2026
41f0f45
rebased with main branch
ciiay Apr 3, 2026
fcb4bf1
fixed failed ci check
ciiay Apr 3, 2026
6cc94af
resolved a qoto bot comment
ciiay Apr 3, 2026
e0f7498
fixed prettier issue
ciiay Apr 6, 2026
b744365
fixed responsive UI issue and added an error handler
ciiay Apr 6, 2026
30edf70
UX improvements
ciiay Apr 9, 2026
eaa7de9
fixed a failed test
ciiay Apr 9, 2026
ee7f248
added i18n support to MCP servers selector
ciiay Apr 9, 2026
d4738a6
fixed a chat history hidden display issue
ciiay Apr 9, 2026
94e76f8
updated api-reports
ciiay Apr 9, 2026
266be83
fixed failed e2e tests
ciiay Apr 9, 2026
ea0645e
fixed failed e2e tests
ciiay Apr 10, 2026
3ee9bd1
fix(lightspeed): stabilize display mode menu e2e locators
Apr 10, 2026
5273e1d
test(lightspeed): use i18n for MCP settings e2e and drop English-only…
Apr 10, 2026
72f916d
refactor(lightspeed-e2e): simplify MCP mocks and devMode handlers
Apr 10, 2026
92f4687
Revert "fixed failed e2e tests"
ciiay Apr 10, 2026
a750e6e
Merge pull request #3 from HusneShabbir/fix/lightspeed-e2e-menu-locat…
ciiay Apr 10, 2026
c69071c
test(lightspeed-e2e): use exact match for MCP configure modal Cancel
Apr 10, 2026
826a603
fixed minor ui issues
ciiay Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions workspaces/lightspeed/.changeset/sixty-chefs-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
---

Added the MCP servers selector/settings feature in Lightspeed with backend
integration for listing servers, per-user token updates, and validation.

In the settings panel, users can review server status, enable or disable
eligible servers, configure personal tokens, and get inline token validation
feedback. Token validation now runs automatically after typing stops and shows
success (`Connection successful`) or error (`Authorization failed. Try again.`)
before save.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
* limitations under the License.
*/

import type { LightspeedMessages } from '../utils/translations';
import { formatMcpToolCountStatus } from '../utils/translations';

/**
* GET /api/lightspeed/mcp-servers body shape (see McpServersSettings McpServerResponse).
* Use {@link mcpServer} for defaults; override fields per scenario.
Expand Down Expand Up @@ -49,20 +52,33 @@ export function mcpServer(
};
}

/**
* Expected MCP header “selected” line — mirrors McpServersSettings `selectedCount` useMemo
* (`enabled && !failed && !tokenRequired`).
*/
export function getExpectedMcpSelectedCountForMock(
mcpList: McpServersListMock,
): { selectedCount: number; totalCount: number } {
const totalCount = mcpList.servers.length;
const selectedCount = mcpList.servers.filter(
server => server.enabled && server.hasToken && server.status !== 'error',
).length;
return { selectedCount, totalCount };
}

/**
* Expected Status column text for a mock row — mirrors McpServersSettings getDisplayStatus +
* getDisplayDetail.
*/
export function getExpectedMcpStatusDetailForMock(
server: McpServerMockEntry,
t: LightspeedMessages,
): string {
// Same branch order as McpServersSettings getDisplayStatus + getDisplayDetail.
if (!server.hasToken) return 'Token required';
if (!server.enabled) return 'Disabled';
if (server.status === 'error') return 'Failed';
if (server.status === 'unknown') return 'Unknown';
const suffix = server.toolCount === 1 ? 'tool' : 'tools';
return `${server.toolCount} ${suffix}`;
if (!server.hasToken) return t['mcp.settings.status.tokenRequired'];
if (!server.enabled) return t['mcp.settings.status.disabled'];
if (server.status === 'error') return t['mcp.settings.status.failed'];
if (server.status === 'unknown') return t['mcp.settings.status.unknown'];
return formatMcpToolCountStatus(t, server.toolCount);
}

/** Named presets for Playwright `mockMcpServers(page, scenario)` and panel assertions. */
Expand Down Expand Up @@ -155,3 +171,32 @@ export const mcpServerScenarios = {

export const mockedMcpServersResponse: McpServersListMock =
mcpServerScenarios.default;

/**
* Token accepted by e2e route mocks for `POST .../mcp-servers/validate`
* (credential check before PATCH).
*/
export const E2E_MCP_VALID_TOKEN = 'e2e-mcp-valid-token';

/** One row with `url` set so the configure modal runs credential + server validation. */
export const tokenCredentialValidationScenario = {
servers: [
mcpServer('credential-test-mcp', {
hasToken: false,
toolCount: 0,
status: 'unknown',
url: 'http://127.0.0.1:7777/mcp',
}),
],
} satisfies McpServersListMock;

/** Token required but no `url` — UI must not call credential validate (shows URL error). */
export const tokenCredentialNoUrlScenario = {
servers: [
mcpServer('no-url-mcp', {
hasToken: false,
toolCount: 0,
status: 'unknown',
}),
],
} satisfies McpServersListMock;
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,14 @@ export const mockedShields = [
];

export {
E2E_MCP_VALID_TOKEN,
getExpectedMcpSelectedCountForMock,
getExpectedMcpStatusDetailForMock,
mcpServer,
mcpServerScenarios,
mockedMcpServersResponse,
tokenCredentialNoUrlScenario,
tokenCredentialValidationScenario,
type McpServerMockEntry,
type McpServersListMock,
} from './mcpServerMocks';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
botResponse,
moreConversations,
mockedShields,
E2E_MCP_VALID_TOKEN,
mcpServerScenarios,
tokenCredentialNoUrlScenario,
tokenCredentialValidationScenario,
type McpServersListMock,
thinkingContent,
assistantResponse,
Expand Down Expand Up @@ -59,7 +62,9 @@
clickMcpServersStatusColumn,
clickMcpServersNameColumn,
mcpServersTableBodyRows,
type DisplayMode,
} from './pages/LightspeedPage';
import { McpConfigureTokenPage } from './pages/McpConfigureTokenPage';
import {
uploadFiles,
uploadAndAssertDuplicate,
Expand Down Expand Up @@ -128,10 +133,10 @@
import {
LightspeedMessages,
evaluateMessage,
formatMcpToolCountStatus,
getTranslations,
} from './utils/translations';
import { runAccessibilityTests } from './utils/accessibility';
import { skipUnlessLocales } from './utils/localeSkip';

test.describe('Lightspeed tests', () => {
const botQuery = 'Please respond';
Expand Down Expand Up @@ -213,12 +218,13 @@
await verifyMcpSettingsPanel(sharedPage, translations, mcpList);
}

test.beforeEach(async ({}, testInfo) => {
skipUnlessLocales(
testInfo,
['en'],
'Chatbot MCP settings uses English-only UI strings.',
);
/** Opens the chatbot and sets display mode before MCP configure-server token flows. */
async function openChatbotInDisplayMode(mode: DisplayMode) {

Check warning on line 222 in workspaces/lightspeed/packages/app-legacy/e2e-tests/lightspeed.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move async function 'openChatbotInDisplayMode' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ13zHbtOPuD339sIumC&open=AZ13zHbtOPuD339sIumC&pullRequest=2708
await openChatbot(sharedPage);
await selectDisplayMode(sharedPage, translations, mode);
}

test.beforeEach(async () => {
await sharedPage.goto('/');
});

Expand Down Expand Up @@ -271,31 +277,172 @@
await openChatbot(sharedPage);
await openMcpSettingsPanel(sharedPage, translations);

const rows = mcpServersTableBodyRows(sharedPage);
const rows = mcpServersTableBodyRows(sharedPage, translations);
await expect(rows.nth(0)).toContainText('alpha-mcp');
await expect(rows.nth(1)).toContainText('beta-mcp');

await clickMcpServersNameColumn(sharedPage);
await clickMcpServersNameColumn(sharedPage, translations);
await expect(rows.nth(0)).toContainText('beta-mcp');
await expect(rows.nth(1)).toContainText('alpha-mcp');

await closeMcpSettingsPanel(sharedPage);
await closeMcpSettingsPanel(sharedPage, translations);
});

test('Toggle works as expected', async () => {
const serverName = 'mcp-integration-tools';
await openChatbot(sharedPage);
await openMcpSettingsPanel(sharedPage, translations);

const row = mcpServerRow(sharedPage, serverName);
await clickMcpServersStatusColumn(sharedPage);
await mcpServerToggle(sharedPage, serverName).click();
await expect(row.getByText('Disabled', { exact: true })).toBeVisible();
const row = mcpServerRow(sharedPage, serverName, translations);
await clickMcpServersStatusColumn(sharedPage, translations);
await mcpServerToggle(sharedPage, serverName, translations).click();
await expect(
row.getByText(translations['mcp.settings.status.disabled'], {
exact: true,
}),
).toBeVisible();

await mcpServerToggle(sharedPage, serverName).click();
await expect(row.getByText('14 tools', { exact: true })).toBeVisible();
await mcpServerToggle(sharedPage, serverName, translations).click();
await expect(
row.getByText(formatMcpToolCountStatus(translations, 14), {
exact: true,
}),
).toBeVisible();

await closeMcpSettingsPanel(sharedPage);
await closeMcpSettingsPanel(sharedPage, translations);
});

test.describe('Configure MCP server token', () => {
let mcpToken: McpConfigureTokenPage;

test.beforeEach(() => {
mcpToken = new McpConfigureTokenPage(sharedPage, translations);
});

test('Valid token saves and row shows tools — Overlay', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialValidationScenario,
'Overlay',
);

const serverName = 'credential-test-mcp';
await mcpToken.seeRowStatus(
serverName,
translations['mcp.settings.status.tokenRequired'],
);

await mcpToken.openEditServer(serverName);
await mcpToken.typeToken(E2E_MCP_VALID_TOKEN);
await mcpToken.save();

await mcpToken.seeTokenHidden();
await mcpToken.seeRowStatus(
serverName,
formatMcpToolCountStatus(translations, 5),
);

await mcpToken.closeMcpPanel();
});

test('Invalid token then valid token — Dock to window', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialValidationScenario,
'Dock to window',
);

const serverName = 'credential-test-mcp';
await mcpToken.openEditServer(serverName);

await mcpToken.typeToken('bad-token');
await mcpToken.save();
await mcpToken.seeMessage(
translations['mcp.settings.token.invalidCredentials'],
);

await mcpToken.typeToken(E2E_MCP_VALID_TOKEN);
await mcpToken.save();
await mcpToken.seeTokenHidden();
await mcpToken.seeRowStatus(
serverName,
formatMcpToolCountStatus(translations, 5),
);

await mcpToken.closeMcpPanel();
});

test('Cancel discards without saving — Fullscreen', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialValidationScenario,
'Fullscreen',
);

const serverName = 'credential-test-mcp';
await mcpToken.openEditServer(serverName);
await mcpToken.typeToken('draft-token');
await mcpToken.cancel();

await mcpToken.seeModalClosed();
await mcpToken.seeRowStatus(
serverName,
translations['mcp.settings.status.tokenRequired'],
);

await mcpToken.closeMcpPanel();
});

test('Server validation failure shows error — Overlay', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialValidationScenario,
'Overlay',
{
failServerValidateFor: 'credential-test-mcp',
failServerValidateError:
translations['mcp.settings.token.validationFailed'],
},
);

const serverName = 'credential-test-mcp';
await mcpToken.openEditServer(serverName);
await mcpToken.typeToken(E2E_MCP_VALID_TOKEN);
await mcpToken.save();

await mcpToken.seeMessage(
translations['mcp.settings.token.validationFailed'],
);
await mcpToken.cancel();
await mcpToken.closeMcpPanel();
});

test('Missing server URL shows error — Dock to window', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialNoUrlScenario,
'Dock to window',
);

const serverName = 'no-url-mcp';
await mcpToken.openEditServer(serverName);
await mcpToken.typeToken(E2E_MCP_VALID_TOKEN);
await mcpToken.save();

await mcpToken.seeMessage(
translations['mcp.settings.token.urlUnavailableForValidation'],
);
await mcpToken.cancel();
await mcpToken.closeMcpPanel();
});

test('Clear token input empties PAT — Fullscreen', async () => {
await mcpToken.gotoMcpSettings(
tokenCredentialNoUrlScenario,
'Fullscreen',
);

await mcpToken.openEditServer('no-url-mcp');
await mcpToken.typeThenClearToken('e2e-draft-personal-access-token');

await mcpToken.cancel();
await mcpToken.closeMcpPanel();
});
});
});

Expand Down
Loading
Loading