From a11d8f72181fde184f133673fa137a46f44d507c Mon Sep 17 00:00:00 2001 From: R-Lawton Date: Mon, 31 Aug 2026 18:14:42 +0100 Subject: [PATCH 1/7] Adding in the mcpgatewayexstension httproute management filed and toggling the httproute Signed-off-by: R-Lawton --- e2e/tests/mcp-setup-wizard.spec.ts | 58 ++---- src/components/mcp/MCPExtensionFormFields.tsx | 46 ++++- src/components/mcp/MCPExtensionStep.tsx | 24 ++- src/components/mcp/MCPSetupWizard.tsx | 195 ++++++------------ src/components/mcp/MCPVerifyStep.test.tsx | 17 ++ src/components/mcp/MCPVerifyStep.tsx | 37 +++- src/components/mcp/mcpResourceUtils.test.ts | 42 ++++ src/components/mcp/mcpResourceUtils.ts | 52 ++++- src/components/mcp/types.ts | 2 + 9 files changed, 274 insertions(+), 199 deletions(-) diff --git a/e2e/tests/mcp-setup-wizard.spec.ts b/e2e/tests/mcp-setup-wizard.spec.ts index c03ce389..d0573c86 100644 --- a/e2e/tests/mcp-setup-wizard.spec.ts +++ b/e2e/tests/mcp-setup-wizard.spec.ts @@ -115,17 +115,16 @@ spec: deleteResource('gateway', setupGatewayName, TEST_NAMESPACE); }); - test('renders the wizard with 4 steps', { tag: '@nightly' }, async ({ page }) => { + test('renders the wizard with 3 steps by default', { tag: '@nightly' }, async ({ page }) => { await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); await expect(page.getByRole('heading', { name: 'MCP Gateway Setup' })).toBeVisible({ timeout: 15_000, }); - await expect(page.getByRole('button', { name: '1. Create Gateway' })).toBeVisible(); - await expect(page.getByRole('button', { name: '2. Route for Gateway' })).toBeVisible(); - await expect(page.getByRole('button', { name: '3. MCP Extension' })).toBeVisible(); - await expect(page.getByRole('button', { name: '4. Verify configuration' })).toBeVisible(); + await expect(page.getByRole('button', { name: /1\. Create Gateway/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /2\. MCP Extension/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /3\. Verify configuration/ })).toBeVisible(); }); test('step 1 shows choose and create radio options', { tag: '@nightly' }, async ({ page }) => { @@ -145,7 +144,7 @@ spec: }); test( - 'steps 2-4 are disabled until step 1 is complete', + 'steps 2-3 are disabled until step 1 is complete', { tag: '@nightly' }, async ({ page }) => { await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); @@ -154,9 +153,8 @@ spec: timeout: 15_000, }); - await expect(page.getByRole('button', { name: '2. Route for Gateway' })).toBeDisabled(); - await expect(page.getByRole('button', { name: '3. MCP Extension' })).toBeDisabled(); - await expect(page.getByRole('button', { name: '4. Verify configuration' })).toBeDisabled(); + await expect(page.getByRole('button', { name: /2\. MCP Extension/ })).toBeDisabled(); + await expect(page.getByRole('button', { name: /3\. Verify configuration/ })).toBeDisabled(); }, ); @@ -262,12 +260,7 @@ spec: await expect(nextButton).toBeEnabled(); await nextButton.click(); - // Step 2: Select existing route - await expect(page.locator('[data-test="mcp-route-select"]')).toBeVisible({ timeout: 15_000 }); - await page.locator('[data-test="mcp-route-select"]').selectOption(routeName); - await nextButton.click(); - - // Step 3: Fill MCP Extension form + // Step 2: Fill MCP Extension form await expect(page.locator('[data-test="mcp-extension-name"]')).toBeVisible({ timeout: 15_000, }); @@ -283,7 +276,7 @@ spec: await nextButton.click(); - // Step 4: Verify — MCPGatewayExtension should be created + // Step 3: Verify — MCPGatewayExtension should be created await expect(page.getByText('Create MCPGatewayExtension')).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('MCPGatewayExtension created successfully')).toBeVisible({ timeout: 30_000, @@ -349,14 +342,7 @@ spec: await expect(nextButton).toBeEnabled({ timeout: 15_000 }); await nextButton.click(); - // Step 2: Select the pre-created route - await expect(page.getByLabel('Choose an existing HTTPRoute')).toBeChecked({ - timeout: 15_000, - }); - await page.locator('[data-test="mcp-route-select"]').selectOption(routeName); - await nextButton.click(); - - // Step 3: Fill MCP Extension + // Step 2: Fill MCP Extension await expect(page.locator('[data-test="mcp-extension-name"]')).toBeVisible({ timeout: 15_000, }); @@ -364,7 +350,7 @@ spec: await page.locator('[data-test="mcp-section-name-input"]').fill('mcp'); await nextButton.click(); - // Step 4: Verify + // Step 3: Verify await expect(page.getByText('Create Gateway', { exact: true })).toBeVisible({ timeout: 15_000, }); @@ -446,14 +432,7 @@ spec: const nextButton = page.getByRole('button', { name: 'Next', exact: true }); await nextButton.click(); - // Step 2: Select existing route - await expect(page.locator('[data-test="mcp-route-select"]')).toBeVisible({ - timeout: 15_000, - }); - await page.locator('[data-test="mcp-route-select"]').selectOption('e2e-mcp-adv-route'); - await nextButton.click(); - - // Step 3: Fill MCP Extension with advanced settings + // Step 2: Fill MCP Extension with advanced settings await expect(page.locator('[data-test="mcp-extension-name"]')).toBeVisible({ timeout: 15_000, }); @@ -478,7 +457,7 @@ spec: await nextButton.click(); - // Step 4: Verify + // Step 3: Verify await expect(page.getByText('MCPGatewayExtension created successfully')).toBeVisible({ timeout: 30_000, }); @@ -571,14 +550,7 @@ spec: const nextButton = page.getByRole('button', { name: 'Next', exact: true }); await nextButton.click(); - // Step 2: Select existing route - await expect(page.locator('[data-test="mcp-route-select"]')).toBeVisible({ - timeout: 15_000, - }); - await page.locator('[data-test="mcp-route-select"]').selectOption('e2e-mcp-xns-route'); - await nextButton.click(); - - // Step 3: Fill extension — set a DIFFERENT namespace to trigger ReferenceGrant + // Step 2: Fill extension — set a DIFFERENT namespace to trigger ReferenceGrant await expect(page.locator('[data-test="mcp-extension-name"]')).toBeVisible({ timeout: 15_000, }); @@ -592,7 +564,7 @@ spec: } await nextButton.click(); - // Step 4: Verify — should create ReferenceGrant + MCPGatewayExtension + // Step 3: Verify — should create ReferenceGrant + MCPGatewayExtension await expect(page.getByText('Create ReferenceGrant')).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('ReferenceGrant created successfully')).toBeVisible({ timeout: 30_000, diff --git a/src/components/mcp/MCPExtensionFormFields.tsx b/src/components/mcp/MCPExtensionFormFields.tsx index b43f87ab..bfa5c544 100644 --- a/src/components/mcp/MCPExtensionFormFields.tsx +++ b/src/components/mcp/MCPExtensionFormFields.tsx @@ -40,7 +40,7 @@ interface MCPExtensionFormFieldsProps { // isn't otherwise selectable there); the standalone page hides it and uses the // console's namespace picker instead. showNamespaceField?: boolean; - // Callback fired when validation state changes + validationError?: string | null; onValidationChange?: (isValid: boolean) => void; } @@ -54,6 +54,7 @@ const MCPExtensionFormFields: React.FC = ({ disableIdentity = false, gatewayNames = [], showNamespaceField = true, + validationError, onValidationChange, }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); @@ -196,6 +197,13 @@ const MCPExtensionFormFields: React.FC = ({ placeholder={t('Enter extension name')} data-test="mcp-extension-name" /> + {validationError?.includes('extension name') && formState.extensionName.trim() && ( + + + {validationError} + + + )} = ({ + {validationError?.includes('target Gateway') && formState.targetGateway.trim() && ( + + + {validationError} + + + )} @@ -352,9 +367,38 @@ const MCPExtensionFormFields: React.FC = ({ + {validationError && + !validationError.includes('extension name') && + !validationError.includes('target Gateway') && + formState.sectionName.trim() && ( + + + {validationError} + + + )} + + + updateFormState({ httpRouteManagementEnabled: !checked }) + } + data-test="mcp-http-route-management" + /> + + + + {t('Disables the controller automatically creating the gateway HTTPRoute.')} + + + + + = ({ () => buildMCPGatewayExtension(formState, selectedNamespace), [formState, selectedNamespace], ); + const validationError = getMCPGatewayExtensionValidationError(formState, selectedGateway); // Handle YAML changes and sync back to form const handleYamlChange = (yamlInput: string) => { @@ -58,6 +62,7 @@ const MCPExtensionStep: React.FC = ({ oauthAuthorizationServers: parsed.spec?.oauthProtectedResource?.authorizationServers?.join(', ') || '', oauthResourceName: parsed.spec?.oauthProtectedResource?.resourceName || '', + httpRouteManagementEnabled: parsed.spec?.httpRouteManagement !== 'Disabled', }); } } catch { @@ -88,13 +93,16 @@ const MCPExtensionStep: React.FC = ({ {createView === 'form' ? ( - + <> + + ) : (
{t('Loading YAML editor...')}
}> diff --git a/src/components/mcp/MCPSetupWizard.tsx b/src/components/mcp/MCPSetupWizard.tsx index 0943cbd9..2e708950 100644 --- a/src/components/mcp/MCPSetupWizard.tsx +++ b/src/components/mcp/MCPSetupWizard.tsx @@ -13,6 +13,7 @@ import { FormSelectOption, Button, Radio, + Switch, Content, Popover, Alert, @@ -30,6 +31,7 @@ import { GatewayResource } from '../gateway/types'; import { HTTPRouteResource } from '../httproute/types'; import { MCPWizardFormState, MCPGatewayExtension, initialFormState } from './types'; import MCPExtensionStep from './MCPExtensionStep'; +import { getMCPGatewayExtensionValidationError } from './mcpResourceUtils'; import MCPVerifyStep, { VerifyStepItem, WatchResourceConfig } from './MCPVerifyStep'; import GatewayCreatePage from '../gateway/GatewayCreatePage'; import HTTPRouteCreatePage from '../httproute/HTTPRouteCreatePage'; @@ -49,6 +51,7 @@ const MCPSetupWizard: React.FC = () => { const [newRouteResource, setNewRouteResource] = React.useState(null); const [newRouteValid, setNewRouteValid] = React.useState(false); const [extensionValid, setExtensionValid] = React.useState(false); + const [createRouteEnabled, setCreateRouteEnabled] = React.useState(false); const [formState, setFormState] = React.useState({ ...initialFormState, @@ -64,13 +67,6 @@ const MCPSetupWizard: React.FC = () => { namespace: selectedNamespace, }); - // Watch existing HTTPRoutes for Step 2 dropdown - const [httpRoutes, routesLoaded, routesError] = useK8sWatchResource({ - groupVersionKind: RESOURCES.HTTPRoute.gvk, - isList: true, - namespace: selectedNamespace, - }); - const updateFormState = React.useCallback((updates: Partial) => { setFormState((prev) => ({ ...prev, ...updates })); }, []); @@ -91,9 +87,10 @@ const MCPSetupWizard: React.FC = () => { // Get listeners from the selected gateway for the listener dropdown in Step 3 const selectedGateway = React.useMemo(() => { - if (formState.gatewayMode !== 'existing' || !formState.selectedGatewayName) return undefined; + if (formState.gatewayMode === 'new') return newGatewayResource || undefined; + if (!formState.selectedGatewayName) return undefined; return (gateways || []).find((gw) => gw.metadata?.name === formState.selectedGatewayName); - }, [gateways, formState.gatewayMode, formState.selectedGatewayName]); + }, [gateways, formState.gatewayMode, formState.selectedGatewayName, newGatewayResource]); const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; @@ -112,7 +109,7 @@ const MCPSetupWizard: React.FC = () => { }); } - if (formState.routeMode === 'new' && newRouteResource) { + if (createRouteEnabled && newRouteResource && newRouteValid) { result.push({ type: 'create', id: 'create-route', @@ -199,6 +196,7 @@ const MCPSetupWizard: React.FC = () => { }, } : {}), + httpRouteManagement: formState.httpRouteManagementEnabled ? 'Enabled' : 'Disabled', }, }; @@ -215,6 +213,8 @@ const MCPSetupWizard: React.FC = () => { formState, newGatewayResource, newRouteResource, + newRouteValid, + createRouteEnabled, isCrossNamespace, extensionNamespace, gatewayNamespace, @@ -239,10 +239,10 @@ const MCPSetupWizard: React.FC = () => { (formState.gatewayMode === 'existing' && formState.selectedGatewayName !== '') || (formState.gatewayMode === 'new' && newGatewayValid); - // Step 2 validation: must have a route selected or a valid new route form - const isStep2Valid = - (formState.routeMode === 'existing' && formState.selectedRouteName !== '') || - (formState.routeMode === 'new' && newRouteValid); + const extensionValidationError = getMCPGatewayExtensionValidationError( + formState, + selectedGateway, + ); return ( <> @@ -263,7 +263,7 @@ const MCPSetupWizard: React.FC = () => { {/* Step 1: Create Gateway */} { - {/* Step 2: Route for Gateway */} - - - {t('Choose or create an HTTPRoute')} - - - {t('Select an existing route or create a new one to direct traffic to MCP servers.')} - - - - - updateFormState({ routeMode: 'existing' })} - /> - - {formState.routeMode === 'existing' && ( - - - {t('HTTPRoute name')} - - - - - } - fieldId="route-select" - > - - updateFormState({ - routeMode: 'existing', - selectedRouteName: value, - selectedRouteNamespace: - (httpRoutes || []).find((r) => r.metadata?.name === value)?.metadata - ?.namespace || selectedNamespace, - }) - } - aria-label={t('Select an HTTPRoute')} - data-test="mcp-route-select" - isDisabled={!routesLoaded} - > - - {(httpRoutes || []).map((route) => ( - - ))} - - - {routesError && ( - - {String(routesError)} - - )} - - )} - - - - - updateFormState({ routeMode: 'new' })} - /> - - {formState.routeMode === 'new' && ( - -
- { - setNewRouteResource(resource); - setNewRouteValid(isValid); - updateFormState({ - newRouteName: resource.metadata?.name || '', - }); - }} - /> -
-
- )} -
-
- - {/* Step 3: MCP Extension */} + {/* Step 2: MCP Extension */} { /> - {/* Step 4: Verify configuration */} + {!formState.httpRouteManagementEnabled && ( + + + {t('Create an HTTPRoute (optional)')} + + + {t('You can create an HTTPRoute for MCP traffic, or continue without one.')} + + + + setCreateRouteEnabled(checked)} + /> + + {createRouteEnabled && ( + +
+ { + setNewRouteResource(resource); + setNewRouteValid(isValid); + updateFormState({ newRouteName: resource.metadata?.name || '' }); + }} + /> +
+
+ )} +
+
+ )} + + {/* Verify configuration */} { }); }); + it('shows a useful Kubernetes cause when the API returns a structured error', async () => { + mockK8sCreate.mockRejectedValueOnce({ + json: { + reason: 'Invalid', + details: { causes: [{ message: 'spec.targetRef.sectionName: listener not found' }] }, + }, + }); + + render(); + + await waitFor(() => { + expect( + screen.getAllByText('spec.targetRef.sectionName: listener not found').length, + ).toBeGreaterThan(0); + }); + }); + it('creates HTTPRoute when included in items', async () => { const newRoute = { apiVersion: 'gateway.networking.k8s.io/v1', diff --git a/src/components/mcp/MCPVerifyStep.tsx b/src/components/mcp/MCPVerifyStep.tsx index 5342971b..860c2d78 100644 --- a/src/components/mcp/MCPVerifyStep.tsx +++ b/src/components/mcp/MCPVerifyStep.tsx @@ -51,6 +51,27 @@ interface WatchedResource extends K8sResourceCommon { }; } +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) return error.message; + const response = error as { + message?: string; + reason?: string; + json?: { + message?: string; + reason?: string; + details?: { causes?: { message?: string }[] }; + }; + }; + return ( + response?.json?.message || + response?.json?.details?.causes?.find((cause) => cause.message)?.message || + response?.message || + response?.json?.reason || + response?.reason || + 'The resource could not be created or verified.' + ); +}; + interface MCPVerifyStepProps { items: VerifyStepItem[]; watchResource: WatchResourceConfig; @@ -86,7 +107,7 @@ const MCPVerifyStep: React.FC = ({ const watchReadyId = 'watch-ready'; - const [watchedData, watchedLoaded] = useK8sWatchResource( + const [watchedData, watchedLoaded, watchedError] = useK8sWatchResource( watchStarted ? { groupVersionKind: watchResource.gvk, @@ -98,7 +119,15 @@ const MCPVerifyStep: React.FC = ({ ); React.useEffect(() => { - if (!watchStarted || !watchedLoaded || !watchedData) return; + if (!watchStarted || !watchedLoaded) return; + if (watchedError) { + updateCheckById(watchReadyId, 'error', getErrorMessage(watchedError)); + return; + } + if (!watchedData) { + updateCheckById(watchReadyId, 'error', 'The created resource could not be found.'); + return; + } const conditions = watchedData.status?.conditions || []; const readyCondition = conditions.find((c) => c.type === 'Ready'); @@ -116,7 +145,7 @@ const MCPVerifyStep: React.FC = ({ readyCondition.message || readyCondition.reason || t('Resource is not ready'), ); } - }, [watchedData, watchedLoaded, watchStarted, watchSuccessMessage, t]); + }, [watchedData, watchedLoaded, watchedError, watchStarted, watchSuccessMessage, t]); const updateCheckById = React.useCallback((id: string, status: CheckStatus, message?: string) => { setChecks((prev) => @@ -195,7 +224,7 @@ const MCPVerifyStep: React.FC = ({ setWatchStarted(true); onAllCreated?.(); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err); setError(message); setChecks((prev) => diff --git a/src/components/mcp/mcpResourceUtils.test.ts b/src/components/mcp/mcpResourceUtils.test.ts index 51aecc80..868deeb9 100644 --- a/src/components/mcp/mcpResourceUtils.test.ts +++ b/src/components/mcp/mcpResourceUtils.test.ts @@ -2,6 +2,9 @@ import { buildMCPGatewayExtension, mcpExtensionToFormState, isMCPGatewayExtensionValid, + getMCPGatewayExtensionValidationError, + isKubernetesResourceName, + isGatewayListenerName, buildMCPServerRegistration, wireHTTPRouteToExternalHost, parseServiceEntryHosts, @@ -211,6 +214,45 @@ describe('parseServiceEntryHosts', () => { }); }); +describe('MCPGatewayExtension validation', () => { + it('accepts valid Kubernetes resource and listener names', () => { + expect(isKubernetesResourceName('my-extension.example')).toBe(true); + expect(isGatewayListenerName('https-443')).toBe(true); + expect(isMCPGatewayExtensionValid(baseFormState())).toBe(true); + }); + + it.each(['UPPERCASE', 'has spaces', '-starts-wrong', 'ends-wrong-', ''])( + 'rejects invalid extension name "%s"', + (name) => { + expect(isKubernetesResourceName(name)).toBe(false); + expect(isMCPGatewayExtensionValid(baseFormState({ extensionName: name }))).toBe(false); + }, + ); + + it('rejects a listener that is not present on the selected Gateway', () => { + const gateway = { + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'Gateway', + metadata: { name: 'my-gw', namespace: 'gw-ns' }, + spec: { + gatewayClassName: 'istio', + listeners: [{ name: 'http', port: 80, protocol: 'HTTP' as const }], + }, + }; + + expect( + getMCPGatewayExtensionValidationError(baseFormState({ sectionName: 'https' }), gateway), + ).toBe('Listener "https" was not found on Gateway "my-gw".'); + }); + + it('rejects invalid listener names', () => { + expect(isGatewayListenerName('listener with spaces')).toBe(false); + expect( + getMCPGatewayExtensionValidationError(baseFormState({ sectionName: 'listener with spaces' })), + ).toBe('The listener name must be a valid Kubernetes name.'); + }); +}); + describe('mcpExtensionToFormState', () => { it('is the inverse of buildMCPGatewayExtension for a fully-populated resource', () => { const formState = baseFormState({ diff --git a/src/components/mcp/mcpResourceUtils.ts b/src/components/mcp/mcpResourceUtils.ts index fa134335..940cc77e 100644 --- a/src/components/mcp/mcpResourceUtils.ts +++ b/src/components/mcp/mcpResourceUtils.ts @@ -15,6 +15,7 @@ import { } from './types'; import { HTTPRouteResource } from '../httproute/types'; import { RESOURCES, Secret } from '../../utils/resources'; +import type { GatewayResource } from '../gateway/types'; // Key used within the credential Secret's stringData for the token configured in // step 4 (Add access credentials) of the external MCP wizard. @@ -26,6 +27,45 @@ export const parseServiceEntryHosts = (hosts: string): string[] => .map((host) => host.trim()) .filter(Boolean); +const DNS_SUBDOMAIN_REGEX = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; +const DNS_LABEL_REGEX = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; + +export const isKubernetesResourceName = (name: string): boolean => + name.length > 0 && name.length <= 253 && DNS_SUBDOMAIN_REGEX.test(name); + +export const isGatewayListenerName = (name: string): boolean => + name.length > 0 && name.length <= 63 && DNS_LABEL_REGEX.test(name); + +export const getMCPGatewayExtensionValidationError = ( + formState: MCPWizardFormState, + selectedGateway?: GatewayResource, +): string | null => { + if (!isKubernetesResourceName(formState.extensionName.trim())) { + return 'The extension name must be a valid Kubernetes resource name.'; + } + if (!formState.targetGateway.trim()) return 'A target Gateway is required.'; + if (!isGatewayListenerName(formState.sectionName.trim())) { + return 'The listener name must be a valid Kubernetes name.'; + } + + if (selectedGateway) { + const listenerExists = (selectedGateway.spec?.listeners || []).some( + (listener) => listener.name === formState.sectionName.trim(), + ); + if (!listenerExists) { + return `Listener "${formState.sectionName}" was not found on Gateway "${formState.targetGateway}".`; + } + } + + if (formState.sessionStorageEnabled && !formState.sessionStoreSecretName.trim()) { + return 'A session store Secret name is required when session storage is enabled.'; + } + if (formState.oauthEnabled && !formState.oauthAuthorizationServers.trim()) { + return 'At least one OAuth authorization server is required when OAuth is enabled.'; + } + return null; +}; + // Build an MCPGatewayExtension resource from wizard/page form state. // When originalMetadata is provided (edit mode) it is preserved so that // k8sUpdate keeps the resourceVersion and other server-managed fields. @@ -51,6 +91,7 @@ export const buildMCPGatewayExtension = ( namespace: gatewayNamespace, sectionName: formState.sectionName, }, + httpRouteManagement: formState.httpRouteManagementEnabled ? 'Enabled' : 'Disabled', }, }; @@ -100,16 +141,15 @@ export const mcpExtensionToFormState = ( oauthEnabled: hasOauth, oauthAuthorizationServers: spec.oauthProtectedResource?.authorizationServers?.join(', ') || '', oauthResourceName: spec.oauthProtectedResource?.resourceName || '', + httpRouteManagementEnabled: spec.httpRouteManagement !== 'Disabled', }; }; // Validation shared by the wizard step footer and the standalone create/edit page. -export const isMCPGatewayExtensionValid = (formState: MCPWizardFormState): boolean => - !!formState.extensionName.trim() && - !!formState.targetGateway.trim() && - !!formState.sectionName.trim() && - (!formState.sessionStorageEnabled || !!formState.sessionStoreSecretName.trim()) && - (!formState.oauthEnabled || !!formState.oauthAuthorizationServers.trim()); +export const isMCPGatewayExtensionValid = ( + formState: MCPWizardFormState, + selectedGateway?: GatewayResource, +): boolean => !getMCPGatewayExtensionValidationError(formState, selectedGateway); // Build an MCPServerRegistration resource from wizard/page form state. // When originalMetadata is provided (edit mode) it is preserved so that diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index ad24ec07..4f21da52 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -105,6 +105,7 @@ export interface MCPWizardFormState { oauthEnabled: boolean; oauthAuthorizationServers: string; oauthResourceName: string; + httpRouteManagementEnabled: boolean; } export const initialFormState: MCPWizardFormState = { @@ -131,6 +132,7 @@ export const initialFormState: MCPWizardFormState = { oauthEnabled: false, oauthAuthorizationServers: '', oauthResourceName: '', + httpRouteManagementEnabled: true, }; // Registration wizard form state From 18347e94eae4a1948679ec07e63fe573a018c1fc Mon Sep 17 00:00:00 2001 From: R-Lawton Date: Tue, 1 Sep 2026 17:33:41 +0100 Subject: [PATCH 2/7] fix: refine MCP setup wizard entry points Signed-off-by: R-Lawton --- console-extensions.json | 8 - e2e/tests/mcp-setup-wizard.spec.ts | 131 ++++++--------- .../en/plugin__kuadrant-console-plugin.json | 22 ++- src/components/KuadrantOverviewPage.tsx | 2 +- src/components/kuadrant.css | 29 +++- src/components/mcp/MCPExtensionFormFields.tsx | 43 ++--- src/components/mcp/MCPExtensionStep.test.tsx | 14 ++ src/components/mcp/MCPOverviewPage.test.tsx | 89 +++++++++- src/components/mcp/MCPOverviewPage.tsx | 158 ++++++++++++++++-- src/components/mcp/MCPSetupWizard.tsx | 145 +++++++++++----- src/components/mcp/mcpResourceUtils.test.ts | 26 +++ src/components/mcp/types.test.ts | 34 +--- src/components/mcp/types.ts | 8 +- 13 files changed, 495 insertions(+), 214 deletions(-) diff --git a/console-extensions.json b/console-extensions.json index c9c1a887..0eca49a8 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -169,14 +169,6 @@ "component": { "$codeRef": "APIProductAPIKeysTab" } } }, - { - "type": "console.page/route", - "properties": { - "exact": true, - "path": ["/kuadrant/mcp/overview/ns/:ns", "/kuadrant/mcp/overview/all-namespaces"], - "component": { "$codeRef": "MCPOverviewPage" } - } - }, { "type": "console.page/route", "properties": { diff --git a/e2e/tests/mcp-setup-wizard.spec.ts b/e2e/tests/mcp-setup-wizard.spec.ts index d0573c86..220d7051 100644 --- a/e2e/tests/mcp-setup-wizard.spec.ts +++ b/e2e/tests/mcp-setup-wizard.spec.ts @@ -115,16 +115,16 @@ spec: deleteResource('gateway', setupGatewayName, TEST_NAMESPACE); }); - test('renders the wizard with 3 steps by default', { tag: '@nightly' }, async ({ page }) => { + test('renders the wizard with 3 steps', { tag: '@nightly' }, async ({ page }) => { await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); await expect(page.getByRole('heading', { name: 'MCP Gateway Setup' })).toBeVisible({ timeout: 15_000, }); - await expect(page.getByRole('button', { name: /1\. Create Gateway/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /2\. MCP Extension/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /3\. Verify configuration/ })).toBeVisible(); + await expect(page.getByRole('button', { name: '1. Create Gateway' })).toBeVisible(); + await expect(page.getByRole('button', { name: '2. MCP Extension' })).toBeVisible(); + await expect(page.getByRole('button', { name: '3. Verify configuration' })).toBeVisible(); }); test('step 1 shows choose and create radio options', { tag: '@nightly' }, async ({ page }) => { @@ -143,6 +143,44 @@ spec: }); }); + test( + 'disabling automatic HTTPRoute management adds the route step', + { tag: '@nightly' }, + async ({ page }) => { + await page.goto(`/k8s/ns/${TEST_NAMESPACE}`); + await page.waitForLoadState('networkidle'); + await dismissConsoleTour(page); + await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); + + await expect(page.locator('[data-test="mcp-gateway-select"]')).toBeVisible({ + timeout: 15_000, + }); + await page.locator('[data-test="mcp-gateway-select"]').selectOption({ index: 1 }); + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await page.getByText('Advanced broker settings').click(); + await expect(page.locator('[data-test="mcp-http-route-management"]')).not.toBeChecked(); + await page.locator('[data-test="mcp-http-route-management"]').click(); + + await expect(page.getByRole('button', { name: '3. HTTPRoute' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Configure MCP Extension' })).toBeVisible(); + await expect(page.getByRole('button', { name: '2. MCP Extension' })).toBeVisible(); + await expect(page.getByRole('button', { name: '4. Verify configuration' })).toBeVisible(); + + await page.locator('[data-test="mcp-extension-name"]').fill('mcp-ext'); + const sectionSelect = page.locator('[data-test="mcp-section-name"]'); + if (await sectionSelect.isVisible().catch(() => false)) { + await sectionSelect.selectOption({ index: 1 }); + } else { + await page.locator('[data-test="mcp-section-name-input"]').fill('http'); + } + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Choose or create an HTTPRoute' }), + ).toBeVisible(); + await expect(page.getByLabel('Create a new HTTPRoute')).toBeChecked(); + }, + ); + test( 'steps 2-3 are disabled until step 1 is complete', { tag: '@nightly' }, @@ -153,8 +191,8 @@ spec: timeout: 15_000, }); - await expect(page.getByRole('button', { name: /2\. MCP Extension/ })).toBeDisabled(); - await expect(page.getByRole('button', { name: /3\. Verify configuration/ })).toBeDisabled(); + await expect(page.getByRole('button', { name: '2. MCP Extension' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '3. Verify configuration' })).toBeDisabled(); }, ); @@ -198,7 +236,6 @@ spec: test.describe('Happy path: existing resources', () => { let namespace = ''; const gatewayName = `e2e-mcp-gw-${uid()}`; - const routeName = `e2e-mcp-route-${uid()}`; test.beforeAll(() => { namespace = `e2e-mcp-existing-${uid()}`; @@ -217,23 +254,6 @@ spec: - name: http port: 80 protocol: HTTP -`, - ); - kubectl( - ['apply', '-f', '-'], - ` -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: ${routeName} - namespace: ${namespace} -spec: - parentRefs: - - name: ${gatewayName} - rules: - - backendRefs: - - name: test-svc - port: 80 `, ); }); @@ -243,7 +263,7 @@ spec: deleteNamespace(namespace); }); - test('wizard flow with existing gateway and route', { tag: '@smoke' }, async ({ page }) => { + test('wizard flow with existing gateway', { tag: '@smoke' }, async ({ page }) => { // Set active namespace to the test namespace so the wizard watches resources there await page.goto(`/k8s/ns/${namespace}`); await page.waitForLoadState('networkidle'); @@ -288,35 +308,17 @@ spec: test.describe('Happy path: create new resources', () => { let namespace = ''; - const routeName = `e2e-mcp-route-new-${uid()}`; test.beforeAll(() => { namespace = `e2e-mcp-new-${uid()}`; kubectl(['create', 'namespace', namespace]); - kubectl( - ['apply', '-f', '-'], - ` -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: ${routeName} - namespace: ${namespace} -spec: - parentRefs: - - name: placeholder-gw - rules: - - backendRefs: - - name: test-svc - port: 80 -`, - ); }); test.afterAll(() => { deleteNamespace(namespace); }); - test('wizard flow creating new gateway and route', { tag: '@smoke' }, async ({ page }) => { + test('wizard flow creating a new gateway', { tag: '@smoke' }, async ({ page }) => { const gwName = `e2e-new-gw-${uid()}`; const extName = `e2e-new-ext-${uid()}`; @@ -347,7 +349,12 @@ spec: timeout: 15_000, }); await page.locator('[data-test="mcp-extension-name"]').fill(extName); - await page.locator('[data-test="mcp-section-name-input"]').fill('mcp'); + const sectionSelect = page.locator('[data-test="mcp-section-name"]'); + if (await sectionSelect.isVisible().catch(() => false)) { + await sectionSelect.selectOption('mcp'); + } else { + await page.locator('[data-test="mcp-section-name-input"]').fill('mcp'); + } await nextButton.click(); // Step 3: Verify @@ -391,23 +398,6 @@ spec: - name: http port: 80 protocol: HTTP -`, - ); - kubectl( - ['apply', '-f', '-'], - ` -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: e2e-mcp-adv-route - namespace: ${namespace} -spec: - parentRefs: - - name: ${gatewayName} - rules: - - backendRefs: - - name: test-svc - port: 80 `, ); }); @@ -504,23 +494,6 @@ spec: - name: http port: 80 protocol: HTTP -`, - ); - kubectl( - ['apply', '-f', '-'], - ` -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: e2e-mcp-xns-route - namespace: ${gwNamespace} -spec: - parentRefs: - - name: ${gatewayName} - rules: - - backendRefs: - - name: test-svc - port: 80 `, ); }); diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index 229cc7c8..43e48ce8 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -15,9 +15,8 @@ "1 day left ({{date}})": "1 day left ({{date}})", "1 request": "1 request", "1. Create Gateway": "1. Create Gateway", - "2. Route for Gateway": "2. Route for Gateway", - "3. MCP Extension": "3. MCP Extension", - "4. Verify configuration": "4. Verify configuration", + "2. MCP Extension": "2. MCP Extension", + "3. Verify configuration": "3. Verify configuration", "A brief description of how you intend to use this API key.": "A brief description of how you intend to use this API key.", "A consolidated view of all policies attached to this route (including gateway-level policies).": "A consolidated view of all policies attached to this route (including gateway-level policies).", "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.": "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.", @@ -88,6 +87,7 @@ "Allowed Namespaces": "Allowed Namespaces", "Allowed Route Kinds": "Allowed Route Kinds", "Allowed Routes": "Allowed Routes", + "Add an MCP server to your Gateway.": "Add an MCP server to your Gateway.", "Already exists": "Already exists", "An error occurred in the wizard": "An error occurred in the wizard", "An error occurred while loading extensions": "An error occurred while loading extensions", @@ -141,6 +141,8 @@ "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.": "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.", "Automatic": "Automatic", "Automatic approval": "Automatic approval", + "Automatically manage HTTPRoute": "Automatically manage HTTPRoute", + "Disable automatic HTTPRoute management": "Disable automatic HTTPRoute management", "Backend": "Backend", "Backend references": "Backend references", "Backend Services": "Backend Services", @@ -214,6 +216,7 @@ "Create GRPCRoute": "Create GRPCRoute", "Create HTTP route": "Create HTTP route", "Create HTTPRoute": "Create HTTPRoute", + "Configure how a Gateway connects to MCP servers.": "Configure how a Gateway connects to MCP servers.", "Create MCP server registration": "Create MCP server registration", "Create MCPGatewayExtension": "Create MCPGatewayExtension", "Create MCPServerRegistration": "Create MCPServerRegistration", @@ -430,6 +433,9 @@ "Geo value to apply to geo endpoints": "Geo value to apply to geo endpoints", "Geography Label (e.g. 'EU')": "Geography Label (e.g. 'EU')", "Get started": "Get started", + "Get started with MCP management": "Get started with MCP management", + "Get started with MCPGatewayExtensions": "Get started with MCPGatewayExtensions", + "Get started with MCPServerRegistrations": "Get started with MCPServerRegistrations", "Getting started actions": "Getting started actions", "Getting started with Kuadrant": "Getting started with Kuadrant", "Give a version to your API product": "Give a version to your API product", @@ -635,6 +641,8 @@ "Online": "Online", "Only HTTPRoute is supported by this Gateway.": "Only HTTPRoute is supported by this Gateway.", "OpenAPI Spec URL": "OpenAPI Spec URL", + "Open extension setup wizard": "Open extension setup wizard", + "Open server registration wizard": "Open server registration wizard", "Optional hostname to match requests. Leave empty to match all hostnames.": "Optional hostname to match requests. Leave empty to match all hostnames.", "Override hostnames": "Override hostnames", "Override the public and private hostnames derived from the gateway listener.": "Override the public and private hostnames derived from the gateway listener.", @@ -690,6 +698,7 @@ "ReferenceGrant check": "ReferenceGrant check", "ReferenceGrant created successfully": "ReferenceGrant created successfully", "Register an internal MCP server by creating an HTTPRoute and server registration": "Register an internal MCP server by creating an HTTPRoute and server registration", + "Register an internal MCP server.": "Register an internal MCP server.", "Register MCP server": "Register MCP server", "Register MCP Server": "Register MCP Server", "Registration name": "Registration name", @@ -821,7 +830,9 @@ "Session storage": "Session storage", "set": "set", "Set": "Set", - "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway, route, and MCP extension.": "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway, route, and MCP extension.", + "Set up an MCPGatewayExtension for a Gateway.": "Set up an MCPGatewayExtension for a Gateway.", + "Set up MCP Gateway Extension": "Set up MCP Gateway Extension", + "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway and MCP extension.": "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway and MCP extension.", "Set up your MCP infrastructure by creating a gateway, route, and MCP extension. Use the setup wizard to get started quickly.": "Set up your MCP infrastructure by creating a gateway, route, and MCP extension. Use the setup wizard to get started quickly.", "Setup external MCP server": "Setup external MCP server", "Setup MCP server": "Setup MCP server", @@ -950,6 +961,7 @@ "Waiting for controller to reconcile...": "Waiting for controller to reconcile...", "Weekly Limit": "Weekly Limit", "Weight value to apply to weighted endpoints default: 120": "Weight value to apply to weighted endpoints default: 120", + "When enabled, you will create or select the HTTPRoute in the next wizard step.": "When enabled, you will create or select the HTTPRoute in the next wizard step.", "When predicate": "When predicate", "when: {{predicates}}": "when: {{predicates}}", "Whether the service is external to the mesh (MESH_EXTERNAL) or part of it (MESH_INTERNAL).": "Whether the service is external to the mesh (MESH_EXTERNAL) or part of it (MESH_INTERNAL).", @@ -982,4 +994,4 @@ "You do not have permission to view Policy Topology": "You do not have permission to view Policy Topology", "You do not have permission to view Reference grants": "You do not have permission to view Reference grants", "You do not have permission to view this resource": "You do not have permission to view this resource" -} \ No newline at end of file +} diff --git a/src/components/KuadrantOverviewPage.tsx b/src/components/KuadrantOverviewPage.tsx index 406d6883..489c7ab8 100644 --- a/src/components/KuadrantOverviewPage.tsx +++ b/src/components/KuadrantOverviewPage.tsx @@ -1142,4 +1142,4 @@ const KuadrantOverviewPage: React.FC = () => { ); }; -export default React.memo(KuadrantOverviewPage); +export default KuadrantOverviewPage; diff --git a/src/components/kuadrant.css b/src/components/kuadrant.css index 13a882cf..9d4004c3 100644 --- a/src/components/kuadrant.css +++ b/src/components/kuadrant.css @@ -209,8 +209,22 @@ border-width: 1px; } -.kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-title { - font-weight: normal; +.kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-options { + width: 100%; +} + +.kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-option { + padding: 0 1.5rem; + text-align: center; +} + +.kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-option p { + margin: 0; + max-width: 32rem; +} + +.kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-divider { + align-self: stretch; } .kuadrant-mcp-overview-page .kuadrant-mcp-card-heading { @@ -230,6 +244,17 @@ position: relative; } +@media (max-width: 768px) { + .kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-option { + padding: 0.75rem 0; + } + + .kuadrant-mcp-overview-page .kuadrant-mcp-getting-started-divider { + align-self: auto; + width: 100%; + } +} + /* MCP wizard YAML editor */ .kuadrant-mcp-wizard__yaml-editor { height: 600px; diff --git a/src/components/mcp/MCPExtensionFormFields.tsx b/src/components/mcp/MCPExtensionFormFields.tsx index bfa5c544..22f3e0dd 100644 --- a/src/components/mcp/MCPExtensionFormFields.tsx +++ b/src/components/mcp/MCPExtensionFormFields.tsx @@ -380,25 +380,6 @@ const MCPExtensionFormFields: React.FC = ({
- - - updateFormState({ httpRouteManagementEnabled: !checked }) - } - data-test="mcp-http-route-management" - /> - - - - {t('Disables the controller automatically creating the gateway HTTPRoute.')} - - - - - = ({ )} + + { + updateFormState({ + httpRouteManagementEnabled: !checked, + ...(checked ? { routeMode: 'new' } : {}), + }); + }} + data-test="mcp-http-route-management" + /> + + + + {t( + 'When enabled, you will create or select the HTTPRoute in the next wizard step.', + )} + + + + + { screen.getByText('The name of the gateway listener to use for MCP traffic.'), ).toBeInTheDocument(); }); + + it('renders automatic HTTPRoute management enabled by default', () => { + render(); + expect(screen.getByTestId('mcp-http-route-management')).not.toBeChecked(); + }); + + it('updates HTTPRoute management when toggled off', () => { + render(); + fireEvent.click(screen.getByTestId('mcp-http-route-management')); + expect(defaultProps.updateFormState).toHaveBeenCalledWith({ + httpRouteManagementEnabled: false, + routeMode: 'new', + }); + }); }); diff --git a/src/components/mcp/MCPOverviewPage.test.tsx b/src/components/mcp/MCPOverviewPage.test.tsx index 85ac79dd..dba3ef10 100644 --- a/src/components/mcp/MCPOverviewPage.test.tsx +++ b/src/components/mcp/MCPOverviewPage.test.tsx @@ -5,6 +5,8 @@ import { render, screen, fireEvent, configure } from '@testing-library/react'; configure({ testIdAttribute: 'data-test' }); const mockNavigate = jest.fn(); +let mockMcpResourceKind: 'none' | 'extension' | 'server' | 'both' = 'none'; +const mockRegistrationWizard = jest.fn(() => null); // Configurable watch result for the extensions resource: [data, loaded, error]. // The first useK8sWatchResource call in the component is for extensions. @@ -30,13 +32,37 @@ jest.mock('react-helmet', () => ({ })); jest.mock('@openshift-console/dynamic-plugin-sdk', () => { - let callIndex = 0; return { - useK8sWatchResource: () => { - // The component calls this for extensions first, then gateways, then servers. - const isExtensionsCall = callIndex % 3 === 0; - callIndex += 1; - return isExtensionsCall ? mockExtensionsWatch : [[], true, null]; + useK8sWatchResource: (resource: { groupVersionKind: { kind: string } }) => { + if (resource.groupVersionKind.kind === 'MCPGatewayExtension') { + if (mockMcpResourceKind === 'extension' || mockMcpResourceKind === 'both') { + return [ + [ + { metadata: { name: 'mcp-resource', namespace: 'test-ns' } }, + { metadata: { name: 'another-mcp-resource', namespace: 'test-ns' } }, + ], + true, + null, + ]; + } + return mockExtensionsWatch; + } + + if ( + resource.groupVersionKind.kind === 'MCPServerRegistration' && + (mockMcpResourceKind === 'server' || mockMcpResourceKind === 'both') + ) { + return [ + [ + { metadata: { name: 'mcp-resource', namespace: 'test-ns' } }, + { metadata: { name: 'another-mcp-resource', namespace: 'test-ns' } }, + ], + true, + null, + ]; + } + + return [[], true, null]; }, NamespaceBar: () =>
, ResourceLink: ({ name }: { name: string }) => {name}, @@ -74,7 +100,7 @@ jest.mock('../ResourceList', () => ({ jest.mock('./MCPRegistrationWizard', () => ({ __esModule: true, - default: () => null, + default: mockRegistrationWizard, })); import MCPOverviewPage from './MCPOverviewPage'; @@ -82,9 +108,15 @@ import MCPOverviewPage from './MCPOverviewPage'; describe('MCPOverviewPage', () => { beforeEach(() => { mockNavigate.mockClear(); - // Default: user can list extensions, no extensions exist, no error. + mockRegistrationWizard.mockClear(); + mockMcpResourceKind = 'none'; mockExtensionsWatch = [[], true, null]; - mockUserRBAC = { 'mcpgatewayextensions-list': true }; + mockUserRBAC = { + 'mcpgatewayextensions-list': true, + 'mcpgatewayextensions-create': true, + 'mcpserverregistrations-list': true, + 'mcpserverregistrations-create': true, + }; }); it('renders the empty state when no extensions exist', () => { @@ -153,4 +185,43 @@ describe('MCPOverviewPage', () => { screen.queryByRole('heading', { name: 'MCP management overview' }), ).not.toBeInTheDocument(); }); + + it('renders all three getting-started entries when the overview has multiple MCP resources', () => { + mockMcpResourceKind = 'both'; + render(); + + expect( + screen.getByRole('heading', { name: /Get started with MCP management/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Get started with MCPGatewayExtensions' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Get started with MCPServerRegistrations' }), + ).toBeInTheDocument(); + expect( + screen.getByText('Configure how a Gateway connects to MCP servers.'), + ).toBeInTheDocument(); + expect(screen.getByText('Add an MCP server to your Gateway.')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-getting-started-extension-button')).toHaveTextContent( + 'Open extension setup wizard', + ); + expect(screen.getByTestId('mcp-getting-started-registration-button')).toHaveTextContent( + 'Open server registration wizard', + ); + }); + + it('uses the existing wizard entry points from the cards', () => { + mockMcpResourceKind = 'extension'; + render(); + + fireEvent.click(screen.getByTestId('mcp-getting-started-extension-button')); + expect(mockNavigate).toHaveBeenCalledWith('/kuadrant/mcp/setup-wizard'); + + fireEvent.click(screen.getByTestId('mcp-getting-started-registration-button')); + expect(mockRegistrationWizard).toHaveBeenLastCalledWith( + expect.objectContaining({ isOpen: true }), + expect.anything(), + ); + }); }); diff --git a/src/components/mcp/MCPOverviewPage.tsx b/src/components/mcp/MCPOverviewPage.tsx index 9f8b9ce8..683aed16 100644 --- a/src/components/mcp/MCPOverviewPage.tsx +++ b/src/components/mcp/MCPOverviewPage.tsx @@ -162,7 +162,7 @@ const MCPOverviewPage: React.FC = () => { namespace: resolvedNamespace, }); - const [servers] = useK8sWatchResource({ + const [servers, serversLoaded] = useK8sWatchResource({ groupVersionKind: RESOURCES.MCPServerRegistration.gvk, isList: true, namespace: resolvedNamespace, @@ -486,9 +486,13 @@ const MCPOverviewPage: React.FC = () => { ); } - const hasNoExtensions = extensionsLoaded && (!extensions || extensions.length === 0); + const hasNoMcpResources = + extensionsLoaded && + serversLoaded && + (!extensions || extensions.length === 0) && + (!servers || servers.length === 0); - if (hasNoExtensions) { + if (hasNoMcpResources) { return ( @@ -533,18 +537,7 @@ const MCPOverviewPage: React.FC = () => { variant="info" isInline className="kuadrant-mcp-getting-started-alert" - title={ - - {t('Getting started with Kuadrant')}:{' '} - - {t('View Documentation')} - - - } + title={t('Get started with MCP management')} actionClose={ setIsGettingStartedMenuOpen(false)} @@ -570,7 +563,138 @@ const MCPOverviewPage: React.FC = () => { } - /> + > + + + + + {t('Getting started with Kuadrant')} + + + {t('Learn how to create, import, and use MCP gateways and servers.')} + + + + + + + + + {t('Get started with MCPGatewayExtensions')} + + + {t('Configure how a Gateway connects to MCP servers.')} + + {!extensionRBAC.create || isAllNamespaces ? ( + + + + ) : ( + + )} + + + + + + + {t('Get started with MCPServerRegistrations')} + + {t('Add an MCP server to your Gateway.')} + {!serverRBAC.create || isAllNamespaces ? ( + + + + ) : ( + + )} + + + + )} @@ -1102,4 +1226,4 @@ const MCPOverviewPage: React.FC = () => { ); }; -export default React.memo(MCPOverviewPage); +export default MCPOverviewPage; diff --git a/src/components/mcp/MCPSetupWizard.tsx b/src/components/mcp/MCPSetupWizard.tsx index 2e708950..ca635b9c 100644 --- a/src/components/mcp/MCPSetupWizard.tsx +++ b/src/components/mcp/MCPSetupWizard.tsx @@ -13,7 +13,6 @@ import { FormSelectOption, Button, Radio, - Switch, Content, Popover, Alert, @@ -31,7 +30,6 @@ import { GatewayResource } from '../gateway/types'; import { HTTPRouteResource } from '../httproute/types'; import { MCPWizardFormState, MCPGatewayExtension, initialFormState } from './types'; import MCPExtensionStep from './MCPExtensionStep'; -import { getMCPGatewayExtensionValidationError } from './mcpResourceUtils'; import MCPVerifyStep, { VerifyStepItem, WatchResourceConfig } from './MCPVerifyStep'; import GatewayCreatePage from '../gateway/GatewayCreatePage'; import HTTPRouteCreatePage from '../httproute/HTTPRouteCreatePage'; @@ -50,8 +48,6 @@ const MCPSetupWizard: React.FC = () => { const [newGatewayValid, setNewGatewayValid] = React.useState(false); const [newRouteResource, setNewRouteResource] = React.useState(null); const [newRouteValid, setNewRouteValid] = React.useState(false); - const [extensionValid, setExtensionValid] = React.useState(false); - const [createRouteEnabled, setCreateRouteEnabled] = React.useState(false); const [formState, setFormState] = React.useState({ ...initialFormState, @@ -67,6 +63,13 @@ const MCPSetupWizard: React.FC = () => { namespace: selectedNamespace, }); + // Watch existing HTTPRoutes only for the optional manually-managed route step. + const [httpRoutes, routesLoaded, routesError] = useK8sWatchResource({ + groupVersionKind: RESOURCES.HTTPRoute.gvk, + isList: true, + namespace: selectedNamespace, + }); + const updateFormState = React.useCallback((updates: Partial) => { setFormState((prev) => ({ ...prev, ...updates })); }, []); @@ -87,10 +90,9 @@ const MCPSetupWizard: React.FC = () => { // Get listeners from the selected gateway for the listener dropdown in Step 3 const selectedGateway = React.useMemo(() => { - if (formState.gatewayMode === 'new') return newGatewayResource || undefined; - if (!formState.selectedGatewayName) return undefined; + if (formState.gatewayMode !== 'existing' || !formState.selectedGatewayName) return undefined; return (gateways || []).find((gw) => gw.metadata?.name === formState.selectedGatewayName); - }, [gateways, formState.gatewayMode, formState.selectedGatewayName, newGatewayResource]); + }, [gateways, formState.gatewayMode, formState.selectedGatewayName]); const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; @@ -109,16 +111,6 @@ const MCPSetupWizard: React.FC = () => { }); } - if (createRouteEnabled && newRouteResource && newRouteValid) { - result.push({ - type: 'create', - id: 'create-route', - label: t('Create HTTPRoute'), - resource: newRouteResource, - successMessage: t('HTTPRoute created successfully'), - }); - } - if (isCrossNamespace) { result.push({ type: 'create', @@ -200,6 +192,20 @@ const MCPSetupWizard: React.FC = () => { }, }; + if ( + !formState.httpRouteManagementEnabled && + formState.routeMode === 'new' && + newRouteResource + ) { + result.push({ + type: 'create', + id: 'create-route', + label: t('Create HTTPRoute'), + resource: newRouteResource, + successMessage: t('HTTPRoute created successfully'), + }); + } + result.push({ type: 'create', id: 'create-extension', @@ -213,8 +219,6 @@ const MCPSetupWizard: React.FC = () => { formState, newGatewayResource, newRouteResource, - newRouteValid, - createRouteEnabled, isCrossNamespace, extensionNamespace, gatewayNamespace, @@ -239,10 +243,10 @@ const MCPSetupWizard: React.FC = () => { (formState.gatewayMode === 'existing' && formState.selectedGatewayName !== '') || (formState.gatewayMode === 'new' && newGatewayValid); - const extensionValidationError = getMCPGatewayExtensionValidationError( - formState, - selectedGateway, - ); + const isRouteStepVisible = !formState.httpRouteManagementEnabled; + const isRouteStepValid = + (formState.routeMode === 'existing' && formState.selectedRouteName !== '') || + (formState.routeMode === 'new' && newRouteValid); return ( <> @@ -254,7 +258,7 @@ const MCPSetupWizard: React.FC = () => { {t('MCP Gateway Setup')}

{t( - 'Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway, route, and MCP extension.', + 'Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway and MCP extension.', )}

@@ -263,7 +267,7 @@ const MCPSetupWizard: React.FC = () => { {/* Step 1: Create Gateway */} { - {/* Step 2: MCP Extension */} + {/* MCP Extension */} { updateFormState={updateFormState} selectedGateway={selectedGateway} selectedNamespace={selectedNamespace} - onValidationChange={setExtensionValid} /> - {!formState.httpRouteManagementEnabled && ( + {isRouteStepVisible && ( - {t('Create an HTTPRoute (optional)')} + {t('Choose or create an HTTPRoute')} - - {t('You can create an HTTPRoute for MCP traffic, or continue without one.')} + + {t( + 'Select an existing route or create a new one to direct traffic to MCP servers.', + )} + + + + updateFormState({ routeMode: 'existing' })} + /> + + {formState.routeMode === 'existing' && ( + + + + updateFormState({ + routeMode: 'existing', + selectedRouteName: value, + selectedRouteNamespace: + (httpRoutes || []).find((r) => r.metadata?.name === value)?.metadata + ?.namespace || selectedNamespace, + }) + } + aria-label={t('Select an HTTPRoute')} + data-test="mcp-route-select" + isDisabled={!routesLoaded} + > + + {(httpRoutes || []).map((route) => ( + + ))} + + + {routesError && ( + + {String(routesError)} + + )} + + )} + + - setCreateRouteEnabled(checked)} + isChecked={formState.routeMode === 'new'} + onChange={() => updateFormState({ routeMode: 'new' })} /> - {createRouteEnabled && ( + {formState.routeMode === 'new' && (
{ {/* Verify configuration */} { namespace: 'gw-ns', sectionName: 'https', }); + expect(resource.spec.httpRouteManagement).toBe('Enabled'); + }); + + it('sets HTTPRoute management to Disabled when the form switch is off', () => { + const resource = buildMCPGatewayExtension( + baseFormState({ httpRouteManagementEnabled: false }), + 'default', + ); + + expect(resource.spec.httpRouteManagement).toBe('Disabled'); }); it('falls back to the provided namespace when extensionNamespace is empty', () => { @@ -264,6 +274,7 @@ describe('mcpExtensionToFormState', () => { oauthEnabled: true, oauthAuthorizationServers: 'https://a.example.com, https://b.example.com', oauthResourceName: 'MCP Server', + httpRouteManagementEnabled: true, }); const resource = buildMCPGatewayExtension(formState, 'default'); @@ -299,6 +310,21 @@ describe('mcpExtensionToFormState', () => { expect(formState.overrideHostnames).toBe(false); expect(formState.sessionStorageEnabled).toBe(false); expect(formState.oauthEnabled).toBe(false); + expect(formState.httpRouteManagementEnabled).toBe(true); + }); + + it('restores disabled HTTPRoute management from an existing resource', () => { + const resource: MCPGatewayExtension = { + apiVersion: 'mcp.kuadrant.io/v1', + kind: 'MCPGatewayExtension', + metadata: { name: 'n', namespace: 'ns' }, + spec: { + targetRef: { name: 'gw', sectionName: 'https' }, + httpRouteManagement: 'Disabled', + }, + }; + + expect(mcpExtensionToFormState(resource, 'ns').httpRouteManagementEnabled).toBe(false); }); }); diff --git a/src/components/mcp/types.test.ts b/src/components/mcp/types.test.ts index 694fda16..1f0f8a4e 100644 --- a/src/components/mcp/types.test.ts +++ b/src/components/mcp/types.test.ts @@ -7,15 +7,13 @@ describe('MCPWizardFormState', () => { expect(initialFormState.gatewayMode).toBe('existing'); }); - it('has routeMode set to existing by default', () => { - expect(initialFormState.routeMode).toBe('existing'); + it('has automatic HTTPRoute management enabled by default', () => { + expect(initialFormState.httpRouteManagementEnabled).toBe(true); }); it('has empty string values for all name fields', () => { expect(initialFormState.selectedGatewayName).toBe(''); expect(initialFormState.newGatewayName).toBe(''); - expect(initialFormState.selectedRouteName).toBe(''); - expect(initialFormState.newRouteName).toBe(''); expect(initialFormState.extensionName).toBe(''); expect(initialFormState.sectionName).toBe(''); expect(initialFormState.targetGateway).toBe(''); @@ -65,31 +63,7 @@ describe('MCPWizardFormState validation logic', () => { expect(isValid).toBe(true); }); - it('step 2 is valid when existing route is selected', () => { - const state: MCPWizardFormState = { - ...initialFormState, - routeMode: 'existing', - selectedRouteName: 'my-route', - }; - const isValid = - (state.routeMode === 'existing' && state.selectedRouteName !== '') || - (state.routeMode === 'new' && state.newRouteName.trim() !== ''); - expect(isValid).toBe(true); - }); - - it('step 2 is invalid when no route selected', () => { - const state: MCPWizardFormState = { - ...initialFormState, - routeMode: 'existing', - selectedRouteName: '', - }; - const isValid = - (state.routeMode === 'existing' && state.selectedRouteName !== '') || - (state.routeMode === 'new' && state.newRouteName.trim() !== ''); - expect(isValid).toBe(false); - }); - - it('step 3 is valid when all required fields are filled', () => { + it('step 2 is valid when all required fields are filled', () => { const state: MCPWizardFormState = { ...initialFormState, extensionName: 'my-ext', @@ -103,7 +77,7 @@ describe('MCPWizardFormState validation logic', () => { expect(isValid).toBe(true); }); - it('step 3 is invalid when extension name is missing', () => { + it('step 2 is invalid when extension name is missing', () => { const state: MCPWizardFormState = { ...initialFormState, extensionName: '', diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index 4f21da52..47146959 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -76,7 +76,7 @@ export interface MCPServerRegistration extends K8sResourceCommon { }; } -// Setup wizard form state for Steps 1-3, consumed in Step 4 for resource creation +// Setup wizard form state for Steps 1-2, consumed in Step 3 for resource creation export interface MCPWizardFormState { // Step 1: Gateway gatewayMode: 'existing' | 'new'; @@ -84,19 +84,19 @@ export interface MCPWizardFormState { selectedGatewayNamespace: string; newGatewayName: string; - // Step 2: HTTPRoute + // Optional HTTPRoute step when automatic HTTPRoute management is disabled routeMode: 'existing' | 'new'; selectedRouteName: string; selectedRouteNamespace: string; newRouteName: string; - // Step 3: MCP Extension + // MCP Extension extensionName: string; extensionNamespace: string; targetGateway: string; sectionName: string; - // Step 3: Advanced broker settings + // Step 2: Advanced broker settings overrideHostnames: boolean; publicHost: string; privateHost: string; From d7a34a63ed4dc7812b61efd3603a2996a4cdfd51 Mon Sep 17 00:00:00 2001 From: R-Lawton Date: Wed, 2 Sep 2026 17:42:58 +0100 Subject: [PATCH 3/7] fix: validate MCP HTTPRoute Gateway association Signed-off-by: R-Lawton --- e2e/tests/mcp-setup-wizard.spec.ts | 67 ++++++++++- .../en/plugin__kuadrant-console-plugin.json | 18 +-- package.json | 1 - .../httproute/HTTPRouteCreatePage.tsx | 105 ++++++++++++++++-- src/components/mcp/MCPExtensionFormFields.tsx | 21 ++++ src/components/mcp/MCPExtensionStep.tsx | 6 +- src/components/mcp/MCPSetupWizard.tsx | 98 ++++++++++++++-- src/components/mcp/mcpResourceUtils.test.ts | 66 +++++++++++ src/components/mcp/mcpResourceUtils.ts | 50 ++++++++- src/utils/ParentReferencesSelect.tsx | 70 ++++++++++-- 10 files changed, 459 insertions(+), 43 deletions(-) diff --git a/e2e/tests/mcp-setup-wizard.spec.ts b/e2e/tests/mcp-setup-wizard.spec.ts index 220d7051..046b11e6 100644 --- a/e2e/tests/mcp-setup-wizard.spec.ts +++ b/e2e/tests/mcp-setup-wizard.spec.ts @@ -115,6 +115,20 @@ spec: deleteResource('gateway', setupGatewayName, TEST_NAMESPACE); }); + let manualRouteName = ''; + let manualExtensionName = ''; + + test.afterEach(() => { + if (manualExtensionName) { + deleteResource('mcpgatewayextension', manualExtensionName, TEST_NAMESPACE); + manualExtensionName = ''; + } + if (manualRouteName) { + deleteResource('httproute', manualRouteName, TEST_NAMESPACE); + manualRouteName = ''; + } + }); + test('renders the wizard with 3 steps', { tag: '@nightly' }, async ({ page }) => { await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); @@ -147,6 +161,9 @@ spec: 'disabling automatic HTTPRoute management adds the route step', { tag: '@nightly' }, async ({ page }) => { + manualRouteName = `e2e-mcp-manual-route-${uid()}`; + manualExtensionName = `e2e-mcp-manual-ext-${uid()}`; + await page.goto(`/k8s/ns/${TEST_NAMESPACE}`); await page.waitForLoadState('networkidle'); await dismissConsoleTour(page); @@ -156,28 +173,74 @@ spec: timeout: 15_000, }); await page.locator('[data-test="mcp-gateway-select"]').selectOption({ index: 1 }); + const selectedGatewayName = await page + .locator('[data-test="mcp-gateway-select"]') + .inputValue(); await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.getByText('Advanced broker settings').click(); await expect(page.locator('[data-test="mcp-http-route-management"]')).not.toBeChecked(); - await page.locator('[data-test="mcp-http-route-management"]').click(); + await page.getByLabel('Disable automatic HTTPRoute management').click(); await expect(page.getByRole('button', { name: '3. HTTPRoute' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Configure MCP Extension' })).toBeVisible(); await expect(page.getByRole('button', { name: '2. MCP Extension' })).toBeVisible(); await expect(page.getByRole('button', { name: '4. Verify configuration' })).toBeVisible(); - await page.locator('[data-test="mcp-extension-name"]').fill('mcp-ext'); + await page.locator('[data-test="mcp-extension-name"]').fill(manualExtensionName); const sectionSelect = page.locator('[data-test="mcp-section-name"]'); + let selectedSectionName = 'http'; if (await sectionSelect.isVisible().catch(() => false)) { await sectionSelect.selectOption({ index: 1 }); } else { await page.locator('[data-test="mcp-section-name-input"]').fill('http'); } + if (await sectionSelect.isVisible().catch(() => false)) { + selectedSectionName = await sectionSelect.inputValue(); + } await page.getByRole('button', { name: 'Next', exact: true }).click(); await expect( page.getByRole('heading', { name: 'Choose or create an HTTPRoute' }), ).toBeVisible(); await expect(page.getByLabel('Create a new HTTPRoute')).toBeChecked(); + await expect(page.locator('#parent-gateway-0')).toHaveValue(selectedGatewayName); + await expect(page.locator('#parent-gateway-0')).toBeDisabled(); + await expect(page.locator('#parent-section-0')).toHaveValue(selectedSectionName); + + await page.locator('#httproute-name').fill(manualRouteName); + await page.getByRole('button', { name: 'Add rule', exact: true }).click(); + + const ruleModal = page.locator('.pf-v6-c-modal-box'); + await expect(ruleModal).toBeVisible(); + await ruleModal.getByRole('button', { name: 'Next', exact: true }).click(); + await ruleModal.getByRole('button', { name: 'Next', exact: true }).click(); + await ruleModal.locator('#service-name').fill('test-svc'); + await ruleModal.getByRole('button', { name: 'Next', exact: true }).click(); + await ruleModal.getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForSelector('.pf-v6-c-modal-box', { state: 'detached', timeout: 10_000 }); + + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(page.getByText('Create HTTPRoute', { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText('HTTPRoute created successfully')).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText('MCPGatewayExtension created successfully')).toBeVisible({ + timeout: 30_000, + }); + + expect(resourceExists('httproute', manualRouteName, TEST_NAMESPACE)).toBe(true); + expect( + kubectl([ + 'get', + 'httproute', + manualRouteName, + '-n', + TEST_NAMESPACE, + '-o', + 'jsonpath={.spec.parentRefs[0].name}', + ]), + ).toBe(selectedGatewayName); }, ); diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index 43e48ce8..a1ea5621 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -16,7 +16,7 @@ "1 request": "1 request", "1. Create Gateway": "1. Create Gateway", "2. MCP Extension": "2. MCP Extension", - "3. Verify configuration": "3. Verify configuration", + "3. HTTPRoute": "3. HTTPRoute", "A brief description of how you intend to use this API key.": "A brief description of how you intend to use this API key.", "A consolidated view of all policies attached to this route (including gateway-level policies).": "A consolidated view of all policies attached to this route (including gateway-level policies).", "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.": "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.", @@ -43,6 +43,7 @@ "Add a tag to your API product": "Add a tag to your API product", "Add access credentials": "Add access credentials", "Add address": "Add address", + "Add an MCP server to your Gateway.": "Add an MCP server to your Gateway.", "Add API and Associate route": "Add API and Associate route", "Add at least one rule before creating an HTTPRoute.": "Add at least one rule before creating an HTTPRoute.", "Add certificate reference": "Add certificate reference", @@ -87,7 +88,6 @@ "Allowed Namespaces": "Allowed Namespaces", "Allowed Route Kinds": "Allowed Route Kinds", "Allowed Routes": "Allowed Routes", - "Add an MCP server to your Gateway.": "Add an MCP server to your Gateway.", "Already exists": "Already exists", "An error occurred in the wizard": "An error occurred in the wizard", "An error occurred while loading extensions": "An error occurred while loading extensions", @@ -141,8 +141,6 @@ "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.": "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.", "Automatic": "Automatic", "Automatic approval": "Automatic approval", - "Automatically manage HTTPRoute": "Automatically manage HTTPRoute", - "Disable automatic HTTPRoute management": "Disable automatic HTTPRoute management", "Backend": "Backend", "Backend references": "Backend references", "Backend Services": "Backend Services", @@ -177,6 +175,7 @@ "Comma-separated list of OAuth authorization server URLs.": "Comma-separated list of OAuth authorization server URLs.", "Conditions": "Conditions", "Configuration": "Configuration", + "Configure how a Gateway connects to MCP servers.": "Configure how a Gateway connects to MCP servers.", "Configure MCP Extension": "Configure MCP Extension", "Configure routing rule with matches and backend services": "Configure routing rule with matches and backend services", "Configured Limits": "Configured Limits", @@ -273,6 +272,7 @@ "Destination name": "Destination name", "DestinationRule created successfully": "DestinationRule created successfully", "Details": "Details", + "Disable automatic HTTPRoute management": "Disable automatic HTTPRoute management", "Disabled": "Disabled", "Display Name": "Display Name", "Display name for your API product (shown to users)": "Display name for your API product (shown to users)", @@ -470,6 +470,7 @@ "HTTPRoute name help": "HTTPRoute name help", "HTTPRoute policies": "HTTPRoute policies", "HTTPRoute provides a way to route HTTP requests to backends.": "HTTPRoute provides a way to route HTTP requests to backends.", + "HTTPRoute target": "HTTPRoute target", "HTTPRoute: Reference to a Kubernetes resource that the policy attaches to.": "HTTPRoute: Reference to a Kubernetes resource that the policy attaches to.", "HTTPRoutes": "HTTPRoutes", "https://auth.example.com": "https://auth.example.com", @@ -502,6 +503,7 @@ "Label value": "Label value", "Labels for categorizing and organizing API Products": "Labels for categorizing and organizing API Products", "Last 24h overview": "Last 24h overview", + "Learn how to create, import, and use MCP gateways and servers.": "Learn how to create, import, and use MCP gateways and servers.", "Lifecycle and Visibility": "Lifecycle and Visibility", "Limit": "Limit", "Limit Name": "Limit Name", @@ -640,9 +642,9 @@ "One or more hostnames of the external service, comma-separated.": "One or more hostnames of the external service, comma-separated.", "Online": "Online", "Only HTTPRoute is supported by this Gateway.": "Only HTTPRoute is supported by this Gateway.", - "OpenAPI Spec URL": "OpenAPI Spec URL", "Open extension setup wizard": "Open extension setup wizard", "Open server registration wizard": "Open server registration wizard", + "OpenAPI Spec URL": "OpenAPI Spec URL", "Optional hostname to match requests. Leave empty to match all hostnames.": "Optional hostname to match requests. Leave empty to match all hostnames.", "Override hostnames": "Override hostnames", "Override the public and private hostnames derived from the gateway listener.": "Override the public and private hostnames derived from the gateway listener.", @@ -698,7 +700,6 @@ "ReferenceGrant check": "ReferenceGrant check", "ReferenceGrant created successfully": "ReferenceGrant created successfully", "Register an internal MCP server by creating an HTTPRoute and server registration": "Register an internal MCP server by creating an HTTPRoute and server registration", - "Register an internal MCP server.": "Register an internal MCP server.", "Register MCP server": "Register MCP server", "Register MCP Server": "Register MCP Server", "Registration name": "Registration name", @@ -789,7 +790,6 @@ "Select an existing route or create a new one for the MCP server.": "Select an existing route or create a new one for the MCP server.", "Select an existing route or create a new one to direct traffic to MCP servers.": "Select an existing route or create a new one to direct traffic to MCP servers.", "Select an HTTPRoute": "Select an HTTPRoute", - "Select an HTTPRoute that defines how traffic reaches your MCP servers.": "Select an HTTPRoute that defines how traffic reaches your MCP servers.", "Select an HTTPRoute that the MCP server will register with.": "Select an HTTPRoute that the MCP server will register with.", "Select an HTTPRoute. APIProduct will be created in the same namespace.": "Select an HTTPRoute. APIProduct will be created in the same namespace.", "Select an Issuer": "Select an Issuer", @@ -830,8 +830,6 @@ "Session storage": "Session storage", "set": "set", "Set": "Set", - "Set up an MCPGatewayExtension for a Gateway.": "Set up an MCPGatewayExtension for a Gateway.", - "Set up MCP Gateway Extension": "Set up MCP Gateway Extension", "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway and MCP extension.": "Set up the infrastructure needed to expose MCP servers through a gateway. This wizard will guide you through creating a gateway and MCP extension.", "Set up your MCP infrastructure by creating a gateway, route, and MCP extension. Use the setup wizard to get started quickly.": "Set up your MCP infrastructure by creating a gateway, route, and MCP extension. Use the setup wizard to get started quickly.", "Setup external MCP server": "Setup external MCP server", @@ -862,6 +860,7 @@ "The denial reason will apply to all selected requests.": "The denial reason will apply to all selected requests.", "The gateway class used for this Gateway.": "The gateway class used for this Gateway.", "The hostname this destination rule applies traffic policy to. Matches the host registered in the service entry.": "The hostname this destination rule applies traffic policy to. Matches the host registered in the service entry.", + "The HTTPRoute must attach to the selected Gateway and listener.": "The HTTPRoute must attach to the selected Gateway and listener.", "The HTTPRoute that this MCP server registration targets.": "The HTTPRoute that this MCP server registration targets.", "The key will be automatically revoked on this date.": "The key will be automatically revoked on this date.", "The key will not expire.": "The key will not expire.", @@ -971,6 +970,7 @@ "Yearly Limit": "Yearly Limit", "You are about to reveal the API key. Make sure to copy and store it securely.": "You are about to reveal the API key. Make sure to copy and store it securely.", "You do not have permission to approve or deny API key requests": "You do not have permission to approve or deny API key requests", + "You do not have permission to create {{policyType}}": "You do not have permission to create {{policyType}}", "You do not have permission to create a {{policyType}}": "You do not have permission to create a {{policyType}}", "You do not have permission to create a Gateway": "You do not have permission to create a Gateway", "You do not have permission to create a GRPCRoute": "You do not have permission to create a GRPCRoute", diff --git a/package.json b/package.json index f3e499d7..9dfea0be 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,6 @@ "useHTTPRouteActions": "./components/httproute/useHTTPRouteActions", "GatewaySingleOverview": "./components/gateway/GatewaySingleOverview", "HTTPRouteSingleOverview": "./components/httproute/HTTPRouteSingleOverview", - "MCPOverviewPage": "./components/mcp/MCPOverviewPage", "MCPSetupWizard": "./components/mcp/MCPSetupWizard", "MCPGatewayExtensionCreatePage": "./components/mcp/MCPGatewayExtensionCreatePage", "MCPServerRegistrationCreatePage": "./components/mcp/MCPServerRegistrationCreatePage" diff --git a/src/components/httproute/HTTPRouteCreatePage.tsx b/src/components/httproute/HTTPRouteCreatePage.tsx index 7bd62fa5..8029c246 100644 --- a/src/components/httproute/HTTPRouteCreatePage.tsx +++ b/src/components/httproute/HTTPRouteCreatePage.tsx @@ -31,7 +31,9 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { useLocation, useNavigate } from 'react-router'; import * as yaml from 'js-yaml'; -import ParentReferencesSelect from '../../utils/ParentReferencesSelect'; +import ParentReferencesSelect, { + RequiredParentReference, +} from '../../utils/ParentReferencesSelect'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { HTTPRouteResource, HTTPRouteMatch } from './types'; import { @@ -62,9 +64,15 @@ interface ParentReference { interface HTTPRouteCreatePageProps { onFormChange?: (resource: HTTPRouteResource, isValid: boolean) => void; + // The MCP wizard uses this to keep manually-created routes attached to the + // Gateway/listener selected in the preceding steps. + requiredParentRef?: RequiredParentReference; } -const HTTPRouteCreatePage: React.FC = ({ onFormChange }) => { +const HTTPRouteCreatePage: React.FC = ({ + onFormChange, + requiredParentRef, +}) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); const [routeName, setRouteName] = React.useState(''); @@ -76,7 +84,9 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange // YAML editor state const [yamlContent, setYamlContent] = React.useState(null); const [yamlError, setYamlError] = React.useState(null); - const [parentRefs, setParentRefs] = React.useState([]); + const [parentRefs, setParentRefs] = React.useState(() => + requiredParentRef ? [{ ...requiredParentRef }] : [], + ); // Metadata for determining edit/create mode const [originalMetadata, setOriginalMetadata] = React.useState< @@ -114,6 +124,46 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange const nameEdit = resourceIndex >= 0 ? segments[resourceIndex + 1] : undefined; const selectedNamespace = !selectedNamespaceRaw || selectedNamespaceRaw === '#ALL_NS#' ? 'default' : selectedNamespaceRaw; + + const requiredParentReference = requiredParentRef; + + const isRequiredParentReference = React.useCallback( + (parentRef: ParentReference) => + !!requiredParentReference && + parentRef.gatewayName === requiredParentReference.gatewayName && + parentRef.gatewayNamespace === requiredParentReference.gatewayNamespace && + (!parentRef.sectionName || parentRef.sectionName === requiredParentReference.sectionName), + [requiredParentReference], + ); + + React.useEffect(() => { + if (!requiredParentReference) return; + + setParentRefs((currentParentRefs) => { + const currentRequired = currentParentRefs.find( + (parentRef) => parentRef.id === requiredParentReference.id, + ); + const requiredIsUnchanged = + currentRequired && + currentRequired.gatewayName === requiredParentReference.gatewayName && + currentRequired.gatewayNamespace === requiredParentReference.gatewayNamespace && + currentRequired.sectionName === requiredParentReference.sectionName && + currentRequired.port === requiredParentReference.port; + + if (requiredIsUnchanged && currentParentRefs[0]?.id === requiredParentReference.id) { + return currentParentRefs; + } + + return [ + requiredParentReference, + ...currentParentRefs.filter( + (parentRef) => + parentRef.id !== requiredParentReference.id && !isRequiredParentReference(parentRef), + ), + ]; + }); + }, [requiredParentReference, isRequiredParentReference]); + // Function to add a new hostname field const addHostnameField = () => { setHostnames([...hostnames, '']); @@ -136,7 +186,16 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange const httpRouteObject = React.useMemo(() => { // Filter out empty hostnames const validHostnames = hostnames.filter((h) => h.trim().length > 0); - const validParentRefs = parentRefs.filter((ref) => ref.gatewayName); + const effectiveParentRefs = requiredParentReference + ? [ + requiredParentReference, + ...parentRefs.filter( + (parentRef) => + parentRef.id !== requiredParentReference.id && !isRequiredParentReference(parentRef), + ), + ] + : parentRefs; + const validParentRefs = effectiveParentRefs.filter((ref) => ref.gatewayName); const httpRoute = { apiVersion: 'gateway.networking.k8s.io/v1', @@ -180,7 +239,16 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange }; return httpRoute; - }, [routeName, hostnames, parentRefs, rules, selectedNamespace, originalMetadata]); + }, [ + routeName, + hostnames, + parentRefs, + rules, + selectedNamespace, + originalMetadata, + requiredParentReference, + isRequiredParentReference, + ]); const populateFormFromHTTPRoute = (httpRoute: unknown) => { try { @@ -202,8 +270,14 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange port: ref.port || 0, }), ); - if (JSON.stringify(formattedParentRefs) !== JSON.stringify(parentRefs)) - setParentRefs(formattedParentRefs); + const nextParentRefs = requiredParentReference + ? [ + requiredParentReference, + ...formattedParentRefs.filter((parentRef) => !isRequiredParentReference(parentRef)), + ] + : formattedParentRefs; + if (JSON.stringify(nextParentRefs) !== JSON.stringify(parentRefs)) + setParentRefs(nextParentRefs); } if (hr.spec?.rules && hr.spec.rules.length > 0) { @@ -334,7 +408,16 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange }; const formValidation = () => { - const hasValidParentRef = parentRefs.some((ref) => ref.gatewayName); + const effectiveParentRefs = requiredParentReference + ? [ + requiredParentReference, + ...parentRefs.filter( + (parentRef) => + parentRef.id !== requiredParentReference.id && !isRequiredParentReference(parentRef), + ), + ] + : parentRefs; + const hasValidParentRef = effectiveParentRefs.some((ref) => ref.gatewayName); // Gateway API requires spec.rules to have at least one item (minItems=1), // so an HTTPRoute with zero rules is rejected by the API server. @@ -462,7 +545,11 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange - + = ({ + {validationError?.includes('extension namespace') && formState.extensionNamespace && ( + + + {validationError} + + + )} )} @@ -450,6 +457,13 @@ const MCPExtensionFormFields: React.FC = ({ placeholder={t('e.g. redis-session-secret')} data-test="mcp-session-store-secret" /> + {validationError?.includes('session store Secret') && ( + + + {validationError} + + + )} )} @@ -507,6 +521,13 @@ const MCPExtensionFormFields: React.FC = ({ placeholder={t('e.g. https://auth.example.com')} data-test="mcp-oauth-auth-servers" /> + {validationError?.includes('OAuth authorization server') && ( + + + {validationError} + + + )} diff --git a/src/components/mcp/MCPExtensionStep.tsx b/src/components/mcp/MCPExtensionStep.tsx index c1ed365d..03a228c0 100644 --- a/src/components/mcp/MCPExtensionStep.tsx +++ b/src/components/mcp/MCPExtensionStep.tsx @@ -37,6 +37,10 @@ const MCPExtensionStep: React.FC = ({ [formState, selectedNamespace], ); const validationError = getMCPGatewayExtensionValidationError(formState, selectedGateway); + const handleValidationChange = React.useCallback( + (isValid: boolean) => onValidationChange?.(isValid && !validationError), + [onValidationChange, validationError], + ); // Handle YAML changes and sync back to form const handleYamlChange = (yamlInput: string) => { @@ -100,7 +104,7 @@ const MCPExtensionStep: React.FC = ({ selectedGateway={selectedGateway} selectedNamespace={selectedNamespace} validationError={validationError} - onValidationChange={onValidationChange} + onValidationChange={handleValidationChange} /> ) : ( diff --git a/src/components/mcp/MCPSetupWizard.tsx b/src/components/mcp/MCPSetupWizard.tsx index ca635b9c..fad20615 100644 --- a/src/components/mcp/MCPSetupWizard.tsx +++ b/src/components/mcp/MCPSetupWizard.tsx @@ -33,6 +33,10 @@ import MCPExtensionStep from './MCPExtensionStep'; import MCPVerifyStep, { VerifyStepItem, WatchResourceConfig } from './MCPVerifyStep'; import GatewayCreatePage from '../gateway/GatewayCreatePage'; import HTTPRouteCreatePage from '../httproute/HTTPRouteCreatePage'; +import { + getMCPGatewayExtensionValidationError, + isHTTPRouteAttachedToGateway, +} from './mcpResourceUtils'; import '../css/gateway-api-plugin.css'; const MCPSetupWizard: React.FC = () => { @@ -90,13 +94,79 @@ const MCPSetupWizard: React.FC = () => { // Get listeners from the selected gateway for the listener dropdown in Step 3 const selectedGateway = React.useMemo(() => { - if (formState.gatewayMode !== 'existing' || !formState.selectedGatewayName) return undefined; - return (gateways || []).find((gw) => gw.metadata?.name === formState.selectedGatewayName); - }, [gateways, formState.gatewayMode, formState.selectedGatewayName]); + if (formState.gatewayMode === 'new') return newGatewayResource || undefined; + if (!formState.selectedGatewayName) return undefined; + return (gateways || []).find( + (gw) => + gw.metadata?.name === formState.selectedGatewayName && + (gw.metadata?.namespace || selectedNamespace) === + (formState.selectedGatewayNamespace || selectedNamespace), + ); + }, [ + gateways, + formState.gatewayMode, + formState.selectedGatewayName, + formState.selectedGatewayNamespace, + newGatewayResource, + selectedNamespace, + ]); const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; const isCrossNamespace = extensionNamespace !== gatewayNamespace; + const selectedGatewayTarget = React.useMemo( + () => ({ + name: formState.targetGateway, + namespace: gatewayNamespace, + sectionName: formState.sectionName, + }), + [formState.targetGateway, formState.sectionName, gatewayNamespace], + ); + const matchingHttpRoutes = React.useMemo( + () => + (httpRoutes || []).filter((route) => + isHTTPRouteAttachedToGateway(route, selectedGatewayTarget, selectedNamespace), + ), + [httpRoutes, selectedGatewayTarget, selectedNamespace], + ); + const extensionValidationError = getMCPGatewayExtensionValidationError( + formState, + selectedGateway, + ); + + React.useEffect(() => { + if ( + !routesLoaded || + formState.routeMode !== 'existing' || + !formState.selectedRouteName || + matchingHttpRoutes.some((route) => route.metadata?.name === formState.selectedRouteName) + ) { + return; + } + + updateFormState({ selectedRouteName: '', selectedRouteNamespace: selectedNamespace }); + }, [ + routesLoaded, + formState.routeMode, + formState.selectedRouteName, + matchingHttpRoutes, + selectedNamespace, + updateFormState, + ]); + + const requiredRouteParentRef = React.useMemo( + () => ({ + id: 'mcp-required-parent-ref', + gatewayName: formState.targetGateway, + gatewayNamespace, + sectionName: formState.sectionName, + port: + selectedGateway?.spec?.listeners?.find( + (listener) => listener.name === formState.sectionName, + )?.port || 0, + }), + [formState.targetGateway, formState.sectionName, gatewayNamespace, selectedGateway], + ); const verifyItems = React.useMemo(() => { const result: VerifyStepItem[] = []; @@ -195,7 +265,8 @@ const MCPSetupWizard: React.FC = () => { if ( !formState.httpRouteManagementEnabled && formState.routeMode === 'new' && - newRouteResource + newRouteResource && + newRouteValid ) { result.push({ type: 'create', @@ -219,6 +290,7 @@ const MCPSetupWizard: React.FC = () => { formState, newGatewayResource, newRouteResource, + newRouteValid, isCrossNamespace, extensionNamespace, gatewayNamespace, @@ -391,10 +463,7 @@ const MCPSetupWizard: React.FC = () => { id="step-extension" footer={{ nextButtonText: t('Next'), - isNextDisabled: - !formState.extensionName.trim() || - !formState.targetGateway.trim() || - !formState.sectionName.trim(), + isNextDisabled: !!extensionValidationError, }} > { 'Select an existing route or create a new one to direct traffic to MCP servers.', )} + + {t('The HTTPRoute must attach to the selected Gateway and listener.')} + @@ -444,7 +521,7 @@ const MCPSetupWizard: React.FC = () => { routeMode: 'existing', selectedRouteName: value, selectedRouteNamespace: - (httpRoutes || []).find((r) => r.metadata?.name === value)?.metadata + matchingHttpRoutes.find((r) => r.metadata?.name === value)?.metadata ?.namespace || selectedNamespace, }) } @@ -457,7 +534,7 @@ const MCPSetupWizard: React.FC = () => { label={!routesLoaded ? t('Loading routes...') : t('Select a route...')} isPlaceholder /> - {(httpRoutes || []).map((route) => ( + {matchingHttpRoutes.map((route) => ( {
{ setNewRouteResource(resource); setNewRouteValid(isValid); diff --git a/src/components/mcp/mcpResourceUtils.test.ts b/src/components/mcp/mcpResourceUtils.test.ts index f3b74f3d..d01dc541 100644 --- a/src/components/mcp/mcpResourceUtils.test.ts +++ b/src/components/mcp/mcpResourceUtils.test.ts @@ -5,6 +5,7 @@ import { getMCPGatewayExtensionValidationError, isKubernetesResourceName, isGatewayListenerName, + isHTTPRouteAttachedToGateway, buildMCPServerRegistration, wireHTTPRouteToExternalHost, parseServiceEntryHosts, @@ -261,6 +262,71 @@ describe('MCPGatewayExtension validation', () => { getMCPGatewayExtensionValidationError(baseFormState({ sectionName: 'listener with spaces' })), ).toBe('The listener name must be a valid Kubernetes name.'); }); + + it('rejects an invalid target Gateway name', () => { + expect( + getMCPGatewayExtensionValidationError(baseFormState({ targetGateway: 'Invalid Gateway' })), + ).toBe('The target Gateway name must be a valid Kubernetes resource name.'); + }); + + it('rejects an invalid extension namespace', () => { + expect( + getMCPGatewayExtensionValidationError(baseFormState({ extensionNamespace: 'not.valid' })), + ).toBe('The extension namespace must be a valid Kubernetes namespace.'); + }); +}); + +describe('HTTPRoute Gateway association', () => { + const target = { name: 'mcp-gateway', namespace: 'mcp', sectionName: 'http' }; + + it('matches a parent reference in the route namespace without a section', () => { + expect( + isHTTPRouteAttachedToGateway( + { + metadata: { namespace: 'mcp' }, + spec: { parentRefs: [{ name: 'mcp-gateway' }] }, + }, + target, + ), + ).toBe(true); + }); + + it('matches an explicit Gateway namespace and listener', () => { + expect( + isHTTPRouteAttachedToGateway( + { + spec: { + parentRefs: [ + { + name: 'mcp-gateway', + namespace: 'mcp', + sectionName: 'http', + }, + ], + }, + }, + target, + 'routes', + ), + ).toBe(true); + }); + + it('does not match a different Gateway or listener', () => { + const route = { + metadata: { namespace: 'mcp' }, + spec: { parentRefs: [{ name: 'other-gateway', sectionName: 'http' }] }, + }; + expect(isHTTPRouteAttachedToGateway(route, target)).toBe(false); + expect( + isHTTPRouteAttachedToGateway( + { + metadata: { namespace: 'mcp' }, + spec: { parentRefs: [{ name: 'mcp-gateway', sectionName: 'https' }] }, + }, + target, + ), + ).toBe(false); + }); }); describe('mcpExtensionToFormState', () => { diff --git a/src/components/mcp/mcpResourceUtils.ts b/src/components/mcp/mcpResourceUtils.ts index 940cc77e..82651b28 100644 --- a/src/components/mcp/mcpResourceUtils.ts +++ b/src/components/mcp/mcpResourceUtils.ts @@ -16,6 +16,8 @@ import { import { HTTPRouteResource } from '../httproute/types'; import { RESOURCES, Secret } from '../../utils/resources'; import type { GatewayResource } from '../gateway/types'; +import type { HTTPRouteResource } from '../httproute/types'; +import { validateNamespace } from '../../utils/validation'; // Key used within the credential Secret's stringData for the token configured in // step 4 (Add access credentials) of the external MCP wizard. @@ -36,21 +38,57 @@ export const isKubernetesResourceName = (name: string): boolean => export const isGatewayListenerName = (name: string): boolean => name.length > 0 && name.length <= 63 && DNS_LABEL_REGEX.test(name); +export interface HTTPRouteGatewayTarget { + name: string; + namespace: string; + sectionName: string; +} + +/** + * Returns whether an HTTPRoute has a parent reference for the selected + * Gateway. A missing namespace means the route's own namespace, and a + * missing sectionName means the reference applies to the Gateway generally. + */ +export const isHTTPRouteAttachedToGateway = ( + route: HTTPRouteResource, + target: HTTPRouteGatewayTarget, + routeNamespace = route.metadata?.namespace || '', +): boolean => + (route.spec?.parentRefs || []).some((parentRef) => { + const parentNamespace = parentRef.namespace || routeNamespace; + const parentGroup = parentRef.group || 'gateway.networking.k8s.io'; + const parentKind = parentRef.kind || 'Gateway'; + + return ( + parentRef.name === target.name && + parentNamespace === target.namespace && + parentGroup === 'gateway.networking.k8s.io' && + parentKind === 'Gateway' && + (!parentRef.sectionName || parentRef.sectionName === target.sectionName) + ); + }); + export const getMCPGatewayExtensionValidationError = ( formState: MCPWizardFormState, selectedGateway?: GatewayResource, ): string | null => { - if (!isKubernetesResourceName(formState.extensionName.trim())) { + if (!isKubernetesResourceName(formState.extensionName)) { return 'The extension name must be a valid Kubernetes resource name.'; } + if (formState.extensionNamespace && validateNamespace(formState.extensionNamespace)) { + return 'The extension namespace must be a valid Kubernetes namespace.'; + } if (!formState.targetGateway.trim()) return 'A target Gateway is required.'; - if (!isGatewayListenerName(formState.sectionName.trim())) { + if (!isKubernetesResourceName(formState.targetGateway)) { + return 'The target Gateway name must be a valid Kubernetes resource name.'; + } + if (!isGatewayListenerName(formState.sectionName)) { return 'The listener name must be a valid Kubernetes name.'; } if (selectedGateway) { const listenerExists = (selectedGateway.spec?.listeners || []).some( - (listener) => listener.name === formState.sectionName.trim(), + (listener) => listener.name === formState.sectionName, ); if (!listenerExists) { return `Listener "${formState.sectionName}" was not found on Gateway "${formState.targetGateway}".`; @@ -60,6 +98,12 @@ export const getMCPGatewayExtensionValidationError = ( if (formState.sessionStorageEnabled && !formState.sessionStoreSecretName.trim()) { return 'A session store Secret name is required when session storage is enabled.'; } + if ( + formState.sessionStorageEnabled && + !isKubernetesResourceName(formState.sessionStoreSecretName) + ) { + return 'The session store Secret name must be a valid Kubernetes resource name.'; + } if (formState.oauthEnabled && !formState.oauthAuthorizationServers.trim()) { return 'At least one OAuth authorization server is required when OAuth is enabled.'; } diff --git a/src/utils/ParentReferencesSelect.tsx b/src/utils/ParentReferencesSelect.tsx index c5c345d6..165208f1 100644 --- a/src/utils/ParentReferencesSelect.tsx +++ b/src/utils/ParentReferencesSelect.tsx @@ -62,16 +62,26 @@ interface ParentReference { port: number; } +export interface RequiredParentReference { + id: string; + gatewayName: string; + gatewayNamespace: string; + sectionName: string; + port: number; +} + interface ParentReferencesSelectProps { parentRefs: ParentReference[]; onChange: (parentRefs: ParentReference[]) => void; isDisabled?: boolean; + requiredParentRef?: RequiredParentReference; } const ParentReferencesSelect: React.FC = ({ parentRefs, onChange, isDisabled = false, + requiredParentRef, }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [availableGateways, setAvailableGateways] = React.useState([]); @@ -79,6 +89,28 @@ const ParentReferencesSelect: React.FC = ({ const isAllNamespaces = !activeNamespace || activeNamespace === '#ALL_NS#'; const selectedNamespace = isAllNamespaces ? undefined : activeNamespace; + const requiredGateway = React.useMemo( + () => + requiredParentRef + ? ({ + metadata: { + name: requiredParentRef.gatewayName, + namespace: requiredParentRef.gatewayNamespace, + }, + spec: { + listeners: [ + { + name: requiredParentRef.sectionName, + port: requiredParentRef.port, + protocol: 'HTTP', + }, + ], + }, + } as GatewayForSelect) + : undefined, + [requiredParentRef], + ); + // Load all available Gateways const gatewayResource = { groupVersionKind: { @@ -165,7 +197,17 @@ const ParentReferencesSelect: React.FC = ({ // Sort Gateways: available first, then unavailable const getSortedGateways = () => { - return [...availableGateways].sort((a, b) => { + const gateways = + requiredGateway && + !availableGateways.some( + (gateway) => + gateway.metadata.name === requiredGateway.metadata.name && + gateway.metadata.namespace === requiredGateway.metadata.namespace, + ) + ? [...availableGateways, requiredGateway] + : availableGateways; + + return [...gateways].sort((a, b) => { const restrictionA = validateGateway(a); const restrictionB = validateGateway(b); @@ -180,7 +222,7 @@ const ParentReferencesSelect: React.FC = ({ // Sort Listeners const getSortedSections = (gatewayName: string, gatewayNamespace: string) => { - const gateway = availableGateways.find( + const gateway = [...availableGateways, ...(requiredGateway ? [requiredGateway] : [])].find( (gw) => gw.metadata.name === gatewayName && gw.metadata.namespace === gatewayNamespace, ); @@ -225,7 +267,10 @@ const ParentReferencesSelect: React.FC = ({ // If Gateway is changed, automatically update namespace and reset section if (field === 'gatewayName') { - const selectedGateway = availableGateways.find((gw) => gw.metadata.name === value); + const selectedGateway = [ + ...availableGateways, + ...(requiredGateway ? [requiredGateway] : []), + ].find((gw) => gw.metadata.name === value); if (selectedGateway) { updatedRef.gatewayNamespace = selectedGateway.metadata.namespace; updatedRef.sectionName = ''; @@ -235,7 +280,10 @@ const ParentReferencesSelect: React.FC = ({ // If Section is changed, update port if (field === 'sectionName') { - const selectedGateway = availableGateways.find( + const selectedGateway = [ + ...availableGateways, + ...(requiredGateway ? [requiredGateway] : []), + ].find( (gw) => gw.metadata.name === ref.gatewayName && gw.metadata.namespace === ref.gatewayNamespace, @@ -297,7 +345,8 @@ const ParentReferencesSelect: React.FC = ({ }} titleDescription={description} actions={ - !isDisabled && ( + !isDisabled && + parentRef.id !== requiredParentRef?.id && (