Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ SSE_SESSION_RECHECK_SECONDS=15

LOG_LEVEL=info

# 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
204 changes: 204 additions & 0 deletions e2e/virtual-servers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
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>;
}

const MOCK_VIRTUAL_SERVER: VirtualServer = {
id: "76c7b637dafc4d7197f14817ddffeda9", // pragma: allowlist secret
Expand Down Expand Up @@ -72,6 +81,93 @@ 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("**/servers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ servers: [MOCK_VIRTUAL_SERVER] }),
});
});
await page.route(`**/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(`**/servers/${MOCK_VIRTUAL_SERVER.id}/tools?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ tools }),
});
});
await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}/resources?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ resources: [] }),
});
});
await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}/prompts?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ prompts: [] }),
});
});
await page.route("**/gateways?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ gateways: [MOCK_MCP_SERVER] }),
});
});
}

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

test.describe("virtual server Try-it tab", () => {
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("live invokes an attached tool through the virtual server", async ({ page }) => {
const tool = makeTryItTool();
let rpcBody: JsonRpcRequest | null = null;
let rpcHeaders: Record<string, string> = {};

await routeVirtualServerTryIt(page, [tool]);
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: {
target: { kind: "federated", gateway_name: "github-mcp" },
content: [
{
type: "text",
text: "Scoped result from virtual server",
mimeType: "text/plain",
},
],
},
}),
});
});

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

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).toBeVisible();
await expect(panel.getByRole("tab", { name: "Components" })).toHaveAttribute(
"data-state",
"active",
);

await panel.getByRole("tab", { name: "Try it" }).click();
await expect(panel.getByText("Live tool call")).toBeVisible();
await expect(panel.getByRole("button", { name: "Preview" })).toHaveCount(0);

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 expect(panel.getByText("Live invoke 200")).toBeVisible();
await expect(panel.getByText("Requested through testVS")).toBeVisible();
await expect(panel.getByText("Answered by github-mcp")).toBeVisible();
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");
});

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");
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 panel.getByRole("tab", { name: "Try it" }).click();

await expect(panel.getByText("Live invoke requires tools.execute.")).toBeVisible();
await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled();
});

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");
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 panel.getByRole("tab", { name: "Try it" }).click();

await expect(panel.getByText("Live invoke requires servers.use.")).toBeVisible();
await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled();
});
});

test("shows only the actions menu in the virtual server card header", async ({ page }) => {
await page.route("**/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: "VITE_ENABLE_TOOL_PREVIEW=true npm run dev:e2e",
command: `VITE_ENABLE_TOOL_PREVIEW=true${VIRTUAL_SERVER_TOOL_TRY_IT_FLAG} npm run dev:e2e`,
url: BASE_URL,
reuseExistingServer: REUSE_EXISTING_SERVER,
timeout: 120_000,
Expand Down
40 changes: 40 additions & 0 deletions src/api/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,46 @@ describe("toolsApi", () => {
});
});

it("includes server_id for scoped live invokes", async () => {
const body = {
jsonrpc: "2.0",
id: "invoke-scoped",
result: {
content: [{ type: "text", text: "scoped", mimeType: "text/plain" }],
},
};
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

await toolsApi.invoke(
"github.search_issues",
{ query: "cloudflare" },
{},
{ requestId: "invoke-scoped", serverId: "virtual-server-1" },
);

expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/rpc"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
jsonrpc: "2.0",
id: "invoke-scoped",
method: "tools/call",
params: {
name: "github.search_issues",
server_id: "virtual-server-1",
arguments: { query: "cloudflare" },
},
}),
}),
);
});

it("throws ToolInvokeJsonRpcError for malformed JSON-RPC success bodies", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ jsonrpc: "2.0", id: "bad" }), {
Expand Down
4 changes: 3 additions & 1 deletion src/api/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface ToolInvokeRequest {
method: "tools/call";
params: {
name: string;
server_id?: string;
arguments: Record<string, unknown>;
};
}
Expand Down Expand Up @@ -278,7 +279,7 @@ export const toolsApi = {
name: string,
args: Record<string, unknown> = {},
passthroughHeaders: Record<string, string> = {},
options: { requestId?: ToolInvokeRequestId; signal?: AbortSignal } = {},
options: { requestId?: ToolInvokeRequestId; serverId?: string; signal?: AbortSignal } = {},
): Promise<ToolInvokeResult> => {
const validName = validateToolName(name);
const requestId = options.requestId ?? `tool-live-${Date.now()}`;
Expand All @@ -288,6 +289,7 @@ export const toolsApi = {
method: "tools/call",
params: {
name: validName,
...(options.serverId ? { server_id: options.serverId } : {}),
arguments: args,
},
};
Expand Down
Loading