import { Page, Locator, expect } from '@playwright/test' import { BasePage } from '../../../core/pages/base.page' import { waitForNetworkIdle } from '../../../core/utils/test-helpers' /** * Connection types supported by MCP clients */ export type MCPConnectionType = 'http' | 'sse' | 'stdio' /** * Authentication types for HTTP/SSE connections */ export type MCPAuthType = 'none' | 'headers' | 'oauth' /** Header value shape used by API (value / env_var / from_env) */ export type EnvVarLike = { value: string; env_var?: string; from_env?: boolean } /** * MCP Client configuration */ export interface MCPClientConfig { name: string connectionType?: MCPConnectionType connectionUrl?: string authType?: MCPAuthType /** Headers for auth_type 'headers'. API shape: Record */ headers?: Record isCodeMode?: boolean isPingAvailable?: boolean // STDIO specific command?: string args?: string envs?: string // OAuth specific oauthClientId?: string oauthClientSecret?: string oauthAuthorizeUrl?: string oauthTokenUrl?: string oauthScopes?: string } /** * Page object for the MCP Registry page */ export class MCPRegistryPage extends BasePage { readonly table: Locator readonly createBtn: Locator readonly sheet: Locator readonly detailSheet: Locator readonly nameInput: Locator readonly saveBtn: Locator readonly cancelBtn: Locator readonly connectionTypeSelect: Locator readonly authTypeSelect: Locator readonly connectionUrlInput: Locator readonly codeModeSwitch: Locator readonly pingAvailableSwitch: Locator // STDIO inputs readonly commandInput: Locator readonly argsInput: Locator readonly envsInput: Locator // OAuth inputs readonly oauthClientIdInput: Locator readonly oauthClientSecretInput: Locator readonly oauthAuthorizeUrlInput: Locator readonly oauthTokenUrlInput: Locator readonly oauthScopesInput: Locator constructor(page: Page) { super(page) this.table = page.locator('[data-testid="mcp-clients-table"]').or(page.locator('table')) this.createBtn = page.locator('[data-testid="create-mcp-client-btn"]').or( page.getByRole('button', { name: /New MCP Server/i }).or(page.getByRole('button', { name: /Add/i })) ) this.sheet = page.locator('[role="dialog"]') this.detailSheet = page.locator('[role="dialog"]') this.nameInput = page.locator('[data-testid="client-name-input"]').or( this.sheet.getByLabel(/Name/i).first() ) this.saveBtn = page.locator('[data-testid="save-client-btn"]').or( this.sheet.getByRole('button', { name: /Create/i }).or( this.sheet.getByRole('button', { name: /Save/i }) ) ) this.cancelBtn = page.locator('[data-testid="cancel-client-btn"]').or( this.sheet.getByRole('button', { name: /Cancel/i }) ) // Connection type and auth this.connectionTypeSelect = page.locator('[data-testid="connection-type-select"]') this.authTypeSelect = page.locator('[data-testid="auth-type-select"]') // Use placeholder as primary selector for EnvVarInput (more reliable) this.connectionUrlInput = this.sheet.getByPlaceholder(/http:\/\/your-mcp-server/i).or( page.locator('[data-testid="connection-url-input"]') ) // Switches (Radix UI switches) this.codeModeSwitch = page.locator('[data-testid="code-mode-switch"]') this.pingAvailableSwitch = this.sheet.locator('#ping-available') // STDIO inputs this.commandInput = page.locator('[data-testid="stdio-command-input"]') this.argsInput = page.locator('[data-testid="stdio-args-input"]') this.envsInput = page.locator('[data-testid="stdio-envs-input"]') // OAuth inputs this.oauthClientIdInput = this.sheet.getByPlaceholder(/your-client-id/i) this.oauthClientSecretInput = this.sheet.getByPlaceholder(/your-client-secret/i) this.oauthAuthorizeUrlInput = this.sheet.getByPlaceholder(/oauth\/authorize/i) this.oauthTokenUrlInput = this.sheet.getByPlaceholder(/oauth\/token/i) this.oauthScopesInput = this.sheet.getByPlaceholder(/read, write, admin/i) } async goto(): Promise { await this.page.goto('/workspace/mcp-registry') await waitForNetworkIdle(this.page) // Wait for table to be visible await this.table.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}) } /** Get the table row for a client by name. Scoped to tbody so the header row is never matched; first() for stable single-row target. */ getClientRow(name: string): Locator { return this.table.locator('tbody tr').filter({ hasText: name }).first() } async clientExists(name: string): Promise { await this.page.waitForTimeout(500) // Brief wait for UI update return (await this.getClientRow(name).count()) > 0 } /** * Poll until the client row appears in the table or timeout. * Used as a fallback success signal when the create form doesn't close (e.g. SSE/stdio). */ async waitForClientInTable(name: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if ((await this.getClientRow(name).count()) > 0) return true await this.page.waitForTimeout(500) } return false } async getClientCount(): Promise { // Exclude header row const rows = this.table.locator('tbody tr') return await rows.count() } /** * Select connection type from dropdown */ async selectConnectionType(type: MCPConnectionType): Promise { // Click the connection type select trigger const selectTrigger = this.page.locator('[data-testid="connection-type-select"]') await expect(selectTrigger).toBeVisible({ timeout: 5000 }) await selectTrigger.click() // Wait for dropdown to open and select the option by data-testid const optionTestId = `connection-type-${type}` const option = this.page.locator(`[data-testid="${optionTestId}"]`) await expect(option).toBeVisible({ timeout: 5000 }) await option.click() // Wait for dropdown to close await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {}) } /** * Select authentication type from dropdown */ async selectAuthType(type: MCPAuthType): Promise { const selectTrigger = this.page.locator('[data-testid="auth-type-select"]') await expect(selectTrigger).toBeVisible({ timeout: 5000 }) await selectTrigger.click() // Select the option by data-testid const optionTestId = `auth-type-${type}` const option = this.page.locator(`[data-testid="${optionTestId}"]`) await expect(option).toBeVisible({ timeout: 5000 }) await option.click() // Wait for dropdown to close await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {}) } /** * Fill the MCP client form with configuration (doesn't submit) */ async fillClientForm(config: MCPClientConfig): Promise { // Fill name await this.nameInput.fill(config.name) // Select connection type if specified if (config.connectionType) { await this.selectConnectionType(config.connectionType) // Wait for the form to update after connection type change await this.page.waitForTimeout(500) } // Toggle code mode if specified (Radix Switch uses data-state="checked"/"unchecked") if (config.isCodeMode !== undefined) { await expect(this.codeModeSwitch).toBeVisible({ timeout: 5000 }) const dataState = await this.codeModeSwitch.getAttribute('data-state') const currentState = dataState === 'checked' if (currentState !== config.isCodeMode) { await this.codeModeSwitch.click() // Wait for state to change const expectedState = config.isCodeMode ? 'checked' : 'unchecked' await expect(this.codeModeSwitch).toHaveAttribute('data-state', expectedState, { timeout: 3000 }) } } // Toggle ping available if specified (Radix Switch) if (config.isPingAvailable !== undefined) { const dataState = await this.pingAvailableSwitch.getAttribute('data-state') const currentState = dataState === 'checked' if (currentState !== config.isPingAvailable) { await this.pingAvailableSwitch.click() } } // Handle connection-type specific fields if (config.connectionType === 'http' || config.connectionType === 'sse' || !config.connectionType) { // Wait for auth type field to be visible (only shows for HTTP/SSE) await expect(this.authTypeSelect).toBeVisible({ timeout: 5000 }) // Select auth type if specified if (config.authType) { await this.selectAuthType(config.authType) await this.page.waitForTimeout(500) } // Fill connection URL if (config.connectionUrl) { await expect(this.connectionUrlInput).toBeVisible({ timeout: 5000 }) await this.connectionUrlInput.fill(config.connectionUrl) // Wait for React to process the input await this.page.waitForTimeout(500) } // Fill headers when auth_type is 'headers' (required for SSE test; export MCP_SSE_HEADERS in your environment) if (config.authType === 'headers' && config.headers && Object.keys(config.headers).length > 0) { const headersTable = this.sheet.locator('[data-testid="mcp-headers-table"]') await expect(headersTable).toBeVisible({ timeout: 5000 }) const entries = Object.entries(config.headers) for (let i = 0; i < entries.length; i++) { const [key, val] = entries[i] const valueStr = typeof val === 'object' && val !== null && 'value' in val ? (val as EnvVarLike).value : String(val) const keyInput = headersTable.locator(`input[data-row="${i}"][data-column="key"]`) const valueInput = headersTable.locator(`input[data-row="${i}"][data-column="value"]`).or( headersTable.locator(`[data-row="${i}"][data-column="value"] input`) ) await keyInput.waitFor({ state: 'visible', timeout: 8000 }) await keyInput.scrollIntoViewIfNeeded() await keyInput.click() await keyInput.fill(key) await this.page.waitForTimeout(400) const valueEl = valueInput.first() await valueEl.waitFor({ state: 'visible', timeout: 3000 }) await valueEl.scrollIntoViewIfNeeded() await valueEl.click() await valueEl.fill(valueStr) await this.page.waitForTimeout(500) } } // Handle OAuth config if (config.authType === 'oauth') { if (config.oauthClientId) { await this.oauthClientIdInput.fill(config.oauthClientId) } if (config.oauthClientSecret) { await this.oauthClientSecretInput.fill(config.oauthClientSecret) } if (config.oauthAuthorizeUrl) { await this.oauthAuthorizeUrlInput.fill(config.oauthAuthorizeUrl) } if (config.oauthTokenUrl) { await this.oauthTokenUrlInput.fill(config.oauthTokenUrl) } if (config.oauthScopes) { await this.oauthScopesInput.fill(config.oauthScopes) } } } else if (config.connectionType === 'stdio') { // Fill STDIO specific fields - wait for them to be visible after type change if (config.command) { await expect(this.commandInput).toBeVisible({ timeout: 5000 }) await this.commandInput.fill(config.command) // Wait for React to process the input await this.page.waitForTimeout(500) } if (config.args) { await expect(this.argsInput).toBeVisible({ timeout: 5000 }) await this.argsInput.fill(config.args) } if (config.envs) { await expect(this.envsInput).toBeVisible({ timeout: 5000 }) await this.envsInput.fill(config.envs) } } } /** * Create an MCP client with full configuration */ async createClient(config: MCPClientConfig): Promise { await this.dismissToasts() await this.createBtn.click() await expect(this.sheet).toBeVisible({ timeout: 5000 }) // Fill the form await this.fillClientForm(config) // Wait for form validation to complete await this.page.waitForTimeout(1500) // Wait for save button to be enabled (validation passed) await expect(this.saveBtn).toBeEnabled({ timeout: 10000 }) // Verify button is visible and contains expected text await expect(this.saveBtn).toBeVisible() await expect(this.saveBtn).toContainText(/Create|Save/i) // Wait for create-client API response then click save (backend may be slow connecting to MCP server) // Create is POST to /mcp/client (singular); do not match GET /mcp/clients const responsePromise = this.page.waitForResponse( (response) => { const url = response.url() const method = response.request().method() return ( (url.includes('/mcp/client') && !url.endsWith('/mcp/clients')) && (method === 'POST' || method === 'PUT') ) }, { timeout: 60000 } ) await this.saveBtn.click({ force: true }) const response = await responsePromise.catch(() => null) const ok = response && response.ok() if (response && !ok) { const body = await response.text().catch(() => '') await this.page.keyboard.press('Escape') await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) throw new Error(`Create MCP client failed: ${response.status()} ${body}`) } // Success: backend returned 2xx. Wait for create form to close (short timeout; UI usually updates quickly). const createFormHeading = this.page.getByRole('heading', { name: 'New MCP Server' }) await createFormHeading.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => null) const createFormClosed = !(await createFormHeading.isVisible().catch(() => false)) if (createFormClosed) { return true } // Backend succeeded but form may not close quickly (e.g. SSE/stdio). If client appears in table, treat as success. const inTable = await this.waitForClientInTable(config.name, 10000) if (inTable) { await this.page.keyboard.press('Escape') await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) return true } // Fallback: wait for toast or heading (e.g. slow UI) const toast = this.getToast() await Promise.race([ createFormHeading.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => null), toast.waitFor({ state: 'visible', timeout: 10000 }).catch(() => null), ]) if (!(await createFormHeading.isVisible().catch(() => true))) { return true } // Sheet still open - check for toast (success or error) let toastText = '' let toastVisible = false try { toastVisible = await toast.isVisible() if (toastVisible) toastText = (await toast.textContent()) || '' } catch { // ignore } if (toastVisible && toastText) { const isSuccess = toastText.toLowerCase().includes('success') || toastText.toLowerCase().includes('created') || toastText.toLowerCase().includes('server created') if (isSuccess) { await createFormHeading.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => null) return true } await this.page.keyboard.press('Escape') await this.sheet.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) throw new Error(`Client creation failed with error: ${toastText}`) } // No toast, sheet still open - validation or unknown failure const errorMessages = (await this.page.locator('[role="alert"]').allTextContents().catch(() => [])) .map((t) => t.trim()) .filter(Boolean) if (errorMessages.length > 0) { throw new Error(`Form validation errors: ${errorMessages.join(', ')}`) } const isDisabled = await this.saveBtn.isDisabled().catch(() => false) if (isDisabled) { throw new Error('Save button is disabled - form validation failed') } throw new Error('No toast appeared and sheet did not close - form submission may have failed') } /** * View client details by clicking on the row */ async viewClientDetails(name: string): Promise { const row = this.getClientRow(name) await row.click() await expect(this.detailSheet).toBeVisible({ timeout: 5000 }) } /** * Close the detail sheet */ async closeDetailSheet(): Promise { // Press Escape or click the X button await this.page.keyboard.press('Escape') await expect(this.detailSheet).not.toBeVisible({ timeout: 5000 }).catch(async () => { // If still visible, try clicking X button const closeBtn = this.detailSheet.locator('button').filter({ has: this.page.locator('svg.lucide-x') }) if (await closeBtn.isVisible()) { await closeBtn.click() } }) } /** * Close any open sheet/dialog (create or detail) so the table is visible */ async closeSheet(): Promise { const isVisible = await this.sheet.isVisible().catch(() => false) if (isVisible) { await this.page.keyboard.press('Escape') await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {}) } } /** * Clean up MCP clients by name. Ensures we're on the page and any sheet is closed before deleting. */ async cleanupMCPClients(names: string[]): Promise { if (names.length === 0) return await this.goto() await this.closeSheet() await this.dismissToasts() await this.table.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}) await this.page.waitForTimeout(500) for (const name of names) { const tryDelete = async (): Promise => { const exists = await this.clientExists(name) if (!exists) return await this.closeSheet() await this.deleteClient(name, { requireToast: false }) } try { await tryDelete() } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) console.error(`[CLEANUP ERROR] Failed to delete MCP client: ${name} - ${errorMsg}`) await this.closeSheet() await this.page.waitForTimeout(1000) try { await tryDelete() } catch (retryErr) { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr) console.error(`[CLEANUP ERROR] Retry failed for MCP client: ${name} - ${retryMsg}`) } } } } /** * Edit an existing client */ async editClient(name: string, updates: Partial): Promise { await this.viewClientDetails(name) // Update name if provided if (updates.name) { const nameInput = this.detailSheet.getByLabel(/Name/i).first() await nameInput.clear() await nameInput.fill(updates.name) } // Toggle code mode if specified if (updates.isCodeMode !== undefined) { const codeModeSwitch = this.detailSheet .locator('input[type="checkbox"]') .filter({ has: this.page.locator('#code-mode') }) .or(this.detailSheet.getByRole('switch', { name: /Code Mode/i })) const isVisible = await codeModeSwitch.isVisible().catch(() => false) if (isVisible) { const currentState = await codeModeSwitch.isChecked() if (currentState !== updates.isCodeMode) { await codeModeSwitch.click() } } } // Save changes const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i }) await saveBtn.click() await this.waitForSuccessToast() await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 }) } /** * Reconnect an MCP client */ async reconnectClient(name: string): Promise { const row = this.getClientRow(name) // Stop propagation by clicking the reconnect button directly const reconnectBtn = row.locator('button').filter({ has: this.page.locator('svg.lucide-refresh-ccw') }) await reconnectBtn.click() await this.waitForSuccessToast('Reconnected') } /** * Toggle tool enabled state in the detail sheet */ async toggleToolEnabled(clientName: string, toolName: string): Promise { await this.viewClientDetails(clientName) // Find the tool row and toggle its enabled switch const toolRow = this.detailSheet.locator('tr').filter({ hasText: toolName }) const enabledSwitch = toolRow.locator('button[role="switch"]').first() await enabledSwitch.click() // Save const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i }) await saveBtn.click() await this.waitForSuccessToast() await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 }) } /** * Toggle auto-execute for a tool in the detail sheet */ async toggleAutoExecute(clientName: string, toolName: string): Promise { await this.viewClientDetails(clientName) // Find the tool row and toggle its auto-execute switch (second switch) const toolRow = this.detailSheet.locator('tr').filter({ hasText: toolName }) const autoExecuteSwitch = toolRow.locator('button[role="switch"]').nth(1) await autoExecuteSwitch.click() // Save const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i }) await saveBtn.click() await this.waitForSuccessToast() await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 }) } /** * Toggle code mode for a client */ async toggleCodeMode(clientName: string): Promise { await this.viewClientDetails(clientName) // Find and toggle the code mode switch const codeModeSwitch = this.detailSheet .getByRole('switch', { name: /Code Mode/i }) .or(this.detailSheet.locator('#code-mode')) await codeModeSwitch.click() // Save const saveBtn = this.detailSheet.getByRole('button', { name: /Save/i }) await saveBtn.click() await this.waitForSuccessToast() await expect(this.detailSheet).not.toBeVisible({ timeout: 10000 }) } /** * Get client status from the table */ async getClientStatus(name: string): Promise { const row = this.getClientRow(name) const statusBadge = row .locator('[class*="badge"]') .or(row.locator('span').filter({ hasText: /connected|disconnected|connecting|error/i })) .last() const statusText = await statusBadge.textContent() return statusText?.toLowerCase().trim() || '' } /** * Get connection type displayed in the table (HTTP, SSE, STDIO) */ async getClientConnectionType(name: string): Promise { const row = this.getClientRow(name) const typeCell = row.getByTestId('mcp-client-connection-type') if ((await typeCell.count()) > 0) { return (await typeCell.first().textContent())?.trim() ?? '' } const cells = row.locator('td') if ((await cells.count()) >= 2) { return (await cells.nth(1).textContent())?.trim() ?? '' } return '' } /** * Get tools count from the client details sheet * Assumes the detail sheet is already open */ async getToolsCount(): Promise { // Tools are displayed in a table in the detail sheet const toolRows = this.detailSheet.locator('table tbody tr') const count = await toolRows.count() return count } /** * Get enabled tools count from table */ async getEnabledToolsCount(name: string): Promise { const row = this.getClientRow(name) // Enabled tools is typically shown as "X/Y" format const cells = row.locator('td') const count = await cells.count() if (count >= 5) { return await cells.nth(4).textContent() } return null } /** * Cancel client creation */ async cancelCreation(): Promise { await this.cancelBtn.click() await expect(this.sheet).not.toBeVisible({ timeout: 5000 }) } /** * Wait for the client row to disappear from the table (e.g. after delete or refetch). * Polls so we don't rely on a stale locator. */ async waitForClientGone(name: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if ((await this.getClientRow(name).count()) === 0) return true await this.page.waitForTimeout(500) } return false } /** * Delete an MCP client. Success is determined by the DELETE API completing and the row * disappearing from the table after the list refetches. */ async deleteClient(name: string, options?: { requireToast?: boolean }): Promise { const row = this.getClientRow(name) const deleteBtn = row .locator('button') .filter({ has: this.page.locator('svg.lucide-trash-2') }) .or(row.locator('button').filter({ has: this.page.locator('svg.lucide-trash') })) await deleteBtn.click() const confirmDialog = this.page.locator('[role="alertdialog"]') await expect(confirmDialog).toBeVisible({ timeout: 5000 }) const deleteResponsePromise = this.page.waitForResponse( (response) => { const url = response.url() return url.includes('/mcp/client/') && response.request().method() === 'DELETE' }, { timeout: 15000 } ) await confirmDialog.getByRole('button', { name: /Delete/i }).click() await deleteResponsePromise.catch(() => null) // Wait for table to refetch and row to disappear (poll fresh locator; avoid stale row reference) const gone = await this.waitForClientGone(name, 20000) if (!gone) { throw new Error(`Client "${name}" still visible after delete`) } if (options?.requireToast !== false) { await this.getToast().waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}) } } /** * Check if empty state is visible */ async isEmptyStateVisible(): Promise { const emptyMessage = this.page.getByText(/No clients found/i) return await emptyMessage.isVisible().catch(() => false) } }