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
244 changes: 244 additions & 0 deletions e2e/tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ interface UpdateToolPayload {
[key: string]: unknown;
}

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

/** Stub the tools list endpoint (`/tools?limit=0&include_inactive=true`). */
async function routeToolsList(page: Page, tools: Tool[]) {
await page.route("**/tools?*", async (route) => {
Expand All @@ -42,6 +49,15 @@ async function fillToolBasics(page: Page, name: string, url: string) {
await page.locator("#tool-url").fill(url);
}

async function openToolDetails(page: Page, gatewaySlug: string) {
await page.getByRole("button", { name: `More options for ${gatewaySlug}` }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: new RegExp(`Tools for ${gatewaySlug}`, "i") });
await expect(panel).toBeVisible();
return panel;
}

function makeTool(id: string, gatewaySlug: string, overrides: Partial<Tool> = {}): Tool {
return {
id,
Expand Down Expand Up @@ -369,6 +385,234 @@ test.describe("Tools page", () => {
expect(previewHeaders["x-tenant-id"]).toBe("team-a");
});

test("live invokes a read-only tool with JSON-RPC args and passthrough headers", async ({
page,
}) => {
const liveTool = makeTool("search_issues", "github-server", {
description: "Search repository issues",
inputSchema: {
type: "object",
required: ["query"],
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "integer" },
},
},
annotations: { readOnlyHint: true },
});
let rpcBody: JsonRpcRequest | null = null;
let rpcHeaders: Record<string, string> = {};

await routeToolsList(page, [liveTool]);
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: "Live result from gateway", mimeType: "text/plain" }],
structured_output: { total: 1 },
},
}),
});
});

await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
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();

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("Live result from gateway").first()).toBeVisible();
await expect(panel.getByText("Structured output")).toBeVisible();
expect(rpcBody).toMatchObject({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "search_issues",
arguments: { query: "cloudflare", limit: 5 },
},
});
expect(rpcBody?.params).not.toHaveProperty("server_id");
expect(rpcHeaders["x-tenant-id"]).toBe("team-a");
expect(rpcHeaders["x-csrf-token"]).toBe("mock-csrf-token");
});

test("confirms destructive local live invoke before calling /rpc", async ({ page }) => {
const destructiveTool = makeTool("delete_issue", "local-gateway", {
gatewayId: null,
annotations: { destructiveHint: true },
inputSchema: { type: "object", properties: {} },
});
let rpcRequestCount = 0;

await routeToolsList(page, [destructiveTool]);
await page.route("**/api/rpc", async (route) => {
rpcRequestCount += 1;
const body = route.request().postDataJSON() as JsonRpcRequest;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
jsonrpc: "2.0",
id: body.id ?? "invoke-1",
result: { content: [{ type: "text", text: "Deleted", mimeType: "text/plain" }] },
}),
});
});

await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "local-gateway");

await panel.getByRole("button", { name: "Live invoke" }).click();
const dialog = page.getByRole("alertdialog", { name: "Invoke destructive tool" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).not.toBeVisible();
expect(rpcRequestCount).toBe(0);

await panel.getByRole("button", { name: "Live invoke" }).click();
await page
.getByRole("alertdialog", { name: "Invoke destructive tool" })
.getByRole("button", { name: "Invoke tool" })
.click();

await expect.poll(() => rpcRequestCount).toBe(1);
await expect(panel.getByText("Live invoke 200")).toBeVisible();
await expect(panel.getByText("Deleted").first()).toBeVisible();
});

test("sends MCP cancellation when cancelling live invoke", async ({ page }) => {
const slowTool = makeTool("slow_search", "github-server", {
annotations: { readOnlyHint: true },
inputSchema: { type: "object", properties: {} },
});
const rpcBodies: JsonRpcRequest[] = [];
let releaseInvoke: (() => void) | undefined;
const releaseInvokePromise = new Promise<void>((resolve) => {
releaseInvoke = resolve;
});

await routeToolsList(page, [slowTool]);
await page.route("**/api/rpc", async (route) => {
const body = route.request().postDataJSON() as JsonRpcRequest;
rpcBodies.push(body);

if (body.method === "notifications/cancelled") {
releaseInvoke?.();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ jsonrpc: "2.0", id: body.id ?? "cancel-1", result: {} }),
});
return;
}

await releaseInvokePromise;
try {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
jsonrpc: "2.0",
id: body.id ?? "invoke-1",
result: { content: [{ type: "text", text: "Finished", mimeType: "text/plain" }] },
}),
});
} catch {
// The browser request is expected to be aborted after cancellation.
}
});

await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");

await panel.getByRole("button", { name: "Live invoke" }).click();
await expect(panel.getByRole("button", { name: "Cancel request" })).toBeVisible();
await panel.getByRole("button", { name: "Cancel request" }).click();

await expect
.poll(() => rpcBodies.some((body) => body.method === "notifications/cancelled"))
.toBe(true);
const invokeBody = rpcBodies.find((body) => body.method === "tools/call");
const cancelBody = rpcBodies.find((body) => body.method === "notifications/cancelled");

expect(invokeBody?.id).toEqual(expect.stringMatching(/^tool-live-/));
expect(cancelBody).toMatchObject({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: {
requestId: String(invokeBody?.id),
reason: "user",
},
});
});

test("hides live invoke when tools.execute is missing", async ({ page, apiMock }) => {
await apiMock.mockPermissions({ permissions: ["tools.read", "servers.use"] });
const liveTool = makeTool("search_issues", "github-server", {
annotations: { readOnlyHint: true },
inputSchema: { type: "object", properties: {} },
});

await routeToolsList(page, [liveTool]);
await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");

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

test("hides live invoke when servers.use is missing", async ({ page, apiMock }) => {
await apiMock.mockPermissions({ permissions: ["tools.read", "tools.execute"] });
const liveTool = makeTool("search_issues", "github-server", {
annotations: { readOnlyHint: true },
inputSchema: { type: "object", properties: {} },
});

await routeToolsList(page, [liveTool]);
await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");

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

test("does not offer live invoke for federated tools without readOnlyHint", async ({ page }) => {
const federatedTool = makeTool("create_issue", "github-server", {
annotations: { destructiveHint: true },
inputSchema: { type: "object", properties: {} },
});

await routeToolsList(page, [federatedTool]);
await page.goto(APP.TOOLS);
await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");

await expect(
panel.getByText("Live invoke is not offered for federated tools without readOnlyHint."),
).toBeVisible();
await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled();
});

test("warns for denied passthrough headers and excludes them from preview", async ({ page }) => {
const previewTool = makeTool("search_issues", "github-server", {
inputSchema: {
Expand Down
Loading
Loading