Skip to content
Merged
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ SSO_ENABLED=false
# SSO_KEYCLOAK_SCOPES=openid profile email
# SSO_LOGIN_STATE_TTL_SECONDS=300

# Build-time UI feature flags. These are read by Vite when the frontend starts.
VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=false

# Used by `npm run e2e:docker` — credentials for a real backend user the e2e
# auth fixture logs in as, and docker-compose.e2e.yml's gateway admin
# password — must be 22+ chars (privileged-account minimum) and not contain
Expand Down
19 changes: 14 additions & 5 deletions e2e/tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ test.describe("Tools page", () => {
let previewHeaders: Record<string, string> = {};

await routeToolsList(page, [previewTool]);
await page.route("**/tools/preview/search_issues", async (route) => {
await page.route("**/v1/tools/preview/search_issues", async (route) => {
previewBody = route.request().postDataJSON();
previewHeaders = route.request().headers();
await route.fulfill({
Expand Down Expand Up @@ -361,7 +361,11 @@ test.describe("Tools page", () => {
await expect(panel.getByText("Read-only")).toBeVisible();

const previewButton = panel.getByRole("button", { name: "Preview" });
await expect(previewButton).toBeDisabled();
await expect(previewButton).toBeEnabled();
await previewButton.click();
await expect(panel.getByLabel("query")).toHaveAttribute("aria-invalid", "true");
await expect(panel.getByText("Required")).toBeVisible();
expect(previewBody).toBeNull();

await panel.getByLabel("query").fill("cloudflare");
await panel.getByLabel("limit").fill("5");
Expand Down Expand Up @@ -426,15 +430,20 @@ test.describe("Tools page", () => {
const panel = await openToolDetails(page, "github-server");

await expect(panel.getByText("MCP 2025-11-25")).toBeVisible();
await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled();
const invokeButton = panel.getByRole("button", { name: "Live invoke" });
await expect(invokeButton).toBeEnabled();
await invokeButton.click();
await expect(panel.getByLabel("query")).toHaveAttribute("aria-invalid", "true");
await expect(panel.getByText("Required")).toBeVisible();
expect(rpcBody).toBeNull();

await panel.getByLabel("query").fill("cloudflare");
await panel.getByLabel("limit").fill("5");
await panel.getByRole("button", { name: "Add header" }).click();
await panel.getByLabel("Header 1 name").fill("X-Tenant-Id");
await panel.getByLabel("Header 1 value").fill("team-a");

await panel.getByRole("button", { name: "Live invoke" }).click();
await invokeButton.click();

await expect(panel.getByText("Live invoke 200")).toBeVisible();
await expect(panel.getByText("Live result from gateway").first()).toBeVisible();
Expand Down Expand Up @@ -624,7 +633,7 @@ test.describe("Tools page", () => {
let previewHeaders: Record<string, string> = {};

await routeToolsList(page, [previewTool]);
await page.route("**/tools/preview/search_issues", async (route) => {
await page.route("**/v1/tools/preview/search_issues", async (route) => {
previewHeaders = route.request().headers();
await route.fulfill({
status: 200,
Expand Down
287 changes: 287 additions & 0 deletions e2e/virtual-servers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { test, expect } from "./fixtures/api-mock";
import { APP } from "./utils/paths";
import type { VirtualServer } from "../src/types/server";
import type { Tool } from "../src/types/tool";
import type { Page } from "@playwright/test";

interface JsonRpcRequest {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: Record<string, unknown>;
}

interface PreviewRequest {
arguments?: Record<string, unknown>;
server_id?: string;
}

const MOCK_VIRTUAL_SERVER: VirtualServer = {
id: "76c7b637dafc4d7197f14817ddffeda9", // pragma: allowlist secret
Expand Down Expand Up @@ -72,6 +86,108 @@ const MOCK_MCP_SERVER_2 = {
prompt_count: 1,
};

function makeTryItTool(overrides: Partial<Tool> = {}): Tool {
return {
id: "tool-search",
name: "github.search_issues",
originalName: "search_issues",
description: "Search repository issues",
originalDescription: "Search repository issues",
title: "Search issues",
displayName: "Search issues",
gatewayId: "mcp-gateway-1",
gatewaySlug: "github-mcp",
customName: "",
customNameSlug: "search_issues",
enabled: true,
reachable: true,
deprecated: false,
executionCount: 0,
tags: [],
integrationType: "MCP",
requestType: "http",
url: "https://example.com/mcp",
headers: {},
annotations: { readOnlyHint: true },
jsonpathFilter: null,
auth: null,
version: 1,
visibility: "team",
createdAt: "2026-04-10T10:00:00Z",
updatedAt: "2026-04-10T10:00:00Z",
inputSchema: {
type: "object",
required: ["query"],
properties: {
query: { type: "string" },
limit: { type: "integer" },
},
},
outputSchema: { type: "object" },
...overrides,
};
}

async function routeVirtualServerTryIt(page: Page, tools: Tool[]) {
await page.route("**/v1/virtual-servers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ servers: [MOCK_VIRTUAL_SERVER] }),
});
});
await page.route(`**/v1/virtual-servers/${MOCK_VIRTUAL_SERVER.id}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_VIRTUAL_SERVER_DETAILS),
});
});
await page.route(`**/v1/virtual-servers/${MOCK_VIRTUAL_SERVER.id}/tools?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ tools }),
});
});
await page.route(`**/v1/virtual-servers/${MOCK_VIRTUAL_SERVER.id}/resources?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ resources: [] }),
});
});
await page.route(`**/v1/virtual-servers/${MOCK_VIRTUAL_SERVER.id}/prompts?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ prompts: [] }),
});
});
await page.route("**/v1/mcp-servers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ gateways: [MOCK_MCP_SERVER] }),
});
});
}

async function openVirtualServerToolTest(page: Page) {
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: "testVS details" });
await expect(panel.getByRole("tab", { name: "Try it" })).toHaveAttribute("aria-selected", "true");
await panel.getByRole("tab", { name: "Components" }).click();
await panel.getByRole("button", { name: "Actions for Search issues" }).click();
const testAction = page.getByRole("menuitem", { name: "Test" });
await expect(testAction.locator("svg")).toHaveCount(0);
await testAction.click();
await expect(panel.getByRole("heading", { name: "Test tool" })).toBeFocused();
return panel;
}

test.describe("Virtual Servers page", () => {
test.beforeEach(async ({ page, apiMock }) => {
// Mock authentication
Expand Down Expand Up @@ -1176,6 +1292,177 @@ test.describe("Virtual Servers page", () => {
);
});

test.describe("virtual server tool testing", () => {
test.skip(
process.env.VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT !== "true",
"requires VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true before Vite starts",
);

test("previews then live invokes an attached tool through the virtual server", async ({
page,
}) => {
const tool = makeTryItTool();
let previewBody: PreviewRequest | null = null;
let previewHeaders: Record<string, string> = {};
let rpcBody: JsonRpcRequest | null = null;
let rpcHeaders: Record<string, string> = {};

await routeVirtualServerTryIt(page, [tool]);
await page.route("**/api/v1/tools/preview/github.search_issues", async (route) => {
previewBody = route.request().postDataJSON() as PreviewRequest;
previewHeaders = route.request().headers();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
target: { kind: "federated", gateway_name: "github-mcp" },
resolved_arguments: previewBody.arguments ?? {},
annotations: { readOnlyHint: true },
pre_hooks_run: [],
warnings: [],
}),
});
});
await page.route("**/api/rpc", async (route) => {
rpcBody = route.request().postDataJSON() as JsonRpcRequest;
rpcHeaders = route.request().headers();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
jsonrpc: "2.0",
id: rpcBody.id ?? "invoke-1",
result: {
content: [
{
type: "text",
text: "Scoped result from virtual server",
mimeType: "text/plain",
},
],
},
}),
});
});

await page.goto(APP.GATEWAYS);
await page.waitForLoadState("networkidle");

const panel = await openVirtualServerToolTest(page);
await expect(panel.getByRole("button", { name: "Preview" })).toBeVisible();
await expect(panel.getByRole("switch", { name: "Live invocation" })).not.toBeChecked();
await expect(
panel.getByText("Writes, external requests, and quota use happen immediately."),
).toBeVisible();
await expect(panel.getByText(/Live invocation is enabled/)).toHaveCount(0);
const liveLabelBounds = await panel
.getByText("Live invocation", { exact: true })
.boundingBox();
const liveSwitchBounds = await panel
.getByRole("switch", { name: "Live invocation" })
.boundingBox();
const liveCopyBounds = await panel
.getByText("Writes, external requests, and quota use happen immediately.")
.boundingBox();
expect(liveLabelBounds).not.toBeNull();
expect(liveSwitchBounds).not.toBeNull();
expect(liveCopyBounds).not.toBeNull();
expect(liveSwitchBounds!.y).toBeGreaterThan(liveLabelBounds!.y + liveLabelBounds!.height);
expect(liveCopyBounds!.x).toBeGreaterThan(liveSwitchBounds!.x + liveSwitchBounds!.width);

await panel.getByLabel("query").fill("cloudflare");
await panel.getByLabel("limit").fill("5");
await panel.getByRole("button", { name: "Add header" }).click();
await panel.getByLabel("Header 1 name").fill("X-Tenant-Id");
await panel.getByLabel("Header 1 value").fill("team-a");
await panel.getByRole("button", { name: "Preview" }).click();

await expect(panel.getByText("Preview 200")).toBeVisible();
expect(previewBody).toEqual({
arguments: { query: "cloudflare", limit: 5 },
server_id: MOCK_VIRTUAL_SERVER.id,
});
expect(previewHeaders["x-tenant-id"]).toBe("team-a");

await panel.getByRole("switch", { name: "Live invocation" }).click();
await expect(panel.getByText(/Live invocation is enabled/)).toBeVisible();
await expect(panel.getByRole("button", { name: "Invoke tool" })).toBeVisible();
await panel.getByRole("button", { name: "Invoke tool" }).click();

await expect(panel.getByText("Live invoke 200")).toBeVisible();
await expect(panel.getByText("Requested through testVS")).toBeVisible();
await expect(panel.getByText(/Answered by/)).toHaveCount(0);
await expect(panel.getByText("Scoped result from virtual server").first()).toBeVisible();
expect(rpcBody).toMatchObject({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "github.search_issues",
server_id: MOCK_VIRTUAL_SERVER.id,
arguments: { query: "cloudflare", limit: 5 },
},
});
expect(rpcHeaders["x-tenant-id"]).toBe("team-a");

await panel
.getByRole("button", { name: "Clear selected tool and return to components" })
.click();
await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused();
});

test("blocks live invoke without tools.execute", async ({ page, apiMock }) => {
await apiMock.mockPermissions({ permissions: ["servers.read", "servers.use"] });
await routeVirtualServerTryIt(page, [makeTryItTool()]);

await page.goto(APP.GATEWAYS);
await page.waitForLoadState("networkidle");
const panel = await openVirtualServerToolTest(page);

await expect(panel.getByText("Live invoke requires tools.execute.")).toBeVisible();
await expect(panel.getByRole("switch", { name: "Live invocation" })).toBeDisabled();
await panel.getByLabel("query").fill("cloudflare");
await expect(panel.getByRole("button", { name: "Preview" })).toBeEnabled();
});

test("restores focus after keyboard navigation through tool testing", async ({ page }) => {
await routeVirtualServerTryIt(page, [makeTryItTool()]);
await page.goto(APP.GATEWAYS);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: "Actions for testVS" }).focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "View details" }).press("Enter");

const panel = page.getByRole("region", { name: "testVS details" });
await panel.getByRole("tab", { name: "Components" }).focus();
await page.keyboard.press("Enter");
await panel.getByRole("button", { name: "Actions for Search issues" }).focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Test" }).press("Enter");
await expect(panel.getByRole("heading", { name: "Test tool" })).toBeFocused();

await panel
.getByRole("button", { name: "Clear selected tool and return to components" })
.focus();
await page.keyboard.press("Enter");
await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused();
});

test("blocks live invoke without servers.use", async ({ page, apiMock }) => {
await apiMock.mockPermissions({ permissions: ["servers.read", "tools.execute"] });
await routeVirtualServerTryIt(page, [makeTryItTool()]);

await page.goto(APP.GATEWAYS);
await page.waitForLoadState("networkidle");
const panel = await openVirtualServerToolTest(page);

await expect(panel.getByText("Live invoke requires servers.use.")).toBeVisible();
await expect(panel.getByRole("switch", { name: "Live invocation" })).toBeDisabled();
await panel.getByLabel("query").fill("cloudflare");
await expect(panel.getByRole("button", { name: "Preview" })).toBeEnabled();
});
});

test("shows only the actions menu in the virtual server card header", async ({ page }) => {
await page.route("**/v1/virtual-servers?*", async (route) => {
await route.fulfill({
Expand Down
6 changes: 5 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const IS_CI = !!process.env.CI;
// Keep the webServer command authoritative for feature flags. Opt in only when
// the pre-running server was started with the same flags.
const REUSE_EXISTING_SERVER = process.env.PLAYWRIGHT_REUSE_EXISTING_SERVER === "true";
const VIRTUAL_SERVER_TOOL_TRY_IT_FLAG =
process.env.VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT === "true"
? "VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true "
: "";

export default defineConfig({
testDir: "./e2e",
Expand Down Expand Up @@ -54,7 +58,7 @@ export default defineConfig({
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
command: "npm run dev:e2e",
command: `${VIRTUAL_SERVER_TOOL_TRY_IT_FLAG}npm run dev:e2e`,
url: BASE_URL,
reuseExistingServer: REUSE_EXISTING_SERVER,
timeout: 120_000,
Expand Down
Loading
Loading