diff --git a/e2e/tests/mcp-setup-wizard.spec.ts b/e2e/tests/mcp-setup-wizard.spec.ts index c03ce389..046b11e6 100644 --- a/e2e/tests/mcp-setup-wizard.spec.ts +++ b/e2e/tests/mcp-setup-wizard.spec.ts @@ -115,7 +115,21 @@ spec: deleteResource('gateway', setupGatewayName, TEST_NAMESPACE); }); - test('renders the wizard with 4 steps', { tag: '@nightly' }, async ({ page }) => { + 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'); await expect(page.getByRole('heading', { name: 'MCP Gateway Setup' })).toBeVisible({ @@ -123,9 +137,8 @@ spec: }); 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: '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 +158,94 @@ spec: }); test( - 'steps 2-4 are disabled until step 1 is complete', + '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); + 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 }); + 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.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(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); + }, + ); + + test( + 'steps 2-3 are disabled until step 1 is complete', { tag: '@nightly' }, async ({ page }) => { await spaNavigate(page, '/kuadrant/mcp/setup-wizard'); @@ -154,9 +254,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(); }, ); @@ -200,7 +299,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()}`; @@ -219,23 +317,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 `, ); }); @@ -245,7 +326,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'); @@ -262,12 +343,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 +359,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, @@ -295,35 +371,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()}`; @@ -349,22 +407,20 @@ 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, }); 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 4: Verify + // Step 3: Verify await expect(page.getByText('Create Gateway', { exact: true })).toBeVisible({ timeout: 15_000, }); @@ -405,23 +461,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 `, ); }); @@ -446,14 +485,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 +510,7 @@ spec: await nextButton.click(); - // Step 4: Verify + // Step 3: Verify await expect(page.getByText('MCPGatewayExtension created successfully')).toBeVisible({ timeout: 30_000, }); @@ -525,23 +557,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 `, ); }); @@ -571,14 +586,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 +600,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/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index 229cc7c8..8e446a49 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. 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.", @@ -26,6 +25,8 @@ "A list of actions to perform on a request or response before it is sent to the backend.": "A list of actions to perform on a request or response before it is sent to the backend.", "A map of key/value pairs to enable implementation-specific TLS options, such as minimum TLS version or cipher suites.": "A map of key/value pairs to enable implementation-specific TLS options, such as minimum TLS version or cipher suites.", "A prefix applied to all tools exposed by this MCP server.": "A prefix applied to all tools exposed by this MCP server.", + "A session store Secret name is required when session storage is enabled.": "A session store Secret name is required when session storage is enabled.", + "A target Gateway is required.": "A target Gateway is required.", "A unified policy that automatically generates and manages underlying Kubernetes Rate Limit and Auth resources to define consumption rules for an API Product.": "A unified policy that automatically generates and manages underlying Kubernetes Rate Limit and Auth resources to define consumption rules for an API Product.", "A unique lowercase name for the MCP server registration.": "A unique lowercase name for the MCP server registration.", "A unique lowercase name for this credential secret.": "A unique lowercase name for this credential secret.", @@ -44,6 +45,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", @@ -130,6 +132,7 @@ "Associated resources": "Associated resources", "At least one host is required.": "At least one host is required.", "At least one listener is required to create a Gateway.": "At least one listener is required to create a Gateway.", + "At least one OAuth authorization server is required when OAuth is enabled.": "At least one OAuth authorization server is required when OAuth is enabled.", "At least one parent reference required for the HTTPRoute": "At least one parent reference required for the HTTPRoute", "Attached": "Attached", "Attached Policies": "Attached Policies", @@ -175,6 +178,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", @@ -214,6 +218,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", @@ -270,6 +275,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)", @@ -430,6 +436,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", @@ -464,6 +473,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", @@ -496,11 +506,13 @@ "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", "Limit value": "Limit value", "Link to external documentation for this API": "Link to external documentation for this API", + "Listener \"{{listener}}\" was not found on Gateway \"{{gateway}}\".": "Listener \"{{listener}}\" was not found on Gateway \"{{gateway}}\".", "Listener is not accepted.": "Listener is not accepted.", "Listener is not programmed.": "Listener is not programmed.", "Listener name": "Listener name", @@ -634,6 +646,8 @@ "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.", + "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", @@ -780,7 +794,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", @@ -821,7 +834,7 @@ "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 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", @@ -848,9 +861,13 @@ "The base URL of the OIDC provider (e.g. https://auth.example.com)": "The base URL of the OIDC provider (e.g. https://auth.example.com)", "The client ID registered with the OIDC provider": "The client ID registered with the OIDC provider", "The credential value sent to authenticate to the external service, e.g. a bearer token.": "The credential value sent to authenticate to the external service, e.g. a bearer token.", + "The created resource could not be found.": "The created resource could not be found.", "The denial reason will apply to all selected requests.": "The denial reason will apply to all selected requests.", + "The extension name must be a valid Kubernetes resource name.": "The extension name must be a valid Kubernetes resource name.", + "The extension namespace must be a valid Kubernetes namespace.": "The extension namespace must be a valid Kubernetes namespace.", "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.", @@ -858,6 +875,7 @@ "The Kubernetes namespace where the gateway infrastructure will be deployed.": "The Kubernetes namespace where the gateway infrastructure will be deployed.", "The Kubernetes resource name for this API key": "The Kubernetes resource name for this API key", "The Kubernetes Secret type used to store this credential.": "The Kubernetes Secret type used to store this credential.", + "The listener name must be a valid Kubernetes name.": "The listener name must be a valid Kubernetes name.", "The name of the gateway listener to use for MCP traffic.": "The name of the gateway listener to use for MCP traffic.", "The name of the gateway this extension targets.": "The name of the gateway this extension targets.", "The namespace for the extension. If different from the gateway namespace, a ReferenceGrant will be created.": "The namespace for the extension. If different from the gateway namespace, a ReferenceGrant will be created.", @@ -866,6 +884,7 @@ "The port the external service listens on.": "The port the external service listens on.", "The protocol that this listener will accept.": "The protocol that this listener will accept.", "The protocol used to access the external service.": "The protocol used to access the external service.", + "The resource could not be created or verified.": "The resource could not be created or verified.", "The resource has an unknown status and is not accepted.": "The resource has an unknown status and is not accepted.", "The resource is accepted but not all policies are enforced.": "The resource is accepted but not all policies are enforced.", "The resource is accepted, programmed, and all policies are enforced.": "The resource is accepted, programmed, and all policies are enforced.", @@ -874,10 +893,12 @@ "The resource is overridden and not enforced.": "The resource is overridden and not enforced.", "The resource is programmed but not fully enforced.": "The resource is programmed but not fully enforced.", "The Server Name Indication to send when originating TLS. Defaults to the host.": "The Server Name Indication to send when originating TLS. Defaults to the host.", + "The TLS connection mode used when connecting to the external service.": "The TLS connection mode used when connecting to the external service.", + "The session store Secret name must be a valid Kubernetes resource name.": "The session store Secret name must be a valid Kubernetes resource name.", "The status of the resource could not be determined.": "The status of the resource could not be determined.", "The status of the resource is unknown.": "The status of the resource is unknown.", "The target for the resource was not found and it is not accepted.": "The target for the resource was not found and it is not accepted.", - "The TLS connection mode used when connecting to the external service.": "The TLS connection mode used when connecting to the external service.", + "The target Gateway name must be a valid Kubernetes resource name.": "The target Gateway name must be a valid Kubernetes resource name.", "There are no {{resourceName}} to display - please create some.": "There are no {{resourceName}} to display - please create some.", "There are no API key requests yet": "There are no API key requests yet", "There are no API Keys to display - request access to an API Product to get started.": "There are no API Keys to display - request access to an API Product to get started.", @@ -950,6 +971,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).", @@ -959,6 +981,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", @@ -982,4 +1005,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/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 - + void; } @@ -54,9 +55,13 @@ const MCPExtensionFormFields: React.FC = ({ disableIdentity = false, gatewayNames = [], showNamespaceField = true, + validationError, onValidationChange, }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const validationMessage = validationError + ? t(validationError.messageKey, validationError.messageParams) + : null; // Validation state const [errors, setErrors] = React.useState<{ @@ -196,6 +201,13 @@ const MCPExtensionFormFields: React.FC = ({ placeholder={t('Enter extension name')} data-test="mcp-extension-name" /> + {validationError?.field === 'extensionName' && formState.extensionName.trim() && ( + + + {validationMessage} + + + )} = ({ + {validationError?.field === 'extensionNamespace' && formState.extensionNamespace && ( + + + {validationMessage} + + + )} )} @@ -297,6 +316,13 @@ const MCPExtensionFormFields: React.FC = ({ + {validationError?.field === 'targetGateway' && formState.targetGateway.trim() && ( + + + {validationMessage} + + + )} @@ -352,6 +378,13 @@ const MCPExtensionFormFields: React.FC = ({ + {validationError?.field === 'sectionName' && formState.sectionName.trim() && ( + + + {validationMessage} + + + )} @@ -425,9 +458,40 @@ const MCPExtensionFormFields: React.FC = ({ placeholder={t('e.g. redis-session-secret')} data-test="mcp-session-store-secret" /> + {validationError?.field === 'sessionStoreSecretName' && ( + + + {validationMessage} + + + )} )} + + { + 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.', + )} + + + + + = ({ placeholder={t('e.g. https://auth.example.com')} data-test="mcp-oauth-auth-servers" /> + {validationError?.field === 'oauthAuthorizationServers' && ( + + + {validationMessage} + + + )} diff --git a/src/components/mcp/MCPExtensionStep.test.tsx b/src/components/mcp/MCPExtensionStep.test.tsx index a463cd83..00683c25 100644 --- a/src/components/mcp/MCPExtensionStep.test.tsx +++ b/src/components/mcp/MCPExtensionStep.test.tsx @@ -134,4 +134,18 @@ describe('MCPExtensionStep', () => { 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/MCPExtensionStep.tsx b/src/components/mcp/MCPExtensionStep.tsx index 246c5195..03a228c0 100644 --- a/src/components/mcp/MCPExtensionStep.tsx +++ b/src/components/mcp/MCPExtensionStep.tsx @@ -4,7 +4,10 @@ import { ResourceYAMLEditor } from '@openshift-console/dynamic-plugin-sdk'; import { useTranslation } from 'react-i18next'; import * as yaml from 'js-yaml'; import { MCPWizardFormState, MCPGatewayExtension } from './types'; -import { buildMCPGatewayExtension } from './mcpResourceUtils'; +import { + buildMCPGatewayExtension, + getMCPGatewayExtensionValidationError, +} from './mcpResourceUtils'; import MCPExtensionFormFields from './MCPExtensionFormFields'; import '../css/gateway-api-plugin.css'; import { GatewayResource } from '../gateway/types'; @@ -33,6 +36,11 @@ const MCPExtensionStep: React.FC = ({ () => buildMCPGatewayExtension(formState, selectedNamespace), [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) => { @@ -58,6 +66,7 @@ const MCPExtensionStep: React.FC = ({ oauthAuthorizationServers: parsed.spec?.oauthProtectedResource?.authorizationServers?.join(', ') || '', oauthResourceName: parsed.spec?.oauthProtectedResource?.resourceName || '', + httpRouteManagementEnabled: parsed.spec?.httpRouteManagement !== 'Disabled', }); } } catch { @@ -88,13 +97,16 @@ const MCPExtensionStep: React.FC = ({ {createView === 'form' ? ( - + <> + + ) : (
{t('Loading YAML editor...')}
}> diff --git a/src/components/mcp/MCPGatewayExtensionCreatePage.tsx b/src/components/mcp/MCPGatewayExtensionCreatePage.tsx index a0313da6..d33f2946 100644 --- a/src/components/mcp/MCPGatewayExtensionCreatePage.tsx +++ b/src/components/mcp/MCPGatewayExtensionCreatePage.tsx @@ -24,6 +24,7 @@ import { GatewayResource } from '../gateway/types'; import { buildMCPGatewayExtension, mcpExtensionToFormState, + getMCPGatewayExtensionValidationError, isMCPGatewayExtensionValid, } from './mcpResourceUtils'; import MCPExtensionFormFields from './MCPExtensionFormFields'; @@ -133,8 +134,14 @@ const MCPGatewayExtensionCreatePage: React.FC = () => { namespace: formState.selectedGatewayNamespace || selectedNamespace, }); const selectedGateway = React.useMemo( - () => (gateways || []).find((gw) => gw.metadata?.name === formState.targetGateway), - [gateways, formState.targetGateway], + () => + (gateways || []).find( + (gw) => + gw.metadata?.name === formState.targetGateway && + (gw.metadata?.namespace || selectedNamespace) === + (formState.selectedGatewayNamespace || selectedNamespace), + ), + [gateways, formState.targetGateway, formState.selectedGatewayNamespace, selectedNamespace], ); const gatewayNames = React.useMemo( () => @@ -164,6 +171,7 @@ const MCPGatewayExtensionCreatePage: React.FC = () => { () => getModelFromResource(extensionResource), [extensionResource], ); + const validationError = getMCPGatewayExtensionValidationError(formState, selectedGateway); const redirectPath = `/kuadrant/mcp/overview/ns/${ extensionResource.metadata?.namespace || selectedNamespace @@ -233,10 +241,11 @@ const MCPGatewayExtensionCreatePage: React.FC = () => { disableIdentity={isEdit} gatewayNames={gatewayNames} showNamespaceField={false} + validationError={validationError} /> 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,19 @@ 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, + }; + }); + + afterEach(() => { + document.body.classList.remove('pf-v6-theme-light', 'pf-v6-theme-dark'); }); it('renders the empty state when no extensions exist', () => { @@ -153,4 +189,61 @@ 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.each([ + ['light', 'pf-v6-theme-light'], + ['dark', 'pf-v6-theme-dark'], + ])('renders getting-started content in the %s theme', (_theme, themeClass) => { + document.body.classList.add(themeClass); + mockMcpResourceKind = 'both'; + + const { container } = render(); + + const gettingStartedAlert = container.querySelector('.kuadrant-mcp-getting-started-alert'); + expect(document.body).toHaveClass(themeClass); + expect(gettingStartedAlert).toBeInTheDocument(); + expect(gettingStartedAlert).toHaveClass('kuadrant-mcp-getting-started-alert'); + expect(gettingStartedAlert).toHaveTextContent('Get started with MCP management'); + expect(screen.getByTestId('mcp-getting-started-extension-button')).toBeVisible(); + expect(screen.getByTestId('mcp-getting-started-registration-button')).toBeVisible(); + }); + + 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 0943cbd9..42ebb629 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 = () => { @@ -48,7 +52,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 [formState, setFormState] = React.useState({ ...initialFormState, @@ -64,7 +67,7 @@ const MCPSetupWizard: React.FC = () => { namespace: selectedNamespace, }); - // Watch existing HTTPRoutes for Step 2 dropdown + // Watch existing HTTPRoutes only for the optional manually-managed route step. const [httpRoutes, routesLoaded, routesError] = useK8sWatchResource({ groupVersionKind: RESOURCES.HTTPRoute.gvk, isList: true, @@ -91,13 +94,87 @@ 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.targetGateway) return undefined; + if (formState.gatewayMode === 'new') { + return newGatewayResource?.metadata?.name === formState.targetGateway + ? newGatewayResource + : undefined; + } + return (gateways || []).find( + (gw) => + gw.metadata?.name === formState.targetGateway && + (gw.metadata?.namespace || selectedNamespace) === + (formState.selectedGatewayNamespace || selectedNamespace), + ); + }, [ + gateways, + formState.gatewayMode, + formState.targetGateway, + formState.selectedGatewayNamespace, + newGatewayResource, + selectedNamespace, + ]); const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; const isCrossNamespace = extensionNamespace !== gatewayNamespace; + const selectedListenerPort = selectedGateway?.spec?.listeners?.find( + (listener) => listener.name === formState.sectionName, + )?.port; + const selectedGatewayTarget = React.useMemo( + () => ({ + name: formState.targetGateway, + namespace: gatewayNamespace, + sectionName: formState.sectionName, + port: selectedListenerPort, + }), + [formState.targetGateway, formState.sectionName, gatewayNamespace, selectedListenerPort], + ); + 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[] = []; @@ -112,16 +189,6 @@ const MCPSetupWizard: React.FC = () => { }); } - if (formState.routeMode === 'new' && newRouteResource) { - result.push({ - type: 'create', - id: 'create-route', - label: t('Create HTTPRoute'), - resource: newRouteResource, - successMessage: t('HTTPRoute created successfully'), - }); - } - if (isCrossNamespace) { result.push({ type: 'create', @@ -199,9 +266,25 @@ const MCPSetupWizard: React.FC = () => { }, } : {}), + httpRouteManagement: formState.httpRouteManagementEnabled ? 'Enabled' : 'Disabled', }, }; + if ( + !formState.httpRouteManagementEnabled && + formState.routeMode === 'new' && + newRouteResource && + newRouteValid + ) { + 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', @@ -215,6 +298,7 @@ const MCPSetupWizard: React.FC = () => { formState, newGatewayResource, newRouteResource, + newRouteValid, isCrossNamespace, extensionNamespace, gatewayNamespace, @@ -239,8 +323,8 @@ 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 = + const isRouteStepVisible = !formState.httpRouteManagementEnabled; + const isRouteStepValid = (formState.routeMode === 'existing' && formState.selectedRouteName !== '') || (formState.routeMode === 'new' && newRouteValid); @@ -254,7 +338,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.', )}

@@ -381,130 +465,13 @@ const MCPSetupWizard: React.FC = () => { - {/* 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 */} + {/* MCP Extension */} { updateFormState={updateFormState} selectedGateway={selectedGateway} selectedNamespace={selectedNamespace} - onValidationChange={setExtensionValid} /> - {/* Step 4: Verify configuration */} + {isRouteStepVisible && ( + + + {t('Choose or create an HTTPRoute')} + + + {t( + '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.')} + + + + + updateFormState({ routeMode: 'existing' })} + /> + + {formState.routeMode === 'existing' && ( + + + + updateFormState({ + routeMode: 'existing', + selectedRouteName: value, + selectedRouteNamespace: + matchingHttpRoutes.find((r) => r.metadata?.name === value)?.metadata + ?.namespace || selectedNamespace, + }) + } + aria-label={t('Select an HTTPRoute')} + data-test="mcp-route-select" + isDisabled={!routesLoaded} + > + + {matchingHttpRoutes.map((route) => ( + + ))} + + + {routesError && ( + + {String(routesError)} + + )} + + )} + + + + + updateFormState({ routeMode: 'new' })} + /> + + {formState.routeMode === 'new' && ( + +
+ { + 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('prefers the top-level API message over a Kubernetes cause', async () => { + mockK8sCreate.mockRejectedValueOnce({ + json: { + message: 'Top-level API message', + details: { causes: [{ message: 'Cause message' }] }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Top-level API message').length).toBeGreaterThan(0); + }); + expect(screen.queryByText('Cause message')).not.toBeInTheDocument(); + }); + + it('falls back when structured causes contain invalid entries', async () => { + mockK8sCreate.mockRejectedValueOnce({ + json: { + reason: 'Invalid', + details: { causes: [null, {}, { message: '' }, 'invalid cause'] }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Invalid').length).toBeGreaterThan(0); + }); + }); + + it('falls back when structured causes are not an array', async () => { + mockK8sCreate.mockRejectedValueOnce({ + json: { + reason: 'Invalid', + details: { causes: { message: 'not an array' } }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Invalid').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..205a5916 100644 --- a/src/components/mcp/MCPVerifyStep.tsx +++ b/src/components/mcp/MCPVerifyStep.tsx @@ -51,6 +51,42 @@ interface WatchedResource extends K8sResourceCommon { }; } +const getErrorMessage = (error: unknown, fallbackMessage: string): string => { + if (error instanceof Error && error.message) return error.message; + const response = error as { + message?: unknown; + reason?: unknown; + json?: { + message?: unknown; + reason?: unknown; + details?: { causes?: unknown }; + }; + }; + + const causes = response?.json?.details?.causes; + const causeMessage = Array.isArray(causes) + ? causes.find( + (cause): cause is { message: string } => + typeof cause === 'object' && + cause !== null && + 'message' in cause && + typeof cause.message === 'string' && + cause.message.length > 0, + )?.message + : undefined; + const getStringMessage = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + + return ( + getStringMessage(response?.json?.message) || + causeMessage || + getStringMessage(response?.message) || + getStringMessage(response?.json?.reason) || + getStringMessage(response?.reason) || + fallbackMessage + ); +}; + interface MCPVerifyStepProps { items: VerifyStepItem[]; watchResource: WatchResourceConfig; @@ -86,7 +122,7 @@ const MCPVerifyStep: React.FC = ({ const watchReadyId = 'watch-ready'; - const [watchedData, watchedLoaded] = useK8sWatchResource( + const [watchedData, watchedLoaded, watchedError] = useK8sWatchResource( watchStarted ? { groupVersionKind: watchResource.gvk, @@ -98,7 +134,19 @@ const MCPVerifyStep: React.FC = ({ ); React.useEffect(() => { - if (!watchStarted || !watchedLoaded || !watchedData) return; + if (!watchStarted || !watchedLoaded) return; + if (watchedError) { + updateCheckById( + watchReadyId, + 'error', + getErrorMessage(watchedError, t('The resource could not be created or verified.')), + ); + return; + } + if (!watchedData) { + updateCheckById(watchReadyId, 'error', t('The created resource could not be found.')); + return; + } const conditions = watchedData.status?.conditions || []; const readyCondition = conditions.find((c) => c.type === 'Ready'); @@ -116,7 +164,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 +243,7 @@ const MCPVerifyStep: React.FC = ({ setWatchStarted(true); onAllCreated?.(); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err, t('The resource could not be created or verified.')); setError(message); setChecks((prev) => diff --git a/src/components/mcp/mcpResourceUtils.test.ts b/src/components/mcp/mcpResourceUtils.test.ts index 51aecc80..aa3d9b02 100644 --- a/src/components/mcp/mcpResourceUtils.test.ts +++ b/src/components/mcp/mcpResourceUtils.test.ts @@ -2,6 +2,10 @@ import { buildMCPGatewayExtension, mcpExtensionToFormState, isMCPGatewayExtensionValid, + getMCPGatewayExtensionValidationError, + isKubernetesResourceName, + isGatewayListenerName, + isHTTPRouteAttachedToGateway, buildMCPServerRegistration, wireHTTPRouteToExternalHost, parseServiceEntryHosts, @@ -51,6 +55,16 @@ describe('buildMCPGatewayExtension', () => { 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', () => { @@ -211,6 +225,169 @@ 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), + ).toEqual({ + field: 'sectionName', + messageKey: 'Listener "{{listener}}" was not found on Gateway "{{gateway}}".', + messageParams: { listener: 'https', gateway: 'my-gw' }, + }); + }); + + it('does not validate a stale Gateway against the current target', () => { + const staleGateway = { + 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({ targetGateway: 'other-gw', sectionName: 'https' }), + staleGateway, + ), + ).toBeNull(); + }); + + it('rejects invalid listener names', () => { + expect(isGatewayListenerName('listener with spaces')).toBe(false); + expect( + getMCPGatewayExtensionValidationError(baseFormState({ sectionName: 'listener with spaces' })), + ).toEqual({ + field: 'sectionName', + messageKey: 'The listener name must be a valid Kubernetes name.', + }); + }); + + it('rejects an invalid target Gateway name', () => { + expect( + getMCPGatewayExtensionValidationError(baseFormState({ targetGateway: 'Invalid Gateway' })), + ).toEqual({ + field: 'targetGateway', + messageKey: 'The target Gateway name must be a valid Kubernetes resource name.', + }); + }); + + it('rejects an invalid extension namespace', () => { + expect( + getMCPGatewayExtensionValidationError(baseFormState({ extensionNamespace: 'not.valid' })), + ).toEqual({ + field: 'extensionNamespace', + messageKey: 'The extension namespace must be a valid Kubernetes namespace.', + }); + }); +}); + +describe('HTTPRoute Gateway association', () => { + const target = { name: 'mcp-gateway', namespace: 'mcp', sectionName: 'http', port: 80 }; + + 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('matches an explicit Gateway listener port', () => { + expect( + isHTTPRouteAttachedToGateway( + { + metadata: { namespace: 'mcp' }, + spec: { + parentRefs: [{ name: 'mcp-gateway', sectionName: 'http', port: 80 }], + }, + }, + target, + ), + ).toBe(true); + }); + + it('does not match a different Gateway listener port', () => { + expect( + isHTTPRouteAttachedToGateway( + { + spec: { + parentRefs: [{ name: 'mcp-gateway', sectionName: 'http', port: 443 }], + }, + }, + target, + ), + ).toBe(false); + }); + + 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', () => { it('is the inverse of buildMCPGatewayExtension for a fully-populated resource', () => { const formState = baseFormState({ @@ -222,6 +399,7 @@ describe('mcpExtensionToFormState', () => { oauthEnabled: true, oauthAuthorizationServers: 'https://a.example.com, https://b.example.com', oauthResourceName: 'MCP Server', + httpRouteManagementEnabled: true, }); const resource = buildMCPGatewayExtension(formState, 'default'); @@ -257,6 +435,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); }); }); @@ -265,6 +458,22 @@ describe('isMCPGatewayExtensionValid', () => { expect(isMCPGatewayExtensionValid(baseFormState())).toBe(true); }); + it('returns false when the selected Gateway does not contain the listener', () => { + 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(isMCPGatewayExtensionValid(baseFormState({ sectionName: 'https' }), gateway)).toBe( + false, + ); + }); + it.each([ ['extensionName', { extensionName: '' }], ['extensionName (whitespace only)', { extensionName: ' ' }], diff --git a/src/components/mcp/mcpResourceUtils.ts b/src/components/mcp/mcpResourceUtils.ts index fa134335..0cdfa99e 100644 --- a/src/components/mcp/mcpResourceUtils.ts +++ b/src/components/mcp/mcpResourceUtils.ts @@ -15,6 +15,8 @@ import { } from './types'; import { HTTPRouteResource } from '../httproute/types'; import { RESOURCES, Secret } from '../../utils/resources'; +import type { GatewayResource } from '../gateway/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. @@ -26,6 +28,172 @@ 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 type MCPGatewayExtensionValidationField = + | 'extensionName' + | 'extensionNamespace' + | 'targetGateway' + | 'sectionName' + | 'sessionStoreSecretName' + | 'oauthAuthorizationServers'; + +export type MCPGatewayExtensionValidationMessageKey = + | 'The extension name must be a valid Kubernetes resource name.' + | 'The extension namespace must be a valid Kubernetes namespace.' + | 'A target Gateway is required.' + | 'The target Gateway name must be a valid Kubernetes resource name.' + | 'The listener name must be a valid Kubernetes name.' + | 'Listener "{{listener}}" was not found on Gateway "{{gateway}}".' + | 'A session store Secret name is required when session storage is enabled.' + | 'The session store Secret name must be a valid Kubernetes resource name.' + | 'At least one OAuth authorization server is required when OAuth is enabled.'; + +export interface MCPGatewayExtensionValidationError { + field: MCPGatewayExtensionValidationField; + messageKey: MCPGatewayExtensionValidationMessageKey; + messageParams?: Record; +} + +export interface HTTPRouteGatewayTarget { + name: string; + namespace: string; + sectionName: string; + port?: number; +} + +/** + * 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 or port means the reference applies to the Gateway + * generally for that selector. + */ +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) && + (!parentRef.port || parentRef.port === target.port) + ); + }); + +export const getMCPGatewayExtensionValidationError = ( + formState: MCPWizardFormState, + selectedGateway?: GatewayResource, +): MCPGatewayExtensionValidationError | null => { + if (!isKubernetesResourceName(formState.extensionName)) { + return { + field: 'extensionName', + messageKey: 'The extension name must be a valid Kubernetes resource name.', + }; + } + if (formState.extensionNamespace && validateNamespace(formState.extensionNamespace)) { + return { + field: 'extensionNamespace', + messageKey: 'The extension namespace must be a valid Kubernetes namespace.', + }; + } + if (!formState.targetGateway.trim()) { + return { field: 'targetGateway', messageKey: 'A target Gateway is required.' }; + } + if (!isKubernetesResourceName(formState.targetGateway)) { + return { + field: 'targetGateway', + messageKey: 'The target Gateway name must be a valid Kubernetes resource name.', + }; + } + if (!isGatewayListenerName(formState.sectionName)) { + return { + field: 'sectionName', + messageKey: 'The listener name must be a valid Kubernetes name.', + }; + } + + const selectedGatewayMatchesTarget = + selectedGateway?.metadata?.name === formState.targetGateway && + (!formState.selectedGatewayNamespace || + selectedGateway.metadata?.namespace === formState.selectedGatewayNamespace); + + if (selectedGatewayMatchesTarget) { + const listenerExists = (selectedGateway.spec?.listeners || []).some( + (listener) => listener.name === formState.sectionName, + ); + if (!listenerExists) { + return { + field: 'sectionName', + messageKey: 'Listener "{{listener}}" was not found on Gateway "{{gateway}}".', + messageParams: { + listener: formState.sectionName, + gateway: formState.targetGateway, + }, + }; + } + } + + if (formState.sessionStorageEnabled && !formState.sessionStoreSecretName.trim()) { + return { + field: 'sessionStoreSecretName', + messageKey: 'A session store Secret name is required when session storage is enabled.', + }; + } + if ( + formState.sessionStorageEnabled && + !isKubernetesResourceName(formState.sessionStoreSecretName) + ) { + return { + field: 'sessionStoreSecretName', + messageKey: 'The session store Secret name must be a valid Kubernetes resource name.', + }; + } + if (formState.oauthEnabled && !formState.oauthAuthorizationServers.trim()) { + return { + field: 'oauthAuthorizationServers', + messageKey: 'At least one OAuth authorization server is required when OAuth is enabled.', + }; + } + return null; +}; + +/** + * i18n extraction marker for validation keys returned above. The utility keeps + * validation independent from React; callers translate the returned key at + * the UI boundary. + */ +export const _mcpGatewayExtensionValidationI18nKeys = ( + t: (key: string, options?: Record) => string, +): string[] => [ + t('The extension name must be a valid Kubernetes resource name.'), + t('The extension namespace must be a valid Kubernetes namespace.'), + t('A target Gateway is required.'), + t('The target Gateway name must be a valid Kubernetes resource name.'), + t('The listener name must be a valid Kubernetes name.'), + t('Listener "{{listener}}" was not found on Gateway "{{gateway}}".', { + listener: '', + gateway: '', + }), + t('A session store Secret name is required when session storage is enabled.'), + t('The session store Secret name must be a valid Kubernetes resource name.'), + t('At least one OAuth authorization server is required when OAuth is enabled.'), +]; + // 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 +219,7 @@ export const buildMCPGatewayExtension = ( namespace: gatewayNamespace, sectionName: formState.sectionName, }, + httpRouteManagement: formState.httpRouteManagementEnabled ? 'Enabled' : 'Disabled', }, }; @@ -100,16 +269,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.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 ad24ec07..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; @@ -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 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 && (