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
1 change: 1 addition & 0 deletions e2e/global-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ test.describe("Global search", () => {

await expect(page).toHaveURL(/\/app\/tools\?selected=tool-weather&search=weather$/);
await expect(page.getByRole("region", { name: /Tools for weather-server/i })).toBeVisible();
await page.getByRole("tab", { name: "Definition" }).click();
await expect(page.locator('tr[data-state="selected"]')).toContainText("Weather Tool");
});
});
123 changes: 122 additions & 1 deletion e2e/tools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "./fixtures/api-mock";
import { APP } from "./utils/paths";
import type { Tool } from "../src/types/tool";
Expand Down Expand Up @@ -28,6 +28,10 @@ async function routeToolsList(page: Page, tools: Tool[]) {
});
}

async function showDefinitionTab(panel: Locator) {
await panel.getByRole("tab", { name: "Definition" }).click();
}

async function openAddToolForm(page: Page) {
await page.getByText("Add tools").click();
await expect(page.getByRole("heading", { name: "Add tool" })).toBeVisible();
Expand Down Expand Up @@ -285,6 +289,111 @@ test.describe("Tools page", () => {
await expect(panel.getByText("create_issue").first()).toBeVisible();
});

test("previews a tool with schema arguments and allowed passthrough headers", async ({
page,
}) => {
const previewTool = 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 previewBody: unknown = null;
let previewHeaders: Record<string, string> = {};

await routeToolsList(page, [previewTool]);
await page.route("**/tools/preview/search_issues", async (route) => {
previewBody = route.request().postDataJSON();
previewHeaders = route.request().headers();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
target: { kind: "local" },
resolved_arguments: { query: "cloudflare", limit: 5 },
annotations: { readOnlyHint: true },
pre_hooks_run: [],
warnings: [],
}),
});
});

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

await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Tools for github-server/i });
await expect(panel.getByText("Tool preview")).toBeVisible();
await expect(panel.getByText("Read-only")).toBeVisible();

const previewButton = panel.getByRole("button", { name: "Preview" });
await expect(previewButton).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 expect(previewButton).toBeEnabled();
await previewButton.click();

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

test("warns for denied passthrough headers and excludes them from preview", async ({ page }) => {
const previewTool = makeTool("search_issues", "github-server", {
inputSchema: {
type: "object",
required: ["query"],
properties: { query: { type: "string" } },
},
});
let previewHeaders: Record<string, string> = {};

await routeToolsList(page, [previewTool]);
await page.route("**/tools/preview/search_issues", async (route) => {
previewHeaders = route.request().headers();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ target: "local", resolved_arguments: { query: "cloudflare" } }),
});
});

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

await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Tools for github-server/i });
await panel.getByLabel("query").fill("cloudflare");
await panel.getByRole("button", { name: "Add header" }).click();
await panel.getByLabel("Header 1 name").fill("Authorization");
await panel.getByLabel("Header 1 value").fill("Bearer typed-token");

await expect(panel.getByText("This header is not forwardable from the web UI.")).toBeVisible();
await expect(panel.getByRole("button", { name: "Preview" })).toBeDisabled();

await panel.getByLabel("Header 1 name").fill("X-Api-Key");
await panel.getByRole("button", { name: "Preview" }).click();

await expect(panel.getByText("Preview 200")).toBeVisible();
expect(previewHeaders.authorization).toBeUndefined();
expect(previewHeaders["x-api-key"]).toBe("Bearer typed-token");
});

test("closes details panel via close button", async ({ page }) => {
await page.route("**/tools?*", async (route) => {
await route.fulfill({
Expand Down Expand Up @@ -339,6 +448,7 @@ test.describe("Tools page", () => {
await expect(panel).toBeVisible();

await expect(panel.getByText("get_issues").first()).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -388,6 +498,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for github-server/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -420,6 +531,7 @@ test.describe("Tools page", () => {
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Tools for github-server/i });
await showDefinitionTab(panel);
await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();

Expand Down Expand Up @@ -460,6 +572,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for solo-gateway/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -507,6 +620,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for multi-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);
await expect(panel.getByText("alpha_tool").first()).toBeVisible();
await expect(panel.getByText("beta_tool").first()).toBeVisible();

Expand Down Expand Up @@ -557,6 +671,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for rb-gateway/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -605,6 +720,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for lone-gateway/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -867,6 +983,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for edit-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
Expand Down Expand Up @@ -907,6 +1024,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for schema-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "View schema" }).first().click();

Expand Down Expand Up @@ -958,6 +1076,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for toggle-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);
await expect(panel.getByText("Active", { exact: true })).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
Expand Down Expand Up @@ -1007,6 +1126,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for activate-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);
await expect(panel.getByText("Inactive", { exact: true })).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
Expand Down Expand Up @@ -1056,6 +1176,7 @@ test.describe("Tools page", () => {

const panel = page.getByRole("region", { name: /Tools for fail-gw/i });
await expect(panel).toBeVisible();
await showDefinitionTab(panel);

await panel.getByRole("button", { name: "More options" }).first().click();
await page.getByRole("menuitem", { name: "Deactivate" }).click();
Expand Down
7 changes: 5 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { defineConfig, devices } from "@playwright/test";
// when pointing at a pre-running server.
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
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";

export default defineConfig({
testDir: "./e2e",
Expand Down Expand Up @@ -46,9 +49,9 @@ export default defineConfig({
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
command: "npm run dev:e2e",
command: "VITE_ENABLE_TOOL_PREVIEW=true npm run dev:e2e",
url: BASE_URL,
reuseExistingServer: !IS_CI,
reuseExistingServer: REUSE_EXISTING_SERVER,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
Expand Down
63 changes: 63 additions & 0 deletions src/api/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,69 @@ describe("toolsApi", () => {
});
});

describe("preview", () => {
it("POSTs arguments to /tools/preview/:name with passthrough headers", async () => {
const body = {
resolved_arguments: { query: "cloudflare" },
target: "local",
annotations: { readOnlyHint: true },
pre_hooks_run: [],
warnings: [],
};
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

const result = await toolsApi.preview(
"search.issues",
{ query: "cloudflare" },
{ "X-Api-Key": "session-key" },
);

expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/tools/preview/search.issues"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({ arguments: { query: "cloudflare" } }),
headers: expect.objectContaining({
"X-CSRF-Token": "test-csrf-token",
"X-Api-Key": "session-key",
}),
credentials: "same-origin", // pragma: allowlist secret
}),
);
expect(result).toEqual({ preview: body, status: 200 });
});

it("accepts prompt-style MCP names with spaces, dots, hyphens, and underscores", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ target: "local" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

await expect(toolsApi.preview("my tool.v2_test-name")).resolves.toMatchObject({
status: 200,
});
});

it("throws synchronously for an empty name", () => {
expect(() => toolsApi.preview("")).toThrow("Invalid tool name");
});

it("throws synchronously for name with path traversal characters", () => {
expect(() => toolsApi.preview("../etc/passwd")).toThrow("Invalid tool name format");
});

it.each([".", "..", " "])("throws synchronously for unsafe name %p", (name) => {
expect(() => toolsApi.preview(name)).toThrow();
});
});

describe("delete", () => {
it("calls DELETE /tools/:id with CSRF token and same-origin credentials", async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
Expand Down
Loading
Loading