first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import { join, resolve } from 'path'
import { MCPClientConfig, EnvVarLike } from './pages/mcp-registry.page'
/** Normalize header value from env (string or EnvVarLike) to EnvVarLike */
function toEnvVarLike(v: string | EnvVarLike): EnvVarLike {
if (typeof v === 'object' && v !== null && 'value' in v) return v as EnvVarLike
return { value: String(v), env_var: '', from_env: false }
}
/**
* Resolve header value: if string starts with "env.", use process.env[VAR_NAME].
*/
function resolveHeaderValue(v: EnvVarLike): EnvVarLike {
if (v.value.startsWith('env.')) {
const envVar = v.value.slice(4)
const resolved = process.env[envVar]
if (resolved !== undefined) {
return { value: resolved, env_var: envVar, from_env: true }
}
}
return v
}
/**
* Parse MCP_SSE_HEADERS: supports single object, array of objects, or concatenated objects.
* e.g. {"Authorization":"Bearer ..."},{"ENV_EXA_API_KEY":"..."} → merged into one record
*/
function parseSSEHeadersRaw(raw: string): Record<string, string | EnvVarLike> {
const trimmed = raw.trim()
if (!trimmed) return {}
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed
}
} catch {
// Fallback: concatenated objects {"a":1},{"b":2} → wrap in [ ] and merge
}
try {
const asArray = JSON.parse('[' + trimmed + ']')
if (Array.isArray(asArray) && asArray.every((o) => typeof o === 'object' && o !== null)) {
return Object.assign({}, ...asArray)
}
} catch {
// ignore
}
return {}
}
/**
* Create basic MCP client data
*/
export function createMCPClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return {
name: `test_client_${Date.now()}`,
connectionType: 'http',
connectionUrl: 'http://localhost:3001/',
...overrides,
}
}
/**
* Create HTTP MCP client data
* Uses http-no-ping-server from examples/mcps
*/
export function createHTTPClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'none',
isPingAvailable: false, // http-no-ping-server doesn't support ping
...overrides,
})
}
/**
* Normalize parsed headers to EnvVarLike format (handles both plain values and nested EnvVar objects).
*/
function normalizeHeaders(parsed: Record<string, string | EnvVarLike>): Record<string, EnvVarLike> {
const out: Record<string, EnvVarLike> = {}
for (const [k, v] of Object.entries(parsed)) {
if (v !== undefined && v !== null) {
out[k] = resolveHeaderValue(toEnvVarLike(v))
}
}
return out
}
/**
* Create SSE MCP client data.
* URL and headers are injected via workflow env: MCP_SSE_URL, MCP_SSE_HEADERS (JSON).
* Supports: {"Authorization":"Bearer ...","K":"V"} or {"Authorization":{"value":"...","env_var":"","from_env":false}}.
*/
export function createSSEClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
const connectionUrl = "https://ts-mcp-sse-proxy.fly.dev/npx%20-y%20exa-mcp-server/sse"
const raw = process.env.MCP_SSE_HEADERS
const parsed = raw ? parseSSEHeadersRaw(raw) : {}
const headers = normalizeHeaders(parsed)
return createMCPClientData({
connectionType: 'sse',
connectionUrl,
authType: 'headers',
headers: headers,
isPingAvailable: false,
...overrides,
})
}
/**
* Create STDIO MCP client data
* Uses test-tools-server from examples/mcps
*/
export function createSTDIOClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
// Use the built test-tools-server
const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..')
// Then for the stdio server:
const serverPath = join(REPO_ROOT, 'examples', 'mcps', 'test-tools-server', 'dist', 'index.js')
return createMCPClientData({
name: `stdio_client_${Date.now()}`,
connectionType: 'stdio',
command: 'node',
args: serverPath, // Run the actual MCP server
...overrides,
})
}
/**
* Create HTTP client with headers auth
*/
export function createHTTPClientWithHeaders(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'headers',
isPingAvailable: false,
...overrides,
})
}
/**
* Create HTTP client with OAuth auth (minimal config for testing)
* Note: http-no-ping-server doesn't support OAuth, so this is for UI testing only
*/
export function createHTTPClientWithOAuth(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
name: `oauth_client_${Date.now()}`,
connectionType: 'http',
connectionUrl: 'http://localhost:3001/', // http-no-ping-server
authType: 'oauth',
oauthClientId: 'test-client-id',
oauthAuthorizeUrl: 'http://localhost:3001/oauth/authorize',
oauthTokenUrl: 'http://localhost:3001/oauth/token',
isPingAvailable: false,
...overrides,
})
}
/**
* Create client with code mode enabled
*/
export function createCodeModeClientData(overrides: Partial<MCPClientConfig> = {}): MCPClientConfig {
return createMCPClientData({
isCodeMode: true,
...overrides,
})
}

View File

@@ -0,0 +1,357 @@
import { expect, test } from '../../core/fixtures/base.fixture'
import {
createCodeModeClientData,
createHTTPClientData,
createSSEClientData,
createSTDIOClientData
} from './mcp-registry.data'
const hasSSEHeaders = Boolean(process.env.MCP_SSE_HEADERS)
// Track created clients for cleanup
const createdClients: string[] = []
test.describe('MCP Registry', () => {
// MCP client creation can be slow (backend connects to MCP server); give tests room to complete
test.setTimeout(120000)
test.beforeEach(async ({ mcpRegistryPage }) => {
await mcpRegistryPage.goto()
})
test.afterEach(async ({ mcpRegistryPage }) => {
const toClean = [...createdClients]
createdClients.length = 0
if (toClean.length > 0) {
await mcpRegistryPage.cleanupMCPClients(toClean)
}
})
test.describe('MCP Client Display', () => {
test('should display MCP clients table', async ({ mcpRegistryPage }) => {
await expect(mcpRegistryPage.table).toBeVisible()
})
test('should display create button', async ({ mcpRegistryPage }) => {
await expect(mcpRegistryPage.createBtn).toBeVisible()
})
test('should show empty state or client list', async ({ mcpRegistryPage }) => {
const count = await mcpRegistryPage.getClientCount()
const isEmptyStateVisible = await mcpRegistryPage.isEmptyStateVisible()
if (count === 0) {
expect(isEmptyStateVisible).toBe(true)
} else {
expect(count).toBeGreaterThan(0)
expect(isEmptyStateVisible).toBe(false)
}
})
})
test.describe('MCP Client Creation', () => {
test('should open client creation sheet', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
await expect(mcpRegistryPage.nameInput).toBeVisible()
// Cancel to clean up
await mcpRegistryPage.cancelCreation()
})
test('should create basic HTTP client', async ({ mcpRegistryPage }) => {
const clientData = createHTTPClientData()
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Verify connection type displayed correctly
const connectionType = await mcpRegistryPage.getClientConnectionType(clientData.name)
expect(connectionType).toBe('HTTP')
})
test('should create SSE client', async ({ mcpRegistryPage }) => {
test.skip(!hasSSEHeaders, 'Requires MCP_SSE_HEADERS for authenticated SSE MCP endpoint')
const clientData = createSSEClientData({
name: `sse_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Verify connection type displayed correctly
const connectionType = await mcpRegistryPage.getClientConnectionType(clientData.name)
expect(connectionType).toBe('SSE')
})
test('should create STDIO client with command', async ({ mcpRegistryPage }) => {
const clientData = createSTDIOClientData()
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
})
test('should create client with code mode enabled', async ({ mcpRegistryPage }) => {
const clientData = createCodeModeClientData({
name: `codemode_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed
createdClients.push(clientData.name)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
})
test('should cancel client creation', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
const testName = `cancelled_client_${Date.now()}`
await mcpRegistryPage.nameInput.fill(testName)
await mcpRegistryPage.cancelCreation()
// Sheet should be closed
await expect(mcpRegistryPage.sheet).not.toBeVisible()
// Client should not exist
const exists = await mcpRegistryPage.clientExists(testName)
expect(exists).toBe(false)
})
})
test.describe('MCP Server Connection Validation', () => {
test('should connect to HTTP server and list tools', async ({ mcpRegistryPage }) => {
const clientData = createHTTPClientData({
name: `http_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify client shows connection status
const status = await mcpRegistryPage.getClientStatus(clientData.name)
expect(status).toBeTruthy()
// Status could be connecting, connected, or disconnected depending on timing
expect(['connected', 'disconnected', 'connecting', 'error']).toContain(status.toLowerCase())
// Verify tools are loaded (http-no-ping-server has: echo, add, greet)
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(3)
await mcpRegistryPage.closeDetailSheet()
})
test('should connect to SSE server and list tools', async ({ mcpRegistryPage }) => {
test.skip(!hasSSEHeaders, 'Requires MCP_SSE_HEADERS for authenticated SSE MCP endpoint')
const clientData = createSSEClientData({
name: `sse_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify tools are loaded
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(3)
await mcpRegistryPage.closeDetailSheet()
})
test('should connect to STDIO server and list tools', async ({ mcpRegistryPage }) => {
const clientData = createSTDIOClientData({
name: `stdio_validation_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)
createdClients.push(clientData.name)
// Wait a moment for connection to establish
await mcpRegistryPage.page.waitForTimeout(2000)
// Verify tools from test-tools-server (echo, calculator, get_weather, delay, throw_error)
await mcpRegistryPage.viewClientDetails(clientData.name)
const toolsCount = await mcpRegistryPage.getToolsCount()
expect(toolsCount).toBeGreaterThanOrEqual(5)
await mcpRegistryPage.closeDetailSheet()
})
})
test.describe('MCP Client Management', () => {
test('should delete MCP client', async ({ mcpRegistryPage }) => {
// Create a client first using HTTP (most reliable)
const clientData = createHTTPClientData({
name: `delete_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
// Verify it exists
let exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
// Delete it
await mcpRegistryPage.deleteClient(clientData.name)
// Verify it's gone
exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(false)
})
test('should view client details', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `view_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// View details
await mcpRegistryPage.viewClientDetails(clientData.name)
// Detail sheet should be visible
await expect(mcpRegistryPage.detailSheet).toBeVisible()
// Close the sheet
await mcpRegistryPage.closeDetailSheet()
})
test('should close client details sheet', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `close_sheet_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Open details
await mcpRegistryPage.viewClientDetails(clientData.name)
await expect(mcpRegistryPage.detailSheet).toBeVisible()
// Close it
await mcpRegistryPage.closeDetailSheet()
// Should be closed
await expect(mcpRegistryPage.detailSheet).not.toBeVisible()
})
test('should reconnect MCP client', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `reconnect_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Reconnect - method waits for success toast
await mcpRegistryPage.reconnectClient(clientData.name)
// Verify client still exists and has a status (reconnect completed)
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
const status = await mcpRegistryPage.getClientStatus(clientData.name)
expect(status).toBeTruthy()
expect(['connected', 'disconnected', 'connecting']).toContain(status.toLowerCase())
})
})
test.describe('Client Status Display', () => {
test('should display client connection status', async ({ mcpRegistryPage }) => {
// Create a client first
const clientData = createHTTPClientData({
name: `status_test_${Date.now()}`,
})
const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true) // Client creation must succeed for this test
createdClients.push(clientData.name)
// Get status
const status = await mcpRegistryPage.getClientStatus(clientData.name)
// Status should be one of the expected values
expect(status).toBeTruthy()
expect(['connected', 'disconnected', 'connecting', 'error']).toContain(status?.toLowerCase())
})
})
test.describe('Form Validation', () => {
test('should require name for client', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Clear name field (should be empty by default)
await mcpRegistryPage.nameInput.clear()
// Save button should be disabled when name is empty
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
test('should validate name format', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Try invalid name with hyphens (not allowed)
await mcpRegistryPage.nameInput.fill('invalid-name-with-hyphens')
// Fill connection URL to satisfy other validation
await mcpRegistryPage.connectionUrlInput.fill('http://localhost:3001')
// Save button should be disabled due to validation error
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
test('should require connection URL for HTTP clients', async ({ mcpRegistryPage }) => {
await mcpRegistryPage.createBtn.click()
await expect(mcpRegistryPage.sheet).toBeVisible()
// Fill valid name
await mcpRegistryPage.nameInput.fill(`valid_name_${Date.now()}`)
// Leave connection URL empty - save should be disabled
const saveBtn = mcpRegistryPage.saveBtn
await expect(saveBtn).toBeDisabled()
await mcpRegistryPage.cancelCreation()
})
})
})

View File

@@ -0,0 +1,703 @@
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<string, EnvVarLike> */
headers?: Record<string, EnvVarLike | string>
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<void> {
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<boolean> {
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<boolean> {
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<number> {
// Exclude header row
const rows = this.table.locator('tbody tr')
return await rows.count()
}
/**
* Select connection type from dropdown
*/
async selectConnectionType(type: MCPConnectionType): Promise<void> {
// 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<void> {
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<void> {
// 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<boolean> {
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<void> {
const row = this.getClientRow(name)
await row.click()
await expect(this.detailSheet).toBeVisible({ timeout: 5000 })
}
/**
* Close the detail sheet
*/
async closeDetailSheet(): Promise<void> {
// 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<void> {
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<void> {
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<void> => {
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<MCPClientConfig>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
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<number> {
// 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<string | null> {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
const emptyMessage = this.page.getByText(/No clients found/i)
return await emptyMessage.isVisible().catch(() => false)
}
}