Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import Utils from "@support/utils";

/*
* Regression tests for: "Fix MCP server Runtime page crash when an endpoint is removed"
*
* Manual reproduction flow:
* 1. Create an MCP server (which sets up a production endpoint)
* 2. Add a sandbox endpoint so both types exist (required to enable the delete button)
* 3. Go to Endpoints → click the sandbox endpoint's delete button → confirm
* 4. Go to Runtime → page previously crashed; after the fix it renders correctly
*
* Before the fix, accessing production_endpoints.url or sandbox_endpoints.url
* directly on the fetched endpointConfig threw when either key was null,
* crashing the React component. The fix uses optional chaining (?.) so a
* missing endpoint is treated as undefined (falsy) and the panel shows "-".
*/
describe("MCP Server — runtime configuration endpoint display", () => {
const { publisher, password } = Utils.getUserInfo();
let mcpServerId;

before(() => {
cy.loginToPublisher(publisher, password);
});

it("runtime configuration page renders correctly after sandbox endpoint is removed via UI", () => {
// Step 1 — create the MCP server. The spec includes a servers entry so APIM
// auto-creates a backend with production_endpoints.url set.
Utils.addMCPServer({}).then((id) => {
mcpServerId = id;

// Step 2 — add sandbox URL to the backend so the delete button is enabled.
// shouldDisableDelete() in EndpointCard only enables delete when BOTH
// production AND sandbox URLs are present on the same backend.
Utils.getApiToken().then((token) => {
const listCurl = `curl -k -s \
-H "Authorization: Bearer ${token}" \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/${id}/backends"`;

cy.exec(listCurl).then((listResult) => {
const backends = JSON.parse(listResult.stdout);
const firstBackend = backends.list && backends.list[0];

if (!firstBackend) {
throw new Error(
'addMCPServer did not create a backend — ' +
'ensure the spec includes a servers entry so APIM auto-creates one.'
);
}

const backendId = firstBackend.id;
let endpointConfig;
try {
endpointConfig = typeof firstBackend.endpointConfig === 'string'
? JSON.parse(firstBackend.endpointConfig)
: firstBackend.endpointConfig;
} catch (_) {
endpointConfig = {};
}

// Add a sandbox URL alongside the existing production URL.
const updatedConfig = {
...endpointConfig,
sandbox_endpoints: { url: 'https://sandbox.petstore.swagger.io/v2' },
};
const updatedBackend = JSON.stringify({
...firstBackend,
endpointConfig: updatedConfig,
});

const putCurl = `curl -k -s -X PUT \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${token}" \
-d '${updatedBackend.replace(/'/g, "'\\''")}' \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/${id}/backends/${backendId}"`;

cy.exec(putCurl).then(() => {
// Step 3 — navigate to the Endpoints page and delete the sandbox
// endpoint through the UI (the same user action that triggers the bug).
cy.visit(`${Utils.getAppOrigin()}/publisher/mcp-servers/${id}/endpoints`);

// The sandbox card's delete button (color="error") is enabled only
// when both production AND sandbox URLs exist — which we just set up.
cy.contains('h2', 'Sandbox Endpoint')
.closest('.MuiPaper-root')
.find('button[color="error"]')
.should('not.be.disabled')
.click();

// Confirm the deletion in the dialog.
cy.get('[aria-labelledby="delete-confirmation-dialog-title"]').within(() => {
cy.contains('button', 'Delete').click();
});

// Wait for the sandbox card to disappear (page re-fetches after delete).
cy.contains('No sandbox endpoints configured').should('be.visible');

// Step 4 — navigate to Runtime Configuration (the crash scenario).
cy.visit(`${Utils.getAppOrigin()}/publisher/mcp-servers/${id}/runtime-configuration`);

// The Endpoints accordion must render — a JS crash would leave it absent.
cy.get('#endpoints').should('exist');

// Production section must be visible and show its URL.
cy.get('#endpoints').within(() => {
cy.contains('Production').should('be.visible');
cy.contains('https://petstore.swagger.io/v2').should('be.visible');

// Sandbox section must show "-" (not crash) because it was deleted.
cy.contains('Sandbox').should('be.visible');
cy.contains('-').should('be.visible');
});
});
});
});
});
});

after(() => {
Utils.deleteMCPServer(mcpServerId);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import Utils from "@support/utils";

/*
* Regression test for: "Fix MCP server Runtime page crash when an endpoint is removed"
* Creation type: generate-from-api (From Existing API)
*
* generate-from-api does not auto-create a backend with endpoint URLs, so the
* endpointConfig passed to the Runtime Configuration Endpoints component is null.
* showEndpoint() must return null safely (not crash) in this state.
*
* If the backend IS auto-created with URLs, the test additionally goes through
* the full UI delete flow and verifies the sandbox shows "-" after deletion.
*/
describe("MCP Server (from existing API) — runtime configuration endpoint display", () => {
const { publisher, password } = Utils.getUserInfo();
let mcpServerId;

before(() => {
cy.loginToPublisher(publisher, password);
});

it("runtime configuration page renders correctly for MCP server created from existing API", () => {
Utils.addMCPServerFromExistingAPI({}).then((id) => {
mcpServerId = id;

Utils.getApiToken().then((token) => {
const listCurl = `curl -k -s \
-H "Authorization: Bearer ${token}" \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/${id}/backends"`;

cy.exec(listCurl).then((listResult) => {
const backends = JSON.parse(listResult.stdout);
const firstBackend = backends.list && backends.list[0];

if (!firstBackend) {
// generate-from-api did not auto-create a backend.
// endpointConfig is null — verify showEndpoint() returns null safely
// (the fix guards against crashes when endpointConfig itself is absent).
cy.visit(`${Utils.getAppOrigin()}/publisher/mcp-servers/${id}/runtime-configuration`);
cy.get('#endpoints').should('exist');
return;
}

// Backend exists — set up both production and sandbox URLs so the
// sandbox delete button is enabled, then run the full UI delete flow.
const backendId = firstBackend.id;
let endpointConfig;
try {
endpointConfig = typeof firstBackend.endpointConfig === 'string'
? JSON.parse(firstBackend.endpointConfig)
: firstBackend.endpointConfig || {};
} catch (_) {
endpointConfig = {};
}

const updatedConfig = {
...endpointConfig,
production_endpoints: endpointConfig.production_endpoints
|| { url: 'https://petstore.swagger.io/v2' },
sandbox_endpoints: { url: 'https://sandbox.petstore.swagger.io/v2' },
};
const updatedBackend = JSON.stringify({
...firstBackend,
endpointConfig: updatedConfig,
});

const putCurl = `curl -k -s -X PUT \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${token}" \
-d '${updatedBackend.replace(/'/g, "'\\''")}' \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/${id}/backends/${backendId}"`;

cy.exec(putCurl).then(() => {
cy.visit(`${Utils.getAppOrigin()}/publisher/mcp-servers/${id}/endpoints`);

cy.contains('h2', 'Sandbox Endpoint')
.closest('.MuiPaper-root')
.find('button[color="error"]')
.should('not.be.disabled')
.click();

cy.get('[aria-labelledby="delete-confirmation-dialog-title"]').within(() => {
cy.contains('button', 'Delete').click();
});

cy.contains('No sandbox endpoints configured').should('be.visible');

cy.visit(`${Utils.getAppOrigin()}/publisher/mcp-servers/${id}/runtime-configuration`);

cy.get('#endpoints').should('exist');
cy.get('#endpoints').within(() => {
cy.contains('Production').should('be.visible');
cy.contains('Sandbox').should('be.visible');
cy.contains('-').should('be.visible');
});
});
});
});
});
});

after(() => {
Utils.deleteMCPServer(mcpServerId);
});
});
114 changes: 114 additions & 0 deletions tests/cypress/support/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,120 @@ export default class Utils {
});
}

/**
* Create an MCP Server from a minimal OpenAPI spec (multipart upload).
* Returns a Cypress.Promise that resolves to the new MCP server's id.
*/
static addMCPServer(data) {
let { name, version, context } = data || {};
name = name || Utils.generateName();
version = version || '1.0.0';
context = context || ('/' + name.replace(/[^A-Z0-9]/ig, '_'));

const additionalProps = JSON.stringify({
name,
version,
context,
policies: ['Unlimited'],
});
// Minimal valid OpenAPI 3.0 spec with a server URL so APIM auto-creates a
// production backend; single-quoted inner JSON avoids shell escaping issues
const specJson = JSON.stringify({
openapi: '3.0.0',
info: { title: name, version },
servers: [{ url: 'https://petstore.swagger.io/v2' }],
paths: {},
});

return new Cypress.Promise((resolve, reject) => {
try {
Utils.getApiToken().then((token) => {
const tmpFile = `/tmp/mcp_spec_${name}.json`;
cy.exec(`printf '%s' '${specJson.replace(/'/g, "'\\''")}' > ${tmpFile}`).then(() => {
const curl = `curl -k -X POST \
-F "file=@${tmpFile};type=application/json" \
-F "additionalProperties=${additionalProps.replace(/"/g, '\\"')}" \
-H "Authorization: Bearer ${token}" \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/generate-from-openapi"`;
cy.exec(curl).then((result) => {
cy.exec(`rm -f ${tmpFile}`, { failOnNonZeroExit: false });
let parsed;
try {
parsed = JSON.parse(result.stdout);
} catch (e) {
throw new Error(`addMCPServer: non-JSON response. body=${result.stdout}`);
}
if (!parsed || !parsed.id) {
throw new Error(`addMCPServer: server returned no id. body=${result.stdout}`);
}
resolve(parsed.id);
});
});
});
} catch (e) {
reject(e);
}
});
}

/**
* Create an MCP Server using an existing API's properties via generate-from-api.
* No OpenAPI spec is needed; APIM creates the server from the supplied metadata.
* A backend may or may not be auto-created — callers should check /backends.
* Resolves with the new MCP server's id.
*/
static addMCPServerFromExistingAPI(data) {
let { name, version, context } = data || {};
name = name || Utils.generateName();
version = version || '1.0.0';
context = context || ('/' + name.replace(/[^A-Z0-9]/ig, '_'));

const body = JSON.stringify({
name,
version,
context,
policies: ['Unlimited'],
transport: ['http', 'https'],
operations: [],
});

return new Cypress.Promise((resolve, reject) => {
try {
Utils.getApiToken().then((token) => {
const curl = `curl -k -s -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${token}" \
-d '${body.replace(/'/g, "'\\''")}' \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/generate-from-api"`;
cy.exec(curl).then((result) => {
let parsed;
try {
parsed = JSON.parse(result.stdout);
} catch (e) {
throw new Error(`addMCPServerFromExistingAPI: non-JSON response. body=${result.stdout}`);
}
if (!parsed || !parsed.id) {
throw new Error(`addMCPServerFromExistingAPI: server returned no id. body=${result.stdout}`);
}
resolve(parsed.id);
});
});
} catch (e) {
reject(e);
}
});
}
static deleteMCPServer(mcpServerId) {
if (!mcpServerId) return;
Cypress.on('uncaught:exception', () => false);
return Utils.getApiToken().then((token) => {
const curl = `curl -k -X DELETE \
-H "Authorization: Bearer ${token}" \
"${Cypress.config().baseUrl}/api/am/publisher/v4/mcp-servers/${mcpServerId}"`;
return cy.exec(curl, { failOnNonZeroExit: false });
});
}

static deleteAPIProduct(productId) {
if (!productId) return;
Cypress.on('uncaught:exception', () => false);
Expand Down
Loading