diff --git a/workspaces/lightspeed/.changeset/sixty-chefs-refuse.md b/workspaces/lightspeed/.changeset/sixty-chefs-refuse.md new file mode 100644 index 00000000000..ac660373eb8 --- /dev/null +++ b/workspaces/lightspeed/.changeset/sixty-chefs-refuse.md @@ -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. diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/mcpServerMocks.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/mcpServerMocks.ts index 29cceff652c..e439a83e990 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/mcpServerMocks.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/mcpServerMocks.ts @@ -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. @@ -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. */ @@ -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; diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/responses.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/responses.ts index 042389641d5..95e3e9afa8f 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/responses.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/fixtures/responses.ts @@ -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'; diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/lightspeed.test.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/lightspeed.test.ts index 2c37b47b810..5f96a6f125b 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/lightspeed.test.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/lightspeed.test.ts @@ -23,7 +23,10 @@ import { botResponse, moreConversations, mockedShields, + E2E_MCP_VALID_TOKEN, mcpServerScenarios, + tokenCredentialNoUrlScenario, + tokenCredentialValidationScenario, type McpServersListMock, thinkingContent, assistantResponse, @@ -59,7 +62,9 @@ import { clickMcpServersStatusColumn, clickMcpServersNameColumn, mcpServersTableBodyRows, + type DisplayMode, } from './pages/LightspeedPage'; +import { McpConfigureTokenPage } from './pages/McpConfigureTokenPage'; import { uploadFiles, uploadAndAssertDuplicate, @@ -128,10 +133,10 @@ import { 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'; @@ -213,12 +218,13 @@ test.describe('Lightspeed tests', () => { 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) { + await openChatbot(sharedPage); + await selectDisplayMode(sharedPage, translations, mode); + } + + test.beforeEach(async () => { await sharedPage.goto('/'); }); @@ -271,15 +277,15 @@ test.describe('Lightspeed tests', () => { 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 () => { @@ -287,15 +293,156 @@ test.describe('Lightspeed tests', () => { 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(); + }); }); }); diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/LightspeedPage.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/LightspeedPage.ts index 7bc75b3e38b..704fbdfc50c 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/LightspeedPage.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/LightspeedPage.ts @@ -16,17 +16,19 @@ import { Page, expect, type Locator } from '@playwright/test'; import { + getExpectedMcpSelectedCountForMock, getExpectedMcpStatusDetailForMock, mockedMcpServersResponse, type McpServersListMock, } from '../fixtures/responses'; -import { LightspeedMessages, evaluateMessage } from '../utils/translations'; +import { + LightspeedMessages, + evaluateMessage, + formatMcpSelectedCount, +} from '../utils/translations'; export type DisplayMode = 'Overlay' | 'Dock to window' | 'Fullscreen'; -/** Menu label in LightspeedChatBoxHeader (not yet in i18n). */ -export const MCP_SETTINGS_MENU_ITEM = 'MCP settings'; - // Actions export async function openChatbot(page: Page) { await page.getByRole('button', { name: 'lightspeed-close' }).click(); @@ -82,72 +84,207 @@ export async function verifyDisplayModeMenuOptions( t: LightspeedMessages, ) { await page.getByRole('button', { name: t['aria.settings.label'] }).click(); - await expect(page.getByLabel('Chatbot', { exact: true })) - .toMatchAriaSnapshot(` - - menu: - - menuitem "${t['settings.displayMode.label']}" [disabled] - - menuitem "${t['settings.displayMode.overlay']}" - - menuitem "${t['settings.displayMode.docked']}" - - menuitem "${t['settings.displayMode.fullscreen']}" - - separator - - menu: - - menuitem "${t['settings.pinned.disable']} ${t['settings.pinned.enabled.description']}" - - menuitem "${MCP_SETTINGS_MENU_ITEM}" - `); + const settingsMenu = page + .getByRole('menu') + .filter({ + has: page.getByRole('menuitem', { + name: t['settings.displayMode.label'], + }), + }) + .first(); + + await expect(settingsMenu).toBeVisible(); + await expect( + settingsMenu.getByRole('menuitem', { + name: t['settings.displayMode.label'], + }), + ).toBeDisabled(); + await expect( + settingsMenu.getByRole('menuitem', { + name: t['settings.displayMode.overlay'], + }), + ).toBeVisible(); + await expect( + settingsMenu.getByRole('menuitem', { + name: t['settings.displayMode.docked'], + }), + ).toBeVisible(); + await expect( + settingsMenu.getByRole('menuitem', { + name: t['settings.displayMode.fullscreen'], + }), + ).toBeVisible(); + + await expect( + page.getByRole('menuitem', { + name: `${t['settings.pinned.disable']} ${t['settings.pinned.enabled.description']}`, + }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: t['settings.mcp.label'] }), + ).toBeVisible(); } -// MCP settings (McpServersSettings — English strings until full i18n) +// MCP settings (McpServersSettings — strings from `mcp.settings.*` translations) export async function openMcpSettingsPanel(page: Page, t: LightspeedMessages) { await page.getByRole('button', { name: t['aria.settings.label'] }).click(); await expect( - page.getByRole('menuitem', { name: MCP_SETTINGS_MENU_ITEM }), + page.getByRole('menuitem', { name: t['settings.mcp.label'] }), ).toBeVisible(); - await page.getByRole('menuitem', { name: MCP_SETTINGS_MENU_ITEM }).click(); + await page.getByRole('menuitem', { name: t['settings.mcp.label'] }).click(); } -export async function closeMcpSettingsPanel(page: Page) { - await page.getByRole('button', { name: 'Close MCP settings' }).click(); +export async function closeMcpSettingsPanel(page: Page, t: LightspeedMessages) { + await page + .getByRole('button', { name: t['mcp.settings.closeAriaLabel'] }) + .click(); } -export function mcpServersTable(page: Page): Locator { - return page.getByLabel('MCP servers table'); +export function mcpServersTable(page: Page, t: LightspeedMessages): Locator { + return page.getByLabel(t['mcp.settings.tableAriaLabel']); } -export function mcpServersTableBodyRows(page: Page): Locator { - return mcpServersTable(page).locator('tbody tr'); +export function mcpServersTableBodyRows( + page: Page, + t: LightspeedMessages, +): Locator { + return mcpServersTable(page, t).locator('tbody tr'); } -export function mcpServerRow(page: Page, serverName: string): Locator { - return mcpServersTableBodyRows(page).filter({ hasText: serverName }); +export function mcpServerRow( + page: Page, + serverName: string, + t: LightspeedMessages, +): Locator { + return mcpServersTableBodyRows(page, t).filter({ hasText: serverName }); } -export function mcpServerToggle(page: Page, serverName: string): Locator { - return mcpServersTable(page) - .getByRole('gridcell', { name: `Toggle ${serverName}` }) +export function mcpServerToggle( + page: Page, + serverName: string, + t: LightspeedMessages, +): Locator { + return mcpServersTable(page, t) + .getByRole('gridcell', { + name: evaluateMessage( + t['mcp.settings.toggleServerAriaLabel'], + serverName, + ), + }) .locator('span'); } -export async function clickMcpServersStatusColumn(page: Page) { - await mcpServersTable(page) - .getByRole('columnheader', { name: 'Status' }) +export function mcpEditServerButton( + page: Page, + serverName: string, + t: LightspeedMessages, +): Locator { + return page.getByRole('button', { + name: evaluateMessage(t['mcp.settings.editServerAriaLabel'], serverName), + }); +} + +export function mcpPersonalAccessTokenInput(page: Page): Locator { + return page.locator('#mcp-pat-input'); +} + +/** Configure-server modal that contains the PAT field (avoids matching other dialogs). */ +export function mcpCredentialConfigureModal(page: Page): Locator { + return page + .getByRole('dialog') + .filter({ has: page.locator('#mcp-pat-input') }); +} + +/** Clear (×) control on the PAT field (`mcp.settings.token.clearAriaLabel`). */ +export function mcpClearTokenInputButton( + page: Page, + t: LightspeedMessages, +): Locator { + return mcpCredentialConfigureModal(page).getByRole('button', { + name: t['mcp.settings.token.clearAriaLabel'], + }); +} + +export function mcpConfigureModalSaveButton( + page: Page, + t: LightspeedMessages, +): Locator { + return mcpCredentialConfigureModal(page).getByRole('button', { + name: t['modal.save'], + }); +} + +export function mcpConfigureModalCancelButton( + page: Page, + t: LightspeedMessages, +): Locator { + return mcpCredentialConfigureModal(page).getByRole('button', { + name: t['modal.cancel'], + exact: true, + }); +} + +/** Validation/helper line under the PAT field after Save (matches i18n `mcp.settings.token.*` copy). */ +export function mcpConfigureModalMessage( + page: Page, + exactText: string, +): Locator { + return mcpCredentialConfigureModal(page).getByText(exactText, { + exact: true, + }); +} + +/** + * Asserts configure-server modal is ready: Close, Clear, Save, Cancel, and PAT field. + */ +export async function expectMcpConfigureModalReady( + page: Page, + t: LightspeedMessages, +) { + await expect( + mcpCredentialConfigureModal(page).getByRole('button', { + name: t['mcp.settings.closeConfigureModalAriaLabel'], + }), + ).toBeVisible(); + await expect(mcpClearTokenInputButton(page, t)).toBeVisible(); + await expect(mcpConfigureModalSaveButton(page, t)).toBeVisible(); + await expect(mcpConfigureModalCancelButton(page, t)).toBeVisible(); + await expect(mcpPersonalAccessTokenInput(page)).toBeVisible(); +} + +export async function clickMcpServersStatusColumn( + page: Page, + t: LightspeedMessages, +) { + await mcpServersTable(page, t) + .getByRole('columnheader', { name: t['mcp.settings.status'] }) .click(); } -export async function clickMcpServersNameColumn(page: Page) { - await mcpServersTable(page).getByRole('button', { name: 'Name' }).click(); +export async function clickMcpServersNameColumn( + page: Page, + t: LightspeedMessages, +) { + await mcpServersTable(page, t) + .getByRole('button', { name: t['mcp.settings.name'] }) + .click(); } -function mcpServersSettingsHeading(page: Page): Locator { - return page.getByRole('heading', { name: 'MCP servers', exact: true }); +function mcpServersSettingsHeading(page: Page, t: LightspeedMessages): Locator { + return page.getByRole('heading', { + name: t['mcp.settings.title'], + exact: true, + }); } /** Assert the MCP servers settings heading is shown or dismissed with the panel. */ export async function expectMcpServersSettingsHeading( page: Page, visible: boolean, + t: LightspeedMessages, ) { - const heading = mcpServersSettingsHeading(page); + const heading = mcpServersSettingsHeading(page, t); const assertion = visible ? expect(heading) : expect(heading).not; await assertion.toBeVisible(); } @@ -162,34 +299,42 @@ export async function verifyMcpSettingsPanel( ) { await openMcpSettingsPanel(page, t); - const table = mcpServersTable(page); + const table = mcpServersTable(page, t); await expect(table).toBeVisible(); - await expectMcpServersSettingsHeading(page, true); - await expect(page.getByText(/^\d+ of \d+ selected/)).toBeVisible(); + await expectMcpServersSettingsHeading(page, true, t); + const { selectedCount, totalCount } = + getExpectedMcpSelectedCountForMock(mcpList); + await expect( + page.getByText(formatMcpSelectedCount(t, selectedCount, totalCount), { + exact: true, + }), + ).toBeVisible(); // Scope to MCP grid: Dock/overlay leaves the catalog visible, which also has "Name" sort buttons. - await expect(table.getByRole('button', { name: 'Name' })).toBeVisible(); await expect( - table.getByRole('columnheader', { name: 'Status' }), + table.getByRole('button', { name: t['mcp.settings.name'] }), + ).toBeVisible(); + await expect( + table.getByRole('columnheader', { name: t['mcp.settings.status'] }), ).toBeVisible(); - await clickMcpServersStatusColumn(page); + await clickMcpServersStatusColumn(page, t); // Close + selected count live in the MCP header, not always inside
(fullscreen omits Settings/form wrapper). await expect( - page.getByRole('button', { name: 'Close MCP settings' }), + page.getByRole('button', { name: t['mcp.settings.closeAriaLabel'] }), ).toBeVisible(); if (mcpList.servers.length === 0) { await expect( - table.getByText('No MCP servers available.', { exact: true }), + table.getByText(t['mcp.settings.noneAvailable'], { exact: true }), ).toBeVisible(); } else { for (const server of mcpList.servers) { - const row = mcpServerRow(page, server.name); + const row = mcpServerRow(page, server.name, t); await expect(row.getByText(server.name, { exact: true })).toBeVisible(); await expect( - row.getByText(getExpectedMcpStatusDetailForMock(server), { + row.getByText(getExpectedMcpStatusDetailForMock(server, t), { exact: true, }), ).toBeVisible(); @@ -203,8 +348,8 @@ export async function verifyMcpSettingsPanel( - button "${t['aria.settings.label']}" `); - await closeMcpSettingsPanel(page); - await expectMcpServersSettingsHeading(page, false); + await closeMcpSettingsPanel(page, t); + await expectMcpServersSettingsHeading(page, false, t); } /** Chat composer message field (matches sendMessage in testHelper). */ diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/McpConfigureTokenPage.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/McpConfigureTokenPage.ts new file mode 100644 index 00000000000..0f1affea6d2 --- /dev/null +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/pages/McpConfigureTokenPage.ts @@ -0,0 +1,118 @@ +/* + * 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 { expect, type Locator, type Page } from '@playwright/test'; +import type { McpServersListMock } from '../fixtures/responses'; +import type { MockMcpServersOptions } from '../utils/devMode'; +import { mockMcpServers } from '../utils/devMode'; +import type { LightspeedMessages } from '../utils/translations'; +import { + closeMcpSettingsPanel, + expectMcpConfigureModalReady, + mcpClearTokenInputButton, + mcpConfigureModalCancelButton, + mcpConfigureModalMessage, + mcpConfigureModalSaveButton, + mcpCredentialConfigureModal, + mcpEditServerButton, + mcpPersonalAccessTokenInput, + mcpServerRow, + openChatbot, + openMcpSettingsPanel, + selectDisplayMode, + type DisplayMode, +} from './LightspeedPage'; + +export class McpConfigureTokenPage { + constructor( + private readonly page: Page, + private readonly t: LightspeedMessages, + ) {} + + async gotoMcpSettings( + mcpList: McpServersListMock, + mode: DisplayMode, + mockOptions?: MockMcpServersOptions, + ): Promise { + await mockMcpServers(this.page, mcpList, mockOptions ?? {}); + await openChatbot(this.page); + await selectDisplayMode(this.page, this.t, mode); + await openMcpSettingsPanel(this.page, this.t); + } + + async openEditServer(serverName: string): Promise { + await mcpEditServerButton(this.page, serverName, this.t).click(); + await expectMcpConfigureModalReady(this.page, this.t); + } + + async seeRowStatus(serverName: string, text: string): Promise { + await expect( + mcpServerRow(this.page, serverName, this.t).getByText(text, { + exact: true, + }), + ).toBeVisible(); + } + + async typeToken(value: string): Promise { + await mcpPersonalAccessTokenInput(this.page).fill(value); + await expect(mcpPersonalAccessTokenInput(this.page)).toHaveValue(value); + await expect( + mcpConfigureModalMessage(this.page, this.t['mcp.settings.enterToken']), + ).toBeVisible(); + } + + async save(): Promise { + await mcpConfigureModalSaveButton(this.page, this.t).click(); + } + + async cancel(): Promise { + await mcpConfigureModalCancelButton(this.page, this.t).click(); + } + + async seeMessage(text: string): Promise { + await expect(mcpConfigureModalMessage(this.page, text)).toBeVisible(); + } + + async seeModalClosed(): Promise { + await expect(mcpCredentialConfigureModal(this.page)).not.toBeVisible(); + } + + async seeTokenHidden(): Promise { + await expect(mcpPersonalAccessTokenInput(this.page)).not.toBeVisible(); + } + + async closeMcpPanel(): Promise { + await closeMcpSettingsPanel(this.page, this.t); + } + + tokenField(): Locator { + return mcpPersonalAccessTokenInput(this.page); + } + + async clearToken(): Promise { + await mcpClearTokenInputButton(this.page, this.t).click(); + } + + async typeThenClearToken(draft: string): Promise { + const field = this.tokenField(); + await field.click(); + await field.fill(draft); + await expect(field).toHaveValue(draft); + await this.clearToken(); + await expect(field).toHaveValue(''); + await this.seeMessage(this.t['mcp.settings.enterToken']); + } +} diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/devMode.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/devMode.ts index ebf20feb064..5444375ac04 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/devMode.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/devMode.ts @@ -15,12 +15,27 @@ */ import { Page } from '@playwright/test'; import { + E2E_MCP_VALID_TOKEN, generateQueryResponse, mockedMcpServersResponse, modelBaseUrl, type McpServersListMock, } from '../fixtures/responses'; +let mcpServersMockState: McpServersListMock; + +export type MockMcpServersOptions = { + failServerValidateFor?: string; + /** Shown as `validation.error` when POST validate fails for `failServerValidateFor` (use product i18n, e.g. `mcp.settings.token.validationFailed`). */ + failServerValidateError?: string; +}; + +let mcpMockOptions: MockMcpServersOptions = {}; + +function cloneMcpServersMock(json: McpServersListMock): McpServersListMock { + return JSON.parse(JSON.stringify(json)) as McpServersListMock; +} + export async function mockModels(page: Page, models: any[]) { await page.route(`${modelBaseUrl}/v1/models`, async route => { const json = { models }; @@ -106,13 +121,16 @@ export async function mockQueryWithResponseDelay( const mcpServersRouteGlob = `${modelBaseUrl}/mcp-servers**`; /** - * Mocks GET `/api/lightspeed/mcp-servers` and PATCH `/api/lightspeed/mcp-servers/:name` - * (toggle enabled). Other methods (e.g. POST validate) pass through to the backend. + * Mocks GET/PATCH `/api/lightspeed/mcp-servers` and POST credential/server validation. + * Keeps in-memory state so PATCH (e.g. token) is reflected on subsequent GET. */ export async function mockMcpServers( page: Page, json: McpServersListMock = mockedMcpServersResponse, + options: MockMcpServersOptions = {}, ) { + mcpServersMockState = cloneMcpServersMock(json); + mcpMockOptions = options; await page.unroute(mcpServersRouteGlob); await page.route(mcpServersRouteGlob, async route => { const req = route.request(); @@ -121,7 +139,78 @@ export async function mockMcpServers( const segments = suffix.split('/').filter(Boolean); if (req.method() === 'GET' && segments.length === 0) { - await route.fulfill({ json }); + await route.fulfill({ json: mcpServersMockState }); + return; + } + + if ( + req.method() === 'POST' && + segments.length === 1 && + segments[0] === 'validate' + ) { + let body: { url?: string; token?: string }; + try { + body = req.postDataJSON(); + } catch { + await route.fulfill({ status: 400, json: { error: 'Invalid JSON' } }); + return; + } + const valid = body.token === E2E_MCP_VALID_TOKEN; + await route.fulfill({ + json: valid + ? { valid: true, toolCount: 1 } + : { + valid: false, + toolCount: 0, + }, + }); + return; + } + + if ( + req.method() === 'POST' && + segments.length === 2 && + segments[1] === 'validate' + ) { + const name = decodeURIComponent(segments[0]!); + if (mcpMockOptions.failServerValidateFor === name) { + await route.fulfill({ + json: { + name, + status: 'error' as const, + toolCount: 0, + validation: { + error: + mcpMockOptions.failServerValidateError ?? + 'Upstream MCP validation failed.', + }, + }, + }); + return; + } + + // Mirror GET list data. The real UI calls validate after load for each server with a + // token; a static { toolCount: 5 } here overwrote scenario tool counts (14 tools, etc.). + const server = mcpServersMockState.servers.find(s => s.name === name); + if (!server) { + await route.fulfill({ + status: 404, + json: { error: `MCP server '${name}' is not configured` }, + }); + return; + } + + const toolCount = server.toolCount; + await route.fulfill({ + json: { + name, + status: server.status, + toolCount, + ...(server.status === 'error' + ? { validation: { error: 'MCP validation failed' } } + : {}), + }, + }); return; } @@ -134,7 +223,7 @@ export async function mockMcpServers( await route.fulfill({ status: 400, json: { error: 'Invalid JSON' } }); return; } - const server = json.servers.find(s => s.name === name); + const server = mcpServersMockState.servers.find(s => s.name === name); if (!server) { await route.fulfill({ status: 404, @@ -144,11 +233,24 @@ export async function mockMcpServers( }); return; } - const updated = { - ...server, - enabled: body.enabled ?? server.enabled, - }; - await route.fulfill({ json: { server: updated } }); + if (body.enabled !== undefined) { + server.enabled = body.enabled; + } + if (Object.prototype.hasOwnProperty.call(body, 'token')) { + const tok = body.token; + if (tok && String(tok).trim().length > 0) { + server.hasToken = true; + server.hasUserToken = true; + server.status = 'connected'; + server.toolCount = 5; + } else { + server.hasToken = false; + server.hasUserToken = false; + server.status = 'unknown'; + server.toolCount = 0; + } + } + await route.fulfill({ json: { server } }); return; } diff --git a/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/translations.ts b/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/translations.ts index 5959dc15eaa..ced48a67d04 100644 --- a/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/translations.ts +++ b/workspaces/lightspeed/packages/app-legacy/e2e-tests/utils/translations.ts @@ -58,3 +58,26 @@ export function evaluateMessage(message: string, value: string) { message.substring(0, startIndex) + value + message.substring(endIndex + 2) ); } + +/** Renders `mcp.settings.selectedCount` for assertions (matches i18n placeholder order per locale). */ +export function formatMcpSelectedCount( + t: LightspeedMessages, + selectedCount: number, + totalCount: number, +): string { + return t['mcp.settings.selectedCount'] + .replace(/\{\{selectedCount\}\}/g, String(selectedCount)) + .replace(/\{\{totalCount\}\}/g, String(totalCount)); +} + +/** Status cell detail for a connected server tool count (singular vs plural). */ +export function formatMcpToolCountStatus( + t: LightspeedMessages, + toolCount: number, +): string { + const key = + toolCount === 1 + ? 'mcp.settings.status.oneTool' + : 'mcp.settings.status.manyTools'; + return t[key].replace(/\{\{count\}\}/g, String(toolCount)); +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/README.md b/workspaces/lightspeed/plugins/lightspeed-backend/README.md index 58a92be3636..228d966acde 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/README.md +++ b/workspaces/lightspeed/plugins/lightspeed-backend/README.md @@ -33,11 +33,29 @@ Add the following lightspeed configurations into your `app-config.yaml` file: lightspeed: servicePort: # Optional - Change the LS service port nubmer. Defaults to 8080. systemPrompt: # Optional - Override the default system prompt. - mcpServers: # Optional - only one mcp server is currently supported + mcpServers: # Optional - one or more MCP servers - name: # must match the name configured in LCS token: ${MCP_TOKEN} ``` +#### MCP servers settings endpoints + +The backend exposes MCP server management endpoints used by the Lightspeed UI +settings panel: + +- `GET /api/lightspeed/mcp-servers` lists configured servers and user-scoped + state. +- `PATCH /api/lightspeed/mcp-servers/:name` updates user settings such as + enabled state and token. + +- `POST /api/lightspeed/mcp-servers/validate` validates a raw `{ url, token }` + pair and returns whether credentials are valid. +- `POST /api/lightspeed/mcp-servers/:name/validate` validates a configured server + by name using the effective token (user token override or configured token). + +These endpoints power server selection and token configuration, including inline +success/error feedback while users enter tokens. + #### Permission Framework Support The Lightspeed Backend plugin has support for the permission framework. diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/sessions/sessionService.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/sessions/sessionService.test.ts index 77400d4b0f1..e6ddc02ce2e 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/sessions/sessionService.test.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/sessions/sessionService.test.ts @@ -32,7 +32,7 @@ describe('SessionService', () => { const mockUserId = 'user:default/guest'; const mockUserId2 = 'user:default/other'; - let service: SessionService; + let service!: SessionService; beforeAll(() => { // ERROR on unhandled requests to catch any real HTTP calls diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts index 8977e2ca442..bba1caa65c5 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts @@ -337,8 +337,17 @@ export async function createRouter( return; } - const { enabled, token } = req.body; - if (enabled === undefined && token === undefined) { + const body = (req.body ?? {}) as { + enabled?: boolean; + token?: string | null; + }; + const hasEnabledField = Object.prototype.hasOwnProperty.call( + body, + 'enabled', + ); + const hasTokenField = Object.prototype.hasOwnProperty.call(body, 'token'); + const { enabled, token } = body; + if (!hasEnabledField && !hasTokenField) { res.status(400).json({ error: 'At least one of enabled or token must be provided', }); @@ -346,8 +355,8 @@ export async function createRouter( } const setting = await settingsStore.upsert(name, user.userEntityRef, { - enabled, - token, + enabled: hasEnabledField ? enabled : undefined, + token: hasTokenField ? token : undefined, }); let validation: McpValidationResult | undefined; diff --git a/workspaces/lightspeed/plugins/lightspeed/README.md b/workspaces/lightspeed/plugins/lightspeed/README.md index 27dd295819e..90621721290 100644 --- a/workspaces/lightspeed/plugins/lightspeed/README.md +++ b/workspaces/lightspeed/plugins/lightspeed/README.md @@ -87,6 +87,25 @@ Lightspeed is a front-end plugin that enables you to interact with any LLM serve 1. Open your Backstage application and select a Lightspeed nav item from the **Navigation**. 2. Ask you questions to the Lightspeed chatbot. +### MCP servers settings + +Lightspeed includes an MCP servers settings panel where users can: + +- View configured MCP servers and current status +- Enable or disable eligible servers +- Configure a personal token per server +- See inline status and validation feedback + +#### Token validation behavior + +When configuring a server token in the settings modal, the token is validated +automatically after typing stops briefly. The input shows inline feedback: + +- Success: `Connection successful` +- Error: `Authorization failed. Try again.` + +Users can then save the configuration after validation feedback is displayed. + ### AI Notebooks (Developer Preview) AI Notebooks is an experimental feature that enables **document-based conversations with Retrieval-Augmented Generation (RAG)**. This feature allows you to: diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 09ba1833902..05efe70d023 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -160,6 +160,41 @@ export const lightspeedTranslationRef: TranslationRef< readonly 'settings.pinned.disable': string; readonly 'settings.pinned.enabled.description': string; readonly 'settings.pinned.disabled.description': string; + readonly 'settings.mcp.label': string; + readonly 'mcp.settings.title': string; + readonly 'mcp.settings.selectedCount': string; + readonly 'mcp.settings.closeAriaLabel': string; + readonly 'mcp.settings.readOnlyAccess': string; + readonly 'mcp.settings.tableAriaLabel': string; + readonly 'mcp.settings.enabled': string; + readonly 'mcp.settings.name': string; + readonly 'mcp.settings.status': string; + readonly 'mcp.settings.edit': string; + readonly 'mcp.settings.loading': string; + readonly 'mcp.settings.noneAvailable': string; + readonly 'mcp.settings.status.disabled': string; + readonly 'mcp.settings.status.tokenRequired': string; + readonly 'mcp.settings.status.failed': string; + readonly 'mcp.settings.status.oneTool': string; + readonly 'mcp.settings.status.manyTools': string; + readonly 'mcp.settings.status.unknown': string; + readonly 'mcp.settings.toggleServerAriaLabel': string; + readonly 'mcp.settings.editServerAriaLabel': string; + readonly 'mcp.settings.configureServerTitle': string; + readonly 'mcp.settings.closeConfigureModalAriaLabel': string; + readonly 'mcp.settings.modalDescription': string; + readonly 'mcp.settings.savedToken': string; + readonly 'mcp.settings.personalAccessToken': string; + readonly 'mcp.settings.usingAdminCredential': string; + readonly 'mcp.settings.enterToken': string; + readonly 'mcp.settings.removePersonalToken': string; + readonly 'mcp.settings.token.clearAriaLabel': string; + readonly 'mcp.settings.token.validating': string; + readonly 'mcp.settings.token.savingAndValidating': string; + readonly 'mcp.settings.token.urlUnavailableForValidation': string; + readonly 'mcp.settings.token.invalidCredentials': string; + readonly 'mcp.settings.token.validationFailed': string; + readonly 'mcp.settings.token.connectionSuccessful': string; readonly 'toolCall.header': string; readonly 'toolCall.thinking': string; readonly 'toolCall.executionTime': string; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 03f14d75649..b97a1bc0ff0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -336,8 +336,6 @@ 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: 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', @@ -377,21 +375,24 @@ const useStyles = makeStyles(theme => ({ }, }, mcpFullscreenLayout: { - display: 'flex', + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', minHeight: 0, height: '100%', flex: 1, width: '100%', + minWidth: 0, + overflow: 'hidden', }, mcpChatPane: { display: 'flex', flexDirection: 'column', minHeight: 0, - flex: 1, + width: '100%', minWidth: 0, }, mcpSettingsPane: { - flex: 1, + width: '100%', minWidth: 0, borderLeft: `1px solid ${theme.palette.divider}`, backgroundColor: @@ -400,6 +401,18 @@ const useStyles = makeStyles(theme => ({ flexDirection: 'column', minHeight: 0, }, + mcpCollapsedDrawerOrderFix: { + '& .pf-v6-c-drawer.pf-m-panel-left > .pf-v6-c-drawer__main > .pf-v6-c-drawer__content, & .pf-v5-c-drawer.pf-m-panel-left > .pf-v5-c-drawer__main > .pf-v5-c-drawer__content': + { + order: 'unset', + }, + '& .pf-v6-c-drawer:not(.pf-m-expanded) > .pf-v6-c-drawer__main > .pf-v6-c-drawer__panel, & .pf-v5-c-drawer:not(.pf-m-expanded) > .pf-v5-c-drawer__main > .pf-v5-c-drawer__panel': + { + visibility: 'hidden', + opacity: 0, + transition: 'none !important', + }, + }, })); type LightspeedChatProps = { @@ -455,6 +468,7 @@ 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); @@ -506,6 +520,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, @@ -1151,7 +1175,10 @@ export const LightspeedChat = ({ ); const mcpSettingsPanel = ( - setIsMcpSettingsOpen(false)} /> + setIsMcpSettingsOpen(false)} + backgroundColor={chatHeaderBgColor} + /> ); const mainPanelContent = (() => { @@ -1253,15 +1280,17 @@ export const LightspeedChat = ({ )} - setIsChatHistoryDrawerOpen(!isChatHistoryDrawerOpen) - } + onMenuToggle={onChatHistoryDrawerToggle} className={classes.headerMenu} tooltipContent={t('tooltip.chatHistoryMenu')} aria-label={t('aria.chatHistoryMenu')} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 8dac49c2ea9..4e07c40d204 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -239,7 +239,7 @@ export const LightspeedChatBoxHeader = ({ icon={} onClick={onMcpSettingsClick} > - MCP settings + {t('settings.mcp.label')} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx index 83fb1c7c1a0..4d0e7fc1d0e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx @@ -14,19 +14,32 @@ * limitations under the License. */ -import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { 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 CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; +import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; +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, Switch, Title, Tooltip } from '@patternfly/react-core'; +import { + Alert, + Button, + Modal, + Switch, + Title, + Tooltip, +} from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon, KeyIcon, + LockIcon, OffIcon, SortAmountDownIcon, SortAmountUpIcon, @@ -36,11 +49,14 @@ 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'; type McpServer = { id: string; name: string; + url?: string; enabled: boolean; status: 'connected' | 'error' | 'unknown'; toolCount: number; @@ -51,17 +67,28 @@ type McpServer = { type McpServersSettingsProps = { onClose: () => void; + backgroundColor?: string; }; +type TokenValidationState = 'idle' | 'validating' | 'success' | 'error'; + +const SAVED_TOKEN_MASK = '********************'; + const useStyles = makeStyles(theme => ({ + '@global': { + '.pf-v6-c-backdrop': { + zIndex: '1400 !important', + }, + '.pf-v5-c-backdrop': { + zIndex: '1400 !important', + }, + }, root: { padding: 0, height: '100%', minHeight: '100%', width: '100%', overflow: 'auto', - backgroundColor: - 'var(--pf-v6-c-table--BackgroundColor, var(--pf-t--global--background--color--primary--default))', }, headerRow: { display: 'flex', @@ -147,6 +174,143 @@ const useStyles = makeStyles(theme => ({ actionButton: { color: theme.palette.text.secondary, }, + modalDescription: { + color: theme.palette.text.secondary, + fontSize: '0.875rem', + marginTop: theme.spacing(2), + 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: '#1F1F1F', + }, + 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', + lineHeight: 1.4, + fontWeight: 500, + }, + }, + tokenRow: { + position: 'relative', + }, + tokenClearButton: { + position: 'absolute', + right: theme.spacing(1), + top: '50%', + transform: 'translateY(-50%)', + zIndex: 1, + color: theme.palette.text.secondary, + }, + tokenHelper: { + 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', + }, + }, + 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', + }, + }, + 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', + }, + }, + modalActions: { + marginTop: theme.spacing(3), + display: 'flex', + gap: theme.spacing(1), + }, + modalActionButton: { + fontSize: '1rem', + }, + modalCancelButton: { + fontSize: '1rem', + }, + forgetTokenButton: { + fontSize: '1rem', + border: '1px solid #B1380B', + 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)', + }, + }, + configureModal: { + '& .pf-v6-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': { + display: 'none', + }, + '& .pf-v5-c-modal-box__close': { + display: 'none', + }, + '& .pf-v6-c-button__icon': { + paddingTop: '5px !important', + fontSize: '1.25rem !important', + }, + }, toggleCell: { paddingRight: '0 !important', }, @@ -176,6 +340,7 @@ const useStyles = makeStyles(theme => ({ type McpServerResponse = { name: string; + url?: string; enabled: boolean; status: 'connected' | 'error' | 'unknown'; toolCount: number; @@ -203,6 +368,12 @@ type McpServersValidateResponse = { }; }; +type McpCredentialsValidateResponse = { + valid: boolean; + error?: string; + toolCount: number; +}; + const getStatusIcon = (status: ServerStatus, className: string) => { if (status === 'tokenRequired') return ; if (status === 'disabled') return ; @@ -219,26 +390,13 @@ const getDisplayStatus = (server: McpServer): ServerStatus => { return 'unknown'; }; -const getDisplayDetail = ( - server: McpServer, - displayStatus: ServerStatus, -): string => { - if (displayStatus === 'disabled') return 'Disabled'; - if (displayStatus === 'tokenRequired') return 'Token required'; - if (displayStatus === 'failed') return 'Failed'; - if (displayStatus === 'ok') { - const suffix = server.toolCount === 1 ? 'tool' : 'tools'; - return `${server.toolCount} ${suffix}`; - } - return 'Unknown'; -}; - const toUiServer = ( server: McpServerResponse, validationError?: string, ): McpServer => ({ id: server.name, name: server.name, + url: server.url, enabled: server.enabled, status: server.status, toolCount: server.toolCount, @@ -246,9 +404,12 @@ const toUiServer = ( hasUserToken: server.hasUserToken, validationError: server.status === 'error' ? validationError : undefined, }); - -export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { +export const McpServersSettings = ({ + onClose, + backgroundColor, +}: McpServersSettingsProps) => { const classes = useStyles(); + const { t } = useTranslation(); const configApi = useApi(configApiRef); const fetchApi = useApi(fetchApiRef); const mcpManagePermission = usePermission({ @@ -260,6 +421,13 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState>({}); const [error, setError] = useState(null); + const [editingServerId, setEditingServerId] = useState(null); + const [tokenInputValue, setTokenInputValue] = useState(''); + const [hasSavedTokenInModal, setHasSavedTokenInModal] = useState(false); + const [canRemovePersonalToken, setCanRemovePersonalToken] = useState(false); + const [tokenValidationState, setTokenValidationState] = + useState('idle'); + const [tokenValidationMessage, setTokenValidationMessage] = useState(''); const getBaseUrl = useCallback(() => { return `${configApi.getString('backend.baseUrl')}/api/lightspeed`; @@ -320,10 +488,24 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { : server, ), ); + return data; }, [fetchJson, getBaseUrl], ); + const validateCredentials = useCallback( + async (url: string, token: string) => { + const baseUrl = getBaseUrl(); + return await fetchJson( + `${baseUrl}/mcp-servers/validate`, + { + method: 'POST', + body: JSON.stringify({ url, token }), + }, + ); + }, + [fetchJson, getBaseUrl], + ); const loadServers = useCallback(async () => { setIsLoading(true); setError(null); @@ -403,6 +585,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { ? e.message : `Failed to update ${serverName} settings`, ); + throw e; } finally { setIsSaving(prev => ({ ...prev, [serverName]: false })); } @@ -410,6 +593,11 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => { [canManageMcp, fetchJson, getBaseUrl, loadServers], ); + const editingServer = useMemo( + () => servers.find(server => server.id === editingServerId), + [servers, editingServerId], + ); + const selectedCount = useMemo( () => servers.filter(server => { @@ -429,24 +617,229 @@ 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(); + const getDisplayDetail = useCallback( + (server: McpServer, displayStatus: ServerStatus): string => { + if (displayStatus === 'disabled') + return t('mcp.settings.status.disabled'); + if (displayStatus === 'tokenRequired') + return t('mcp.settings.status.tokenRequired'); + if (displayStatus === 'failed') return t('mcp.settings.status.failed'); + if (displayStatus === 'ok') { + if (server.toolCount === 1) { + return t('mcp.settings.status.oneTool' as any, { + count: String(server.toolCount), + }); + } + return t('mcp.settings.status.manyTools' as any, { + count: String(server.toolCount), + }); + } + return t('mcp.settings.status.unknown'); + }, + [t], + ); + + const closeConfigureModal = useCallback(() => { + setEditingServerId(null); + setTokenInputValue(''); + setHasSavedTokenInModal(false); + setCanRemovePersonalToken(false); + setTokenValidationState('idle'); + setTokenValidationMessage(''); }, []); + const openConfigureModal = (server: McpServer) => { + setEditingServerId(server.id); + const hasSavedToken = server.hasToken; + setHasSavedTokenInModal(hasSavedToken); + setCanRemovePersonalToken(server.hasUserToken); + setTokenInputValue(hasSavedToken ? SAVED_TOKEN_MASK : ''); + if (server.status === 'error' && server.validationError) { + setTokenValidationState('error'); + setTokenValidationMessage(server.validationError); + } else { + setTokenValidationState('idle'); + setTokenValidationMessage(''); + } + }; + + const onTokenInputChange = (value: string) => { + if (hasSavedTokenInModal && value !== SAVED_TOKEN_MASK) { + setHasSavedTokenInModal(false); + } + setTokenInputValue(value); + setTokenValidationState('idle'); + setTokenValidationMessage(''); + }; + + const clearTokenInput = () => { + if (hasSavedTokenInModal) { + setHasSavedTokenInModal(false); + } + setTokenInputValue(''); + setTokenValidationState('idle'); + setTokenValidationMessage(''); + }; + + let tokenInputStateClass = ''; + let tokenHelperColor: string | undefined; + if (tokenValidationState === 'success') { + tokenInputStateClass = classes.tokenInputSuccess; + tokenHelperColor = '#3E8635'; + } else if (tokenValidationState === 'error') { + tokenInputStateClass = classes.tokenInputError; + tokenHelperColor = '#C9190B'; + } + + let tokenInputAdornment = ( + + + + ); + + if (tokenValidationState === 'success') { + tokenInputAdornment = ( + + ); + } else if (tokenValidationState === 'error') { + tokenInputAdornment = ( + + ); + } + + const isUsingOrganizationCredentialInModal = + hasSavedTokenInModal && !canRemovePersonalToken; + + const saveServerToken = useCallback(async () => { + if (!editingServer || !canManageMcp) return; + + if (hasSavedTokenInModal && tokenInputValue === SAVED_TOKEN_MASK) { + closeConfigureModal(); + return; + } + + const token = tokenInputValue.trim(); + const hasToken = token.length > 0; + + setTokenValidationState('validating'); + setTokenValidationMessage(t('mcp.settings.token.validating')); + + try { + if (hasToken) { + if (!editingServer.url) { + setTokenValidationState('error'); + setTokenValidationMessage( + t('mcp.settings.token.urlUnavailableForValidation'), + ); + return; + } + + const credentialValidation = await validateCredentials( + editingServer.url, + token, + ); + if (!credentialValidation.valid) { + setTokenValidationState('error'); + setTokenValidationMessage( + credentialValidation.error ?? + t('mcp.settings.token.invalidCredentials'), + ); + return; + } + } + + setTokenValidationMessage(t('mcp.settings.token.savingAndValidating')); + await patchServer(editingServer.name, { + enabled: editingServer.enabled, + token: hasToken ? token : null, + }); + const validationResult = await validateServer(editingServer.name); + if (validationResult.status === 'error') { + setTokenValidationState('error'); + setTokenValidationMessage( + validationResult.validation?.error ?? + t('mcp.settings.token.validationFailed'), + ); + return; + } + setTokenValidationState('success'); + setTokenValidationMessage(t('mcp.settings.token.connectionSuccessful')); + closeConfigureModal(); + } catch (e) { + setTokenValidationState('error'); + setTokenValidationMessage( + e instanceof Error + ? e.message + : `Failed to update ${editingServer.name} token`, + ); + } + }, [ + canManageMcp, + closeConfigureModal, + editingServer, + hasSavedTokenInModal, + patchServer, + t, + tokenInputValue, + validateCredentials, + validateServer, + ]); + + const removePersonalToken = () => { + setHasSavedTokenInModal(false); + setCanRemovePersonalToken(false); + setTokenInputValue(''); + setTokenValidationState('idle'); + setTokenValidationMessage(''); + }; + return ( -
+
- MCP servers + {t('mcp.settings.title')}
- {selectedCount} of {servers.length} selected + {t('mcp.settings.selectedCount' as any, { + selectedCount: String(selectedCount), + totalCount: String(servers.length), + })}
+ {canRemovePersonalToken && hasSavedTokenInModal && ( + + )} + +
+
+
); }; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index 08594ed1e78..709db24fc3a 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -212,6 +212,50 @@ const lightspeedTranslationDe = createTranslationMessages({ 'Angeheftete Chats sind derzeit aktiviert', 'settings.pinned.disabled.description': 'Angeheftete Chats sind derzeit deaktiviert.', + 'settings.mcp.label': 'MCP-Einstellungen', + 'mcp.settings.title': 'MCP-Server', + 'mcp.settings.selectedCount': + '{{selectedCount}} von {{totalCount}} ausgewählt', + 'mcp.settings.closeAriaLabel': 'MCP-Einstellungen schließen', + 'mcp.settings.readOnlyAccess': + 'Sie haben schreibgeschützten Zugriff auf MCP-Server.', + 'mcp.settings.tableAriaLabel': 'MCP-Server-Tabelle', + 'mcp.settings.enabled': 'Aktiviert', + 'mcp.settings.name': 'Name', + 'mcp.settings.status': 'Status', + 'mcp.settings.edit': 'Bearbeiten', + 'mcp.settings.loading': 'MCP-Server werden geladen...', + 'mcp.settings.noneAvailable': 'Keine MCP-Server verfügbar.', + 'mcp.settings.status.disabled': 'Deaktiviert', + 'mcp.settings.status.tokenRequired': 'Token erforderlich', + 'mcp.settings.status.failed': 'Fehlgeschlagen', + 'mcp.settings.status.oneTool': '{{count}} Tool', + 'mcp.settings.status.manyTools': '{{count}} Tools', + 'mcp.settings.status.unknown': 'Unbekannt', + 'mcp.settings.toggleServerAriaLabel': '{{serverName}} umschalten', + 'mcp.settings.editServerAriaLabel': '{{serverName}} bearbeiten', + 'mcp.settings.configureServerTitle': 'Server {{serverName}} konfigurieren', + 'mcp.settings.closeConfigureModalAriaLabel': + 'Konfigurationsdialog schließen', + 'mcp.settings.modalDescription': + 'Anmeldedaten werden verschlüsselt gespeichert und sind auf Ihr Profil beschränkt. Lightspeed arbeitet mit genau Ihren Berechtigungen.', + 'mcp.settings.savedToken': 'Gespeicherter Token', + 'mcp.settings.personalAccessToken': 'Persönlicher Zugriffstoken', + 'mcp.settings.usingAdminCredential': + 'Administratoranmeldedaten werden verwendet. Geben Sie einen persönlichen Token ein, um ihn für Ihr Konto zu überschreiben.', + 'mcp.settings.enterToken': 'Geben Sie Ihren Token ein', + 'mcp.settings.removePersonalToken': 'Persönlichen Token entfernen', + 'mcp.settings.token.clearAriaLabel': 'Tokeneingabe löschen', + 'mcp.settings.token.validating': 'Token wird validiert...', + 'mcp.settings.token.savingAndValidating': + 'Token wird gespeichert und validiert...', + 'mcp.settings.token.urlUnavailableForValidation': + 'Token kann nicht validiert werden, da die Server-URL nicht verfügbar ist.', + 'mcp.settings.token.invalidCredentials': + 'Ungültige Anmeldedaten. Überprüfen Sie Server-URL und Token.', + 'mcp.settings.token.validationFailed': + 'Validierung fehlgeschlagen. Überprüfen Sie Server-URL und Token.', + 'mcp.settings.token.connectionSuccessful': 'Verbindung erfolgreich', 'toolCall.header': 'Antwort des Tools: {{toolName}}', 'toolCall.thinking': 'Hat {{seconds}} Sekunden nachgedacht', 'toolCall.executionTime': 'Ausführungszeit: ', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 6a9fcc97554..a0e5d3b6114 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -209,6 +209,49 @@ const lightspeedTranslationEs = createTranslationMessages({ 'Los chats fijados están habilitados actualmente', 'settings.pinned.disabled.description': 'Los chats fijados están deshabilitados actualmente', + 'settings.mcp.label': 'Configuración de MCP', + 'mcp.settings.title': 'Servidores MCP', + 'mcp.settings.selectedCount': + '{{selectedCount}} de {{totalCount}} seleccionados', + 'mcp.settings.closeAriaLabel': 'Cerrar configuración de MCP', + 'mcp.settings.readOnlyAccess': + 'Tienes acceso de solo lectura a los servidores MCP.', + 'mcp.settings.tableAriaLabel': 'Tabla de servidores MCP', + 'mcp.settings.enabled': 'Habilitado', + 'mcp.settings.name': 'Nombre', + 'mcp.settings.status': 'Estado', + 'mcp.settings.edit': 'Editar', + 'mcp.settings.loading': 'Cargando servidores MCP...', + 'mcp.settings.noneAvailable': 'No hay servidores MCP disponibles.', + 'mcp.settings.status.disabled': 'Deshabilitado', + 'mcp.settings.status.tokenRequired': 'Se requiere token', + 'mcp.settings.status.failed': 'Falló', + 'mcp.settings.status.oneTool': '{{count}} herramienta', + 'mcp.settings.status.manyTools': '{{count}} herramientas', + 'mcp.settings.status.unknown': 'Desconocido', + 'mcp.settings.toggleServerAriaLabel': 'Alternar {{serverName}}', + 'mcp.settings.editServerAriaLabel': 'Editar {{serverName}}', + 'mcp.settings.configureServerTitle': 'Configurar servidor {{serverName}}', + 'mcp.settings.closeConfigureModalAriaLabel': + 'Cerrar modal de configuración', + 'mcp.settings.modalDescription': + 'Las credenciales se almacenan cifradas y se limitan a tu perfil. Lightspeed funcionará con exactamente tus permisos.', + 'mcp.settings.savedToken': 'Token guardado', + 'mcp.settings.personalAccessToken': 'Token de acceso personal', + 'mcp.settings.usingAdminCredential': + 'Se están usando credenciales proporcionadas por el administrador. Introduce un token personal para reemplazarlas en tu cuenta.', + 'mcp.settings.enterToken': 'Introduce tu token', + 'mcp.settings.removePersonalToken': 'Eliminar token personal', + 'mcp.settings.token.clearAriaLabel': 'Borrar entrada de token', + 'mcp.settings.token.validating': 'Validando token...', + 'mcp.settings.token.savingAndValidating': 'Guardando y validando token...', + 'mcp.settings.token.urlUnavailableForValidation': + 'No se puede validar el token porque la URL del servidor no está disponible.', + 'mcp.settings.token.invalidCredentials': + 'Credenciales no válidas. Revisa la URL del servidor y el token.', + 'mcp.settings.token.validationFailed': + 'La validación falló. Revisa la URL del servidor y el token.', + 'mcp.settings.token.connectionSuccessful': 'Conexión correcta', 'toolCall.header': 'Respuesta de la herramienta: {{toolName}}', 'toolCall.thinking': 'Pensó durante {{seconds}} segundos', 'toolCall.executionTime': 'Tiempo de ejecución: ', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index c0b5f68f3a8..391b77519f7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -212,6 +212,50 @@ const lightspeedTranslationFr = createTranslationMessages({ 'Les conversation épinglées sont actuellement activées', 'settings.pinned.disabled.description': 'Les conversations épinglées sont actuellement désactivées', + 'settings.mcp.label': 'Paramètres MCP', + 'mcp.settings.title': 'Serveurs MCP', + 'mcp.settings.selectedCount': + '{{selectedCount}} sur {{totalCount}} sélectionnés', + 'mcp.settings.closeAriaLabel': 'Fermer les paramètres MCP', + 'mcp.settings.readOnlyAccess': + 'Vous disposez d’un accès en lecture seule aux serveurs MCP.', + 'mcp.settings.tableAriaLabel': 'Tableau des serveurs MCP', + 'mcp.settings.enabled': 'Activé', + 'mcp.settings.name': 'Nom', + 'mcp.settings.status': 'Statut', + 'mcp.settings.edit': 'Modifier', + 'mcp.settings.loading': 'Chargement des serveurs MCP...', + 'mcp.settings.noneAvailable': 'Aucun serveur MCP disponible.', + 'mcp.settings.status.disabled': 'Désactivé', + 'mcp.settings.status.tokenRequired': 'Jeton requis', + 'mcp.settings.status.failed': 'Échec', + 'mcp.settings.status.oneTool': '{{count}} outil', + 'mcp.settings.status.manyTools': '{{count}} outils', + 'mcp.settings.status.unknown': 'Inconnu', + 'mcp.settings.toggleServerAriaLabel': 'Basculer {{serverName}}', + 'mcp.settings.editServerAriaLabel': 'Modifier {{serverName}}', + 'mcp.settings.configureServerTitle': 'Configurer le serveur {{serverName}}', + 'mcp.settings.closeConfigureModalAriaLabel': + 'Fermer la fenêtre de configuration', + 'mcp.settings.modalDescription': + 'Les identifiants sont chiffrés au repos et limités à votre profil. Lightspeed fonctionnera avec exactement vos autorisations.', + 'mcp.settings.savedToken': 'Jeton enregistré', + 'mcp.settings.personalAccessToken': "Jeton d'accès personnel", + 'mcp.settings.usingAdminCredential': + 'Les identifiants fournis par l’administrateur sont utilisés. Saisissez un jeton personnel pour les remplacer pour votre compte.', + 'mcp.settings.enterToken': 'Saisissez votre jeton', + 'mcp.settings.removePersonalToken': 'Supprimer le jeton personnel', + 'mcp.settings.token.clearAriaLabel': 'Effacer la saisie du jeton', + 'mcp.settings.token.validating': 'Validation du jeton...', + 'mcp.settings.token.savingAndValidating': + 'Enregistrement et validation du jeton...', + 'mcp.settings.token.urlUnavailableForValidation': + 'Impossible de valider le jeton car l’URL du serveur n’est pas disponible.', + 'mcp.settings.token.invalidCredentials': + 'Identifiants invalides. Vérifiez l’URL du serveur et le jeton.', + 'mcp.settings.token.validationFailed': + 'Échec de la validation. Vérifiez l’URL du serveur et le jeton.', + 'mcp.settings.token.connectionSuccessful': 'Connexion réussie', // Tool calling 'toolCall.header': "Réponse de l'outil : {{toolName}}", diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index 72b6e296b66..a09687ae605 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -211,6 +211,50 @@ const lightspeedTranslationIt = createTranslationMessages({ 'Le chat bloccate sono attualmente abilitate', 'settings.pinned.disabled.description': 'Le chat bloccate sono attualmente disabilitate', + 'settings.mcp.label': 'Impostazioni MCP', + 'mcp.settings.title': 'Server MCP', + 'mcp.settings.selectedCount': + '{{selectedCount}} di {{totalCount}} selezionati', + 'mcp.settings.closeAriaLabel': 'Chiudi impostazioni MCP', + 'mcp.settings.readOnlyAccess': + "Disponi dell'accesso in sola lettura ai server MCP.", + 'mcp.settings.tableAriaLabel': 'Tabella dei server MCP', + 'mcp.settings.enabled': 'Abilitato', + 'mcp.settings.name': 'Nome', + 'mcp.settings.status': 'Stato', + 'mcp.settings.edit': 'Modifica', + 'mcp.settings.loading': 'Caricamento dei server MCP...', + 'mcp.settings.noneAvailable': 'Nessun server MCP disponibile.', + 'mcp.settings.status.disabled': 'Disabilitato', + 'mcp.settings.status.tokenRequired': 'Token richiesto', + 'mcp.settings.status.failed': 'Non riuscito', + 'mcp.settings.status.oneTool': '{{count}} strumento', + 'mcp.settings.status.manyTools': '{{count}} strumenti', + 'mcp.settings.status.unknown': 'Sconosciuto', + 'mcp.settings.toggleServerAriaLabel': 'Attiva/disattiva {{serverName}}', + 'mcp.settings.editServerAriaLabel': 'Modifica {{serverName}}', + 'mcp.settings.configureServerTitle': 'Configura server {{serverName}}', + 'mcp.settings.closeConfigureModalAriaLabel': + 'Chiudi finestra di configurazione', + 'mcp.settings.modalDescription': + 'Le credenziali sono crittografate a riposo e associate al tuo profilo. Lightspeed opererà con esattamente le tue autorizzazioni.', + 'mcp.settings.savedToken': 'Token salvato', + 'mcp.settings.personalAccessToken': 'Token di accesso personale', + 'mcp.settings.usingAdminCredential': + "Sono in uso le credenziali fornite dall'amministratore. Inserisci un token personale per sovrascriverle per il tuo account.", + 'mcp.settings.enterToken': 'Inserisci il tuo token', + 'mcp.settings.removePersonalToken': 'Rimuovi token personale', + 'mcp.settings.token.clearAriaLabel': 'Cancella campo token', + 'mcp.settings.token.validating': 'Convalida del token in corso...', + 'mcp.settings.token.savingAndValidating': + 'Salvataggio e convalida del token in corso...', + 'mcp.settings.token.urlUnavailableForValidation': + "Impossibile convalidare il token perché l'URL del server non è disponibile.", + 'mcp.settings.token.invalidCredentials': + "Credenziali non valide. Controlla l'URL del server e il token.", + 'mcp.settings.token.validationFailed': + "Convalida non riuscita. Controlla l'URL del server e il token.", + 'mcp.settings.token.connectionSuccessful': 'Connessione riuscita', // Display modes 'settings.displayMode.label': 'Modalità di visualizzazione', 'settings.displayMode.overlay': 'Sovrapposizione', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index 11952b1fffc..4e2a1805baa 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -206,6 +206,48 @@ const lightspeedTranslationJa = createTranslationMessages({ 'settings.pinned.disable': 'チャットの固定の無効化', 'settings.pinned.enabled.description': 'チャットの固定は現在有効です', 'settings.pinned.disabled.description': 'チャットの固定は現在無効です', + 'settings.mcp.label': 'MCP 設定', + 'mcp.settings.title': 'MCP サーバー', + 'mcp.settings.selectedCount': + '{{totalCount}} 件中 {{selectedCount}} 件を選択', + 'mcp.settings.closeAriaLabel': 'MCP 設定を閉じる', + 'mcp.settings.readOnlyAccess': + 'MCP サーバーへのアクセスは読み取り専用です。', + 'mcp.settings.tableAriaLabel': 'MCP サーバーの表', + 'mcp.settings.enabled': '有効', + 'mcp.settings.name': '名前', + 'mcp.settings.status': 'ステータス', + 'mcp.settings.edit': '編集', + 'mcp.settings.loading': 'MCP サーバーを読み込み中...', + 'mcp.settings.noneAvailable': '利用可能な MCP サーバーはありません。', + 'mcp.settings.status.disabled': '無効', + 'mcp.settings.status.tokenRequired': 'トークンが必要', + 'mcp.settings.status.failed': '失敗', + 'mcp.settings.status.oneTool': '{{count}} 件のツール', + 'mcp.settings.status.manyTools': '{{count}} 件のツール', + 'mcp.settings.status.unknown': '不明', + 'mcp.settings.toggleServerAriaLabel': '{{serverName}} を切り替える', + 'mcp.settings.editServerAriaLabel': '{{serverName}} を編集', + 'mcp.settings.configureServerTitle': '{{serverName}} サーバーを設定', + 'mcp.settings.closeConfigureModalAriaLabel': '設定モーダルを閉じる', + 'mcp.settings.modalDescription': + '認証情報は保存時に暗号化され、あなたのプロファイルに限定されます。Lightspeed はあなたの権限で動作します。', + 'mcp.settings.savedToken': '保存済みトークン', + 'mcp.settings.personalAccessToken': '個人アクセストークン', + 'mcp.settings.usingAdminCredential': + '管理者が提供した認証情報を使用しています。アカウント用に上書きするには個人トークンを入力してください。', + 'mcp.settings.enterToken': 'トークンを入力してください', + 'mcp.settings.removePersonalToken': '個人トークンを削除', + 'mcp.settings.token.clearAriaLabel': 'トークン入力をクリア', + 'mcp.settings.token.validating': 'トークンを検証中...', + 'mcp.settings.token.savingAndValidating': 'トークンを保存して検証中...', + 'mcp.settings.token.urlUnavailableForValidation': + 'サーバー URL が利用できないため、トークンを検証できません。', + 'mcp.settings.token.invalidCredentials': + '認証情報が無効です。サーバー URL とトークンを確認してください。', + 'mcp.settings.token.validationFailed': + '検証に失敗しました。サーバー URL とトークンを確認してください。', + 'mcp.settings.token.connectionSuccessful': '接続に成功しました', // Display modes 'settings.displayMode.label': '表示モード', 'settings.displayMode.overlay': 'オーバーレイ', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 950a025c659..a48666fc108 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -243,6 +243,48 @@ export const lightspeedMessages = { 'settings.pinned.disable': 'Disable pinned chats', 'settings.pinned.enabled.description': 'Pinned chats are currently enabled', 'settings.pinned.disabled.description': 'Pinned chats are currently disabled', + 'settings.mcp.label': 'MCP settings', + + // MCP settings + 'mcp.settings.title': 'MCP servers', + 'mcp.settings.selectedCount': '{{selectedCount}} of {{totalCount}} selected', + 'mcp.settings.closeAriaLabel': 'Close MCP settings', + 'mcp.settings.readOnlyAccess': 'You have read-only access to MCP servers.', + 'mcp.settings.tableAriaLabel': 'MCP servers table', + 'mcp.settings.enabled': 'Enabled', + 'mcp.settings.name': 'Name', + 'mcp.settings.status': 'Status', + 'mcp.settings.edit': 'Edit', + 'mcp.settings.loading': 'Loading MCP servers...', + 'mcp.settings.noneAvailable': 'No MCP servers available.', + 'mcp.settings.status.disabled': 'Disabled', + 'mcp.settings.status.tokenRequired': 'Token required', + 'mcp.settings.status.failed': 'Failed', + 'mcp.settings.status.oneTool': '{{count}} tool', + 'mcp.settings.status.manyTools': '{{count}} tools', + 'mcp.settings.status.unknown': 'Unknown', + 'mcp.settings.toggleServerAriaLabel': 'Toggle {{serverName}}', + 'mcp.settings.editServerAriaLabel': 'Edit {{serverName}}', + 'mcp.settings.configureServerTitle': 'Configure {{serverName}} server', + 'mcp.settings.closeConfigureModalAriaLabel': 'Close configure modal', + 'mcp.settings.modalDescription': + 'Credentials are encrypted at rest and scoped to your profile. Lightspeed will operate with your exact permissions.', + 'mcp.settings.savedToken': 'Saved token', + 'mcp.settings.personalAccessToken': 'Personal Access Token', + 'mcp.settings.usingAdminCredential': + 'Using Administrator provided credential. Enter a personal token to override for your account.', + 'mcp.settings.enterToken': 'Enter your token', + 'mcp.settings.removePersonalToken': 'Remove personal token', + 'mcp.settings.token.clearAriaLabel': 'Clear token input', + 'mcp.settings.token.validating': 'Validating token...', + 'mcp.settings.token.savingAndValidating': 'Saving and validating token...', + 'mcp.settings.token.urlUnavailableForValidation': + 'Unable to validate token because server URL is not available.', + 'mcp.settings.token.invalidCredentials': + 'Invalid credentials. Check server URL and token.', + 'mcp.settings.token.validationFailed': + 'Validation failed. Check server URL and token.', + 'mcp.settings.token.connectionSuccessful': 'Connection successful', // Tool calling 'toolCall.header': 'Tool response: {{toolName}}',