From 0edf5ce926543fc936445e290badc68797de0d12 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 28 Aug 2026 13:38:04 +0100 Subject: [PATCH 1/8] Add virtual server scoped tool testing Signed-off-by: Pratik Gandhi --- .env.example | 3 + e2e/tools.spec.ts | 4 +- e2e/virtual-servers.spec.ts | 238 ++++++++ playwright.config.ts | 6 +- src/api/tools.test.ts | 70 ++- src/api/tools.ts | 14 +- .../VirtualServerDetailsPanel.test.tsx | 198 ++++++- .../gateways/VirtualServerDetailsPanel.tsx | 558 +++++++++++------- src/components/tools/ToolLiveInvokeGate.tsx | 4 +- .../tools/ToolLiveInvokeResult.test.tsx | 23 + src/components/tools/ToolLiveInvokeResult.tsx | 51 +- src/components/tools/ToolTryItTab.test.tsx | 53 ++ src/components/tools/ToolTryItTab.tsx | 131 +++- .../tools/buildToolSnippets.test.ts | 45 ++ src/components/tools/buildToolSnippets.ts | 104 +++- src/config/features.test.ts | 20 + src/config/features.ts | 3 + src/hooks/useToolInvoke.test.tsx | 35 ++ src/hooks/useToolInvoke.ts | 14 +- src/hooks/useToolPreview.test.tsx | 37 +- src/hooks/useToolPreview.ts | 17 +- src/i18n/locales/en-US/gateways.json | 3 + src/i18n/locales/en-US/tools.json | 5 + src/i18n/locales/es-ES/gateways.json | 3 + src/i18n/locales/es-ES/tools.json | 5 + src/i18n/locales/pt-BR/gateways.json | 3 + src/i18n/locales/pt-BR/tools.json | 5 + src/vite-env.d.ts | 4 + 28 files changed, 1412 insertions(+), 244 deletions(-) create mode 100644 src/config/features.test.ts create mode 100644 src/config/features.ts diff --git a/.env.example b/.env.example index 7b8c1db0..80603a12 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,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 diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts index 3ceabfac..e0038acc 100644 --- a/e2e/tools.spec.ts +++ b/e2e/tools.spec.ts @@ -324,7 +324,7 @@ test.describe("Tools page", () => { let previewHeaders: Record = {}; 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({ @@ -624,7 +624,7 @@ test.describe("Tools page", () => { let previewHeaders: Record = {}; 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, diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index 4fe7b317..fb714f33 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -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; +} + +interface PreviewRequest { + arguments?: Record; + server_id?: string; +} const MOCK_VIRTUAL_SERVER: VirtualServer = { id: "76c7b637dafc4d7197f14817ddffeda9", // pragma: allowlist secret @@ -72,6 +86,106 @@ const MOCK_MCP_SERVER_2 = { prompt_count: 1, }; +function makeTryItTool(overrides: Partial = {}): 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(); + await page.getByRole("menuitem", { name: "Test" }).click(); + await expect(panel.getByText("Tool test")).toBeVisible(); + return panel; +} + test.describe("Virtual Servers page", () => { test.beforeEach(async ({ page, apiMock }) => { // Mock authentication @@ -1176,6 +1290,130 @@ 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 = {}; + let rpcBody: JsonRpcRequest | null = null; + let rpcHeaders: Record = {}; + + 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: { + 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"); + + const panel = await openVirtualServerToolTest(page); + await expect(panel.getByRole("button", { name: "Preview" })).toBeVisible(); + await expect(panel.getByRole("switch", { name: "Live invocation" })).not.toBeChecked(); + + 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.getByRole("button", { name: "Live invoke" })).toBeVisible(); + 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"); + 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("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({ diff --git a/playwright.config.ts b/playwright.config.ts index 5353692a..1fddfcad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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", @@ -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, diff --git a/src/api/tools.test.ts b/src/api/tools.test.ts index dfdc65b7..5f6cb8f6 100644 --- a/src/api/tools.test.ts +++ b/src/api/tools.test.ts @@ -44,7 +44,7 @@ describe("toolsApi", () => { }); describe("preview", () => { - it("POSTs arguments to /tools/preview/:name with passthrough headers", async () => { + it("POSTs arguments to /v1/tools/preview/:name with passthrough headers", async () => { const body = { resolved_arguments: { query: "cloudflare" }, target: "local", @@ -66,7 +66,7 @@ describe("toolsApi", () => { ); expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/tools/preview/search.issues"), + expect.stringContaining("/v1/tools/preview/search.issues"), expect.objectContaining({ method: "POST", body: JSON.stringify({ arguments: { query: "cloudflare" } }), @@ -80,6 +80,32 @@ describe("toolsApi", () => { expect(result).toEqual({ preview: body, status: 200 }); }); + it("includes server_id only for scoped previews", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ target: "local" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await toolsApi.preview( + "github.search_issues", + { query: "cloudflare" }, + {}, + { serverId: "virtual-server-1" }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/v1/tools/preview/github.search_issues"), + expect.objectContaining({ + body: JSON.stringify({ + arguments: { query: "cloudflare" }, + server_id: "virtual-server-1", + }), + }), + ); + }); + it("accepts prompt-style MCP names with spaces, dots, hyphens, and underscores", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ target: "local" }), { @@ -177,6 +203,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" }), { diff --git a/src/api/tools.ts b/src/api/tools.ts index 88c55196..a4a2bd3b 100644 --- a/src/api/tools.ts +++ b/src/api/tools.ts @@ -40,6 +40,7 @@ export interface GenerateSchemasFromOpenapiResult { export interface ToolPreviewRequest { arguments: Record; + server_id?: string; } export type ToolPreviewWarning = GeneratedToolPreviewWarning; @@ -61,6 +62,7 @@ export interface ToolInvokeRequest { method: "tools/call"; params: { name: string; + server_id?: string; arguments: Record; }; } @@ -215,13 +217,16 @@ export const toolsApi = { name: string, args: Record = {}, passthroughHeaders: Record = {}, - options: { signal?: AbortSignal } = {}, + options: { serverId?: string; signal?: AbortSignal } = {}, ): Promise => { const validName = validateToolName(name); return api .postWithMeta( - `/tools/preview/${encodeURIComponent(validName)}`, - { arguments: args } satisfies ToolPreviewRequest, + `/v1/tools/preview/${encodeURIComponent(validName)}`, + { + arguments: args, + ...(options.serverId ? { server_id: options.serverId } : {}), + } satisfies ToolPreviewRequest, { headers: passthroughHeaders, signal: options.signal }, ) .then(({ data, status }) => ({ preview: data, status })); @@ -239,7 +244,7 @@ export const toolsApi = { name: string, args: Record = {}, passthroughHeaders: Record = {}, - options: { requestId?: ToolInvokeRequestId; signal?: AbortSignal } = {}, + options: { requestId?: ToolInvokeRequestId; serverId?: string; signal?: AbortSignal } = {}, ): Promise => { const validName = validateToolName(name); const requestId = options.requestId ?? `tool-live-${Date.now()}`; @@ -249,6 +254,7 @@ export const toolsApi = { method: "tools/call", params: { name: validName, + ...(options.serverId ? { server_id: options.serverId } : {}), arguments: args, }, }; diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index b1b4f8fc..70483d37 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse, delay } from "msw"; @@ -6,10 +6,24 @@ import { server as mswServer } from "@/test/mocks/server"; import { renderWithProviders as render } from "@/test/test-utils"; import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel"; import type { VirtualServer } from "@/types/server"; +import type { Tool } from "@/types/tool"; import { copyToClipboard } from "@/lib/clipboard"; vi.mock("@/lib/clipboard", () => ({ copyToClipboard: vi.fn() })); +const authMock = vi.hoisted(() => ({ + permissions: ["*"] as string[], + permissionsLoading: false, +})); + +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + hasPermission: (permission: string) => + authMock.permissions.includes("*") || authMock.permissions.includes(permission), + permissionsLoading: authMock.permissionsLoading, + }), +})); + function makeServer(overrides: Partial = {}): VirtualServer { return { id: "gateway-1", @@ -47,6 +61,56 @@ function makeServer(overrides: Partial = {}): VirtualServer { }; } +function makeTool(overrides: Partial = {}): 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: "gateway-id", + gatewaySlug: "github-server", + 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: "2024-01-01T00:00:00", + updatedAt: "2024-01-02T00:00:00", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + outputSchema: { type: "object" }, + ...overrides, + }; +} + +beforeEach(() => { + authMock.permissions = ["*"]; + authMock.permissionsLoading = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("VirtualServerDetailsPanel inline tag add", () => { it("calls onAddTag with the merged, de-duplicated tag list", async () => { const user = userEvent.setup(); @@ -255,6 +319,138 @@ describe("VirtualServerDetailsPanel components list", () => { }); }); +describe("VirtualServerDetailsPanel tool testing", () => { + beforeEach(() => { + mswServer.use( + http.get("*/v1/virtual-servers/:id/resources", () => HttpResponse.json({ resources: [] })), + http.get("*/v1/virtual-servers/:id/prompts", () => HttpResponse.json({ prompts: [] })), + http.get("*/v1/mcp-servers", () => HttpResponse.json({ gateways: [] })), + ); + }); + + async function openToolTest(user: ReturnType, actionName: string) { + await user.click(await screen.findByRole("tab", { name: "Components" })); + await user.click(await screen.findByRole("button", { name: `Actions for ${actionName}` })); + await user.click(await screen.findByRole("menuitem", { name: "Test" })); + } + + it("keeps the existing handshake Try-it tab and hides tool Test actions when disabled", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "false"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => + HttpResponse.json({ tools: [makeTool({ displayName: "Find issues" })] }), + ), + ); + + render( + , + ); + + expect(await screen.findByRole("tab", { name: "Try it" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.click(screen.getByRole("tab", { name: "Components" })); + await user.click(await screen.findByRole("button", { name: "Actions for Search issues" })); + expect(screen.queryByRole("menuitem", { name: "Test" })).not.toBeInTheDocument(); + }); + + it("opens a fetched tool test in place and returns to the component list", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => + HttpResponse.json({ tools: [makeTool({ id: "tool-1", displayName: "Find issues" })] }), + ), + ); + + render( + , + ); + + await openToolTest(user, "Search issues"); + + expect(await screen.findByText("Tool test")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Live invocation" })).not.toBeChecked(); + expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + expect( + document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'), + ).toHaveTextContent('"server_id":"virtual-server-1"'); + + await user.click(screen.getByRole("button", { name: "Back to components" })); + expect(await screen.findByText("Search issues")).toBeInTheDocument(); + expect(screen.queryByText("Tool test")).not.toBeInTheDocument(); + }); + + it("does not expose Test for associatedToolIds fallback rows", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => HttpResponse.json({ tools: [] })), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Components" })); + await user.click(await screen.findByRole("button", { name: "Actions for Fallback Tool" })); + expect(screen.queryByRole("menuitem", { name: "Test" })).not.toBeInTheDocument(); + }); + + it.each([ + { permissions: ["servers.use"], message: "Live invoke requires tools.execute." }, + { permissions: ["tools.execute"], message: "Live invoke requires servers.use." }, + ])("keeps Preview available when $message", async ({ permissions, message }) => { + const user = userEvent.setup(); + authMock.permissions = permissions; + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => HttpResponse.json({ tools: [makeTool()] })), + ); + + render( + , + ); + + await openToolTest(user, "Search issues"); + + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + expect(screen.getByRole("button", { name: "Preview" })).toBeEnabled(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + expect(screen.getByText(message)).toBeInTheDocument(); + }); +}); + describe("VirtualServerDetailsPanel render variants", () => { beforeEach(() => { mswServer.use( diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 17cb2408..2c589a00 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -3,8 +3,10 @@ import type { ReactNode } from "react"; import { useIntl } from "react-intl"; import { Activity, + ArrowLeft, Box, EllipsisVertical, + FlaskConical, Loader2, MessageSquareCode, PanelRightClose, @@ -25,11 +27,20 @@ import { CopyButton } from "@/components/ui/copy-button"; import { InlineTagAdd } from "@/components/ui/inline-tag-add"; import { CopyValue } from "@/components/ui/copy-value"; import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TruncatedText } from "@/components/ui/truncated-text"; import { getTruncatedMiddle } from "@/components/ui/truncated-middle-text"; +import { ToolTryItTab } from "@/components/tools/ToolTryItTab"; +import { isVirtualServerToolTryItEnabled } from "@/config/features"; import { cn } from "@/lib/utils"; import type { MCPServer, VirtualServer } from "@/types/server"; +import type { Tool as ApiTool } from "@/types/tool"; import type { ComponentFilter } from "@/components/gateways/types"; import { buildComponentItems, @@ -52,13 +63,17 @@ type TopTab = "components" | "test"; const SEGMENTED_TRIGGER_CLASS = "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; -interface Tool { +interface PanelTool extends ApiTool { + gateway_id?: string; +} + +interface ComponentTool { id: string; name: string; - title?: string; + title?: string | null; originalName: string; - description?: string; - gatewayId?: string; + description?: string | null; + gatewayId?: string | null; gateway_id?: string; enabled?: boolean; } @@ -85,7 +100,9 @@ interface Prompt { } type ComponentWithType = - (Tool & { type: "tools" }) | (Resource & { type: "resources" }) | (Prompt & { type: "prompts" }); + | (ComponentTool & { type: "tools" }) + | (Resource & { type: "resources" }) + | (Prompt & { type: "prompts" }); interface MCPServersResponse { gateways?: MCPServer[]; @@ -130,6 +147,47 @@ function getMCPServers(data: MCPServersResponse | MCPServer[] | undefined): MCPS return data?.gateways ?? []; } +function getPanelTools(data: { tools: PanelTool[] } | PanelTool[] | undefined): PanelTool[] { + const tools = Array.isArray(data) ? data : (data?.tools ?? []); + return tools.map(normalizePanelTool); +} + +function normalizePanelTool(tool: PanelTool): PanelTool { + const record = tool as unknown as Record; + return { + ...tool, + annotations: asRecord(tool.annotations) ?? {}, + displayName: getNonEmptyString(record.displayName) ?? getNonEmptyString(record.display_name), + gatewayId: tool.gatewayId ?? getNonEmptyString(record.gateway_id) ?? null, + gatewaySlug: tool.gatewaySlug ?? getNonEmptyString(record.gateway_slug) ?? "", + inputSchema: asRecord(tool.inputSchema) ?? asRecord(record.input_schema) ?? {}, + originalName: + getNonEmptyString(record.originalName) ?? + getNonEmptyString(record.original_name) ?? + tool.name, + outputSchema: asRecord(tool.outputSchema) ?? asRecord(record.output_schema), + }; +} + +function getFriendlyToolLabel(tool: ApiTool): string { + const record = tool as unknown as Record; + return ( + getNonEmptyString(record.displayName) ?? + getNonEmptyString(record.title) ?? + getNonEmptyString(record.originalName) ?? + tool.name + ); +} + +function getNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + export function VirtualServerDetailsPanel({ server, error, @@ -156,6 +214,7 @@ export function VirtualServerDetailsPanel({ const notSyncedYet = intl.formatMessage({ id: "gateways.card.notSyncedYet" }); const tags = (server?.tags ?? []).map((tag, index) => getTagDisplay(tag, index, tagFallback)); const [topTab, setTopTab] = useState("test"); + const [selectedTestToolId, setSelectedTestToolId] = useState(null); const [sourceFilter, setSourceFilter] = useState("all"); const [componentFilter, setComponentFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -164,6 +223,7 @@ export function VirtualServerDetailsPanel({ const previousFocusRef = useRef(null); const searchInputRef = useRef(null); const headingId = useMemo(() => `server-details-heading-${server?.id ?? "none"}`, [server?.id]); + const virtualServerToolTryItEnabled = isVirtualServerToolTryItEnabled(); const getComponentLabel = useCallback( (type: Exclude) => @@ -235,7 +295,7 @@ export function VirtualServerDetailsPanel({ data: toolsData, isLoading: toolsLoading, error: toolsError, - } = useQuery<{ tools: Tool[] }>(toolsPath, { + } = useQuery<{ tools: PanelTool[] } | PanelTool[]>(toolsPath, { enabled: fetchEnabled, }); @@ -255,17 +315,18 @@ export function VirtualServerDetailsPanel({ enabled: fetchEnabled, }); + const fetchedTools = useMemo(() => getPanelTools(toolsData), [toolsData]); + const fetchedComponents = useMemo((): ComponentWithType[] => { - const tools = Array.isArray(toolsData) ? toolsData : toolsData?.tools || []; const resources = Array.isArray(resourcesData) ? resourcesData : resourcesData?.resources || []; const prompts = Array.isArray(promptsData) ? promptsData : promptsData?.prompts || []; return [ - ...tools.map((t): ComponentWithType => ({ ...t, type: "tools" as const })), + ...fetchedTools.map((t): ComponentWithType => ({ ...t, type: "tools" as const })), ...resources.map((r): ComponentWithType => ({ ...r, type: "resources" as const })), ...prompts.map((p): ComponentWithType => ({ ...p, type: "prompts" as const })), ]; - }, [toolsData, resourcesData, promptsData]); + }, [fetchedTools, resourcesData, promptsData]); const fallbackComponents = useMemo((): ComponentWithType[] => { if (!server) return []; @@ -367,17 +428,29 @@ export function VirtualServerDetailsPanel({ }, [sourceIds, sourcesData]); const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; + const selectedTestTool = useMemo( + () => fetchedTools.find((tool) => tool.id === selectedTestToolId) ?? null, + [fetchedTools, selectedTestToolId], + ); // Reset tab, filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; setTopTab("test"); + setSelectedTestToolId(null); setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); setIsSearchExpanded(false); }, [open, server?.id]); + useEffect(() => { + if (!selectedTestToolId) return; + if (!virtualServerToolTryItEnabled || !selectedTestTool) { + setSelectedTestToolId(null); + } + }, [selectedTestTool, selectedTestToolId, virtualServerToolTryItEnabled]); + useEffect(() => { if (sourceFilter === "all") return; if (!sourceIds.includes(sourceFilter)) { @@ -502,7 +575,11 @@ export function VirtualServerDetailsPanel({ setTopTab(v as TopTab)} + onValueChange={(value) => { + const nextTab = value as TopTab; + setTopTab(nextTab); + if (nextTab !== "components") setSelectedTestToolId(null); + }} aria-label="Virtual server details view" > @@ -524,212 +601,262 @@ export function VirtualServerDetailsPanel({ - {(sourcesLoading || sourceTabs.length > 0) && ( -
- {[ - { - id: "all", - label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), - isTruncated: false, - fullValue: undefined as string | undefined, - }, - ...sourceTabs, - ].map((source, index, sources) => { - const isSelected = sourceFilter === source.id; - const tabButton = ( + {virtualServerToolTryItEnabled && selectedTestTool ? ( + setSelectedTestToolId(null)} + /> + ) : ( + <> + {(sourcesLoading || sourceTabs.length > 0) && ( +
+ {[ + { + id: "all", + label: intl.formatMessage({ + id: "gateways.details.filter.allSources", + }), + isTruncated: false, + fullValue: undefined as string | undefined, + }, + ...sourceTabs, + ].map((source, index, sources) => { + const isSelected = sourceFilter === source.id; + const tabButton = ( + + ); + + return ( + + {tabButton} + {source.isTruncated && ( + {source.fullValue} + )} + + ); + })} +
+ )} + +
+
+ {COMPONENT_FILTER_OPTIONS.map((option) => ( + + ))} +
+
- ); - - return ( - - {tabButton} - {source.isTruncated && ( - {source.fullValue} + 0 ? 0 : -1} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onFocus={() => setIsSearchExpanded(true)} + onBlur={() => setIsSearchExpanded(searchQuery.length > 0)} + placeholder={ + isSearchExpanded || searchQuery.length > 0 ? "Search..." : "" + } + className={cn( + "h-8 rounded-md border-border bg-muted/50 text-sm shadow-none transition-[width,padding,color,background-color,border-color] duration-200 ease-out placeholder:text-muted-foreground focus-visible:bg-background", + isSearchExpanded || searchQuery.length > 0 + ? "w-48 px-3 text-foreground" + : "w-0 px-0 text-transparent caret-foreground border-transparent", )} - - ); - })} -
- )} + /> +
+
-
-
- {COMPONENT_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- - 0 ? 0 : -1} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onFocus={() => setIsSearchExpanded(true)} - onBlur={() => setIsSearchExpanded(searchQuery.length > 0)} - placeholder={isSearchExpanded || searchQuery.length > 0 ? "Search..." : ""} - className={cn( - "h-8 rounded-md border-border bg-muted/50 text-sm shadow-none transition-[width,padding,color,background-color,border-color] duration-200 ease-out placeholder:text-muted-foreground focus-visible:bg-background", - isSearchExpanded || searchQuery.length > 0 - ? "w-48 px-3 text-foreground" - : "w-0 px-0 text-transparent caret-foreground border-transparent", - )} - /> -
-
+ {error.message} + + )} - {error && ( -
- {error.message} -
- )} - -
- {componentsLoading && (
-
- )} - - {!componentsLoading && - visibleComponents.map((component) => { - const title = component.title; - const identifier = getComponentIdentifier(component); - - return ( + {componentsLoading && (
- - - {getComponentIcon(component.type)} - - {getComponentLabel(component.type)} - - {title ? ( - <> - - {title} - - - {identifier} - - - - ) : ( - <> - - {identifier} - - -
- ); - })} + )} - {!componentsLoading && visibleComponents.length === 0 && ( -
- No {componentFilter === "all" ? "components" : componentFilter} found + {!componentsLoading && + visibleComponents.map((component) => { + const title = component.title; + const identifier = getComponentIdentifier(component); + const testableTool = + component.type === "tools" + ? fetchedTools.find((tool) => tool.id === component.id) + : undefined; + + return ( +
+ + + {getComponentIcon(component.type)} + + {getComponentLabel(component.type)} + + {title ? ( + <> + + {title} + + + {identifier} + + + + ) : ( + <> + + {identifier} + + +
+ ); + })} + + {!componentsLoading && visibleComponents.length === 0 && ( +
+ No {componentFilter === "all" ? "components" : componentFilter} found +
+ )}
- )} -
+ + )}
@@ -834,3 +961,34 @@ export function VirtualServerDetailsPanel({ ); } + +function VirtualServerToolTestView({ + server, + tool, + onBack, +}: { + server: VirtualServer; + tool: PanelTool; + onBack: () => void; +}) { + const intl = useIntl(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/tools/ToolLiveInvokeGate.tsx b/src/components/tools/ToolLiveInvokeGate.tsx index d4d83363..8fbc3ff9 100644 --- a/src/components/tools/ToolLiveInvokeGate.tsx +++ b/src/components/tools/ToolLiveInvokeGate.tsx @@ -137,13 +137,13 @@ export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveI : intl.formatMessage({ id: "tools.details.invoke.run" })}

- {availabilityMessage(availability, intl.formatMessage)} + {getToolLiveInvokeAvailabilityMessage(availability, intl.formatMessage)}

); } -function availabilityMessage( +export function getToolLiveInvokeAvailabilityMessage( availability: ToolLiveInvokeAvailability, formatMessage: (descriptor: { id: string }) => string, ) { diff --git a/src/components/tools/ToolLiveInvokeResult.test.tsx b/src/components/tools/ToolLiveInvokeResult.test.tsx index 835be8f0..f329e236 100644 --- a/src/components/tools/ToolLiveInvokeResult.test.tsx +++ b/src/components/tools/ToolLiveInvokeResult.test.tsx @@ -69,6 +69,29 @@ describe("ToolLiveInvokeResult", () => { expect(screen.getByRole("alert")).toHaveTextContent("Access denied"); }); + it("renders optional request and backing gateway context", () => { + render( + , + ); + + expect(screen.getByText("Requested through Developer tools")).toBeInTheDocument(); + expect(screen.getByText("Answered by github-mcp")).toBeInTheDocument(); + }); + it("renders HTTP errors and tool-level error results", () => { const { rerender } = render( ; } -export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { +export interface ToolLiveInvokeResultContext { + backingGatewayName?: string; + requestName?: string; +} + +export function ToolLiveInvokeResult({ context, invoke }: ToolLiveInvokeResultProps) { const intl = useIntl(); const { result, error, hasRun } = invoke; @@ -32,6 +39,7 @@ export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { const renderTimeMs = result?.renderTimeMs ?? error?.renderTimeMs ?? 0; const response = result?.result; + const backingGatewayName = context?.backingGatewayName ?? getBackingGatewayName(response); const toolResultIsError = response ? getToolResultIsError(response) : false; const succeeded = result !== null; const statusOk = succeeded && !toolResultIsError; @@ -69,6 +77,27 @@ export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { + {(context?.requestName || backingGatewayName) && ( +
+ {context?.requestName && ( + + {intl.formatMessage( + { id: "tools.details.invoke.context.requestedThrough" }, + { name: context.requestName }, + )} + + )} + {backingGatewayName && ( + + {intl.formatMessage( + { id: "tools.details.invoke.context.answeredBy" }, + { name: backingGatewayName }, + )} + + )} +
+ )} + {response && } {response && ( @@ -97,6 +126,26 @@ export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { ); } +function getBackingGatewayName(response: ToolPreviewResponse | undefined): string | undefined { + if (!response) return undefined; + const root = response as Record; + const target = typeof response.target === "object" && response.target ? response.target : null; + return ( + getNonEmptyString(root.gateway_name) ?? + getNonEmptyString(root.gatewayName) ?? + getNonEmptyString(root.resolved_gateway_name) ?? + getNonEmptyString(root.resolvedGatewayName) ?? + getNonEmptyString(target?.gateway_name) ?? + getNonEmptyString(target?.gatewayName) ?? + getNonEmptyString(target?.gateway_slug) ?? + getNonEmptyString(target?.gatewaySlug) + ); +} + +function getNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + function RawLiveResponse({ response }: { response: unknown }) { const intl = useIntl(); const byteSize = estimateJsonByteSize( diff --git a/src/components/tools/ToolTryItTab.test.tsx b/src/components/tools/ToolTryItTab.test.tsx index 6bc188f2..f19c1cf8 100644 --- a/src/components/tools/ToolTryItTab.test.tsx +++ b/src/components/tools/ToolTryItTab.test.tsx @@ -118,4 +118,57 @@ describe("ToolTryItTab", () => { expect(screen.getByLabelText("Header 1 name")).toHaveValue("X-Tenant-Id"); expect(screen.getByLabelText("Header 1 value")).toHaveValue("team-a"); }); + + it("defaults scoped testing to preview and switches snippets with live mode", async () => { + const user = userEvent.setup(); + const selectedTool = makeTool({ + name: "github.search_issues", + displayName: "Search issues", + annotations: { readOnlyHint: true }, + }); + + render( + tool.displayName ?? tool.name} + serverScope={{ serverId: "virtual-server-1", serverName: "Developer tools" }} + selectedTool={selectedTool} + />, + ); + + expect(screen.getByText("Tool test")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); + expect(activeCode()).toContain("/v1/tools/preview/github.search_issues"); + expect(activeCode()).toContain('"server_id":"virtual-server-1"'); + + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + + expect(screen.getByRole("button", { name: "Live invoke" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Preview" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "JSON-RPC" })).toBeInTheDocument(); + expect(activeCode()).toContain("$MCPGATEWAY_URL/rpc"); + await user.click(screen.getByRole("tab", { name: "JSON-RPC" })); + expect(activeCode()).toContain('"server_id": "virtual-server-1"'); + expect(activeCode()).toContain('"name": "github.search_issues"'); + }); + + it("keeps scoped preview available when live invocation is unsafe", async () => { + const user = userEvent.setup(); + const selectedTool = makeTool({ annotations: {}, gatewayId: "gateway-id" }); + + render( + , + ); + + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + expect(screen.getByRole("button", { name: "Preview" })).toBeEnabled(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + expect( + screen.getByText("Live invoke is not offered for federated tools without readOnlyHint."), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index 6fea71e7..1b30e8c2 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentProps } from "react"; import { useIntl } from "react-intl"; +import { useAuth } from "@/auth/useAuth"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { CodeBlock } from "@/components/ui/code-block"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { Tool } from "@/types/tool"; @@ -11,12 +14,17 @@ import { useToolInvoke } from "@/hooks/useToolInvoke"; import { useToolPreview } from "@/hooks/useToolPreview"; import { TOOL_SNIPPET_MCP_VERSION, + TOOL_PREVIEW_SNIPPETS, TOOL_SNIPPETS, type ToolSnippetLanguage, } from "./buildToolSnippets"; import { ToolArgumentsForm, seedToolArguments } from "./ToolArgumentsForm"; import { getForwardableHeaders, type ToolHeaderRow, ToolHeadersEditor } from "./ToolHeadersEditor"; -import { ToolLiveInvokeGate } from "./ToolLiveInvokeGate"; +import { + getToolLiveInvokeAvailabilityMessage, + resolveToolLiveInvokeAvailability, + ToolLiveInvokeGate, +} from "./ToolLiveInvokeGate"; import { ToolLiveInvokeResult } from "./ToolLiveInvokeResult"; import { ToolPreviewButton } from "./ToolPreviewButton"; import { ToolPreviewResult } from "./ToolPreviewResult"; @@ -25,13 +33,24 @@ import { getToolAnnotationHints } from "./toolAnnotations"; const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; export interface ToolTryItTabProps { - tools: Tool[]; + getToolLabel?: (tool: Tool) => string; + resultContext?: ComponentProps["context"]; + serverScope?: { serverId: string; serverName: string }; + tools?: Tool[]; selectedTool: Tool; - onSelectTool: (tool: Tool) => void; + onSelectTool?: (tool: Tool) => void; } -export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTabProps) { +export function ToolTryItTab({ + getToolLabel, + resultContext, + serverScope, + tools, + selectedTool, + onSelectTool, +}: ToolTryItTabProps) { const intl = useIntl(); + const { hasPermission, permissionsLoading } = useAuth(); const [args, setArgs] = useState>(() => seedToolArguments(selectedTool.inputSchema), ); @@ -40,20 +59,42 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab const [headersValid, setHeadersValid] = useState(true); const [snippetLanguage, setSnippetLanguage] = useState(DEFAULT_SNIPPET_LANGUAGE); + const [liveMode, setLiveMode] = useState(false); + const scopedMode = Boolean(serverScope); const forwardableHeaders = useMemo(() => getForwardableHeaders(headers), [headers]); const annotationHints = getToolAnnotationHints(selectedTool.annotations); - const preview = useToolPreview(selectedTool.name, args, forwardableHeaders); - const invoke = useToolInvoke(selectedTool.name, args, forwardableHeaders); + const liveAvailability = useMemo( + () => + resolveToolLiveInvokeAvailability({ + canExecute: hasPermission("tools.execute"), + canUseServers: hasPermission("servers.use"), + permissionsLoading, + tool: selectedTool, + }), + [hasPermission, permissionsLoading, selectedTool], + ); + const liveModeAvailable = + liveAvailability.state === "available" || liveAvailability.state === "requiresConfirmation"; + const preview = useToolPreview(selectedTool.name, args, forwardableHeaders, { + enabled: !scopedMode || !liveMode, + serverId: serverScope?.serverId, + }); + const invoke = useToolInvoke(selectedTool.name, args, forwardableHeaders, { + serverId: serverScope?.serverId, + }); const resetPreview = preview.reset; const resetInvoke = invoke.reset; const previousToolIdRef = useRef(selectedTool.id); + const availableTools = tools ?? [selectedTool]; + const toolLabel = getToolLabel ?? ((tool: Tool) => tool.name); + const snippetSpecs = scopedMode && !liveMode ? TOOL_PREVIEW_SNIPPETS : TOOL_SNIPPETS; const snippets = useMemo( () => - TOOL_SNIPPETS.map((spec) => ({ + snippetSpecs.map((spec) => ({ ...spec, - text: spec.build({ toolName: selectedTool.name, args }), + text: spec.build({ toolName: selectedTool.name, args, serverId: serverScope?.serverId }), })), - [args, selectedTool.name], + [args, serverScope?.serverId, selectedTool.name, snippetSpecs], ); useEffect(() => { @@ -63,16 +104,32 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab setHeaders([]); setArgsValid(true); setHeadersValid(true); + setLiveMode(false); + setSnippetLanguage(DEFAULT_SNIPPET_LANGUAGE); resetPreview(); resetInvoke(); }, [resetInvoke, resetPreview, selectedTool]); + useEffect(() => { + if (!scopedMode || liveModeAvailable) return; + setLiveMode(false); + }, [liveModeAvailable, scopedMode]); + + const handleLiveModeChange = (checked: boolean) => { + setLiveMode(checked); + setSnippetLanguage(DEFAULT_SNIPPET_LANGUAGE); + resetPreview(); + resetInvoke(); + }; + return (

- {intl.formatMessage({ id: "tools.details.preview.title" })} + {intl.formatMessage({ + id: scopedMode ? "tools.details.test.title" : "tools.details.preview.title", + })}

{annotationHints.readOnlyHint && ( @@ -86,13 +143,13 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab )}
- {tools.length > 1 && ( + {availableTools.length > 1 && onSelectTool && (
- {tools.map((tool) => { + {availableTools.map((tool) => { const isSelected = tool.id === selectedTool.id; return ( ); })} @@ -123,6 +180,30 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab )}
+ {scopedMode && ( +
+
+ + {!liveModeAvailable && ( +

+ {getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)} +

+ )} +
+ +
+ )} +
- {TOOL_SNIPPETS.map((spec) => ( + {snippetSpecs.map((spec) => ( {intl.formatMessage({ id: spec.labelId })} @@ -156,12 +237,16 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab
- - + {(!scopedMode || !liveMode) && ( + + )} + {(!scopedMode || liveMode) && ( + + )}
@@ -180,8 +265,10 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab
- - + {(!scopedMode || !liveMode) && } + {(!scopedMode || liveMode) && ( + + )} ); } diff --git a/src/components/tools/buildToolSnippets.test.ts b/src/components/tools/buildToolSnippets.test.ts index f822f177..dda40748 100644 --- a/src/components/tools/buildToolSnippets.test.ts +++ b/src/components/tools/buildToolSnippets.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest"; import { buildToolCurl, buildToolJsonRpc, + buildToolPreviewCurl, + buildToolPreviewJson, + buildToolPreviewPython, + buildToolPreviewTypescript, buildToolPython, buildToolTypescript, TOOL_SNIPPET_MCP_VERSION, @@ -30,6 +34,47 @@ describe("buildToolSnippets", () => { }); }); + it("includes server_id in scoped tools/call snippets", () => { + const scopedInput = { ...input, serverId: "virtual-server-1" }; + + expect(JSON.parse(buildToolJsonRpc(scopedInput))).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "gateway.search_issues", + server_id: "virtual-server-1", + arguments: { query: "can't reproduce", limit: 5, dryRun: false }, + }, + }); + expect(buildToolCurl(scopedInput)).toContain('"server_id":"virtual-server-1"'); + expect(buildToolPython(scopedInput)).toContain('\\"server_id\\": \\"virtual-server-1\\"'); + expect(buildToolTypescript(scopedInput)).toContain('server_id: "virtual-server-1"'); + }); + + it("builds scoped preview snippets for the versioned endpoint", () => { + const scopedInput = { ...input, serverId: "virtual-server-1" }; + + expect(JSON.parse(buildToolPreviewJson(scopedInput))).toEqual({ + arguments: { query: "can't reproduce", limit: 5, dryRun: false }, + server_id: "virtual-server-1", + }); + expect(buildToolPreviewCurl(scopedInput)).toContain( + "$MCPGATEWAY_URL/v1/tools/preview/gateway.search_issues", + ); + expect(buildToolPreviewCurl(scopedInput)).toContain('"server_id":"virtual-server-1"'); + expect(buildToolPreviewPython(scopedInput)).toContain( + "/v1/tools/preview/gateway.search_issues", + ); + expect(buildToolPreviewTypescript(scopedInput)).toContain('server_id":"virtual-server-1'); + }); + + it("URL-encodes tool names in preview snippet paths", () => { + expect(buildToolPreviewCurl({ ...input, toolName: "gateway.tool name" })).toContain( + "/v1/tools/preview/gateway.tool%20name", + ); + }); + it("targets a real gateway placeholder instead of the browser BFF path", () => { const snippet = buildToolCurl(input); diff --git a/src/components/tools/buildToolSnippets.ts b/src/components/tools/buildToolSnippets.ts index 7381ff2c..f4448092 100644 --- a/src/components/tools/buildToolSnippets.ts +++ b/src/components/tools/buildToolSnippets.ts @@ -4,10 +4,11 @@ export const TOOL_SNIPPET_MCP_VERSION = "2025-11-25"; export const URL_ENV = "MCPGATEWAY_URL"; export const TOKEN_ENV = "MCPGATEWAY_BEARER_TOKEN"; -export type ToolSnippetLanguage = "curl" | "jsonRpc" | "python" | "typescript"; +export type ToolSnippetLanguage = "curl" | "json" | "jsonRpc" | "python" | "typescript"; export interface ToolSnippetInput { args: Record; + serverId?: string; toolName: string; } @@ -19,18 +20,30 @@ export interface ToolSnippetSpec { build: (input: ToolSnippetInput) => string; } -function buildToolCallEnvelope({ toolName, args }: ToolSnippetInput) { +function buildToolCallEnvelope({ toolName, args, serverId }: ToolSnippetInput) { return { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, + ...(serverId ? { server_id: serverId } : {}), arguments: args, }, }; } +function buildToolPreviewBody({ args, serverId }: ToolSnippetInput) { + return { + arguments: args, + ...(serverId ? { server_id: serverId } : {}), + }; +} + +function buildToolPreviewPath(toolName: string): string { + return `/v1/tools/preview/${encodeURIComponent(toolName)}`; +} + function bashSingleQuoteLiteral(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } @@ -49,9 +62,9 @@ export function buildToolJsonRpc(input: ToolSnippetInput): string { return JSON.stringify(buildToolCallEnvelope(input), null, 2); } -export function buildToolPython({ toolName, args }: ToolSnippetInput): string { +export function buildToolPython({ toolName, args, serverId }: ToolSnippetInput): string { const payload = JSON.stringify( - JSON.stringify(buildToolCallEnvelope({ toolName, args }), null, 2), + JSON.stringify(buildToolCallEnvelope({ toolName, args, serverId }), null, 2), ); return [ "import json", @@ -70,7 +83,8 @@ export function buildToolPython({ toolName, args }: ToolSnippetInput): string { ].join("\n"); } -export function buildToolTypescript({ toolName, args }: ToolSnippetInput): string { +export function buildToolTypescript({ toolName, args, serverId }: ToolSnippetInput): string { + const serverIdLine = serverId ? ` server_id: ${JSON.stringify(serverId)},` : null; return [ `const response = await fetch(\`\${process.env.${URL_ENV}}/rpc\`, {`, ` method: "POST",`, @@ -84,6 +98,7 @@ export function buildToolTypescript({ toolName, args }: ToolSnippetInput): strin ` method: "tools/call",`, ` params: {`, ` name: ${JSON.stringify(toolName)},`, + ...(serverIdLine ? [serverIdLine] : []), ` arguments: ${JSON.stringify(args)},`, ` },`, ` }),`, @@ -94,6 +109,54 @@ export function buildToolTypescript({ toolName, args }: ToolSnippetInput): strin ].join("\n"); } +export function buildToolPreviewCurl(input: ToolSnippetInput): string { + const body = JSON.stringify(buildToolPreviewBody(input)); + return [ + `curl -X POST "$${URL_ENV}${buildToolPreviewPath(input.toolName)}" \\`, + ` -H "Authorization: Bearer $${TOKEN_ENV}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d ${bashSingleQuoteLiteral(body)}`, + ].join("\n"); +} + +export function buildToolPreviewJson(input: ToolSnippetInput): string { + return JSON.stringify(buildToolPreviewBody(input), null, 2); +} + +export function buildToolPreviewPython(input: ToolSnippetInput): string { + const payload = JSON.stringify(JSON.stringify(buildToolPreviewBody(input), null, 2)); + return [ + "import json", + "import os", + "import requests", + "", + `payload = ${payload}`, + "", + "response = requests.post(", + ` f"{os.environ['${URL_ENV}']}${buildToolPreviewPath(input.toolName)}",`, + ` headers={"Authorization": f"Bearer {os.environ['${TOKEN_ENV}']}"},`, + " json=json.loads(payload),", + ")", + "response.raise_for_status()", + "print(response.json())", + ].join("\n"); +} + +export function buildToolPreviewTypescript(input: ToolSnippetInput): string { + return [ + `const response = await fetch(\`\${process.env.${URL_ENV}}${buildToolPreviewPath(input.toolName)}\`, {`, + ` method: "POST",`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${TOKEN_ENV}}\`,`, + ` "Content-Type": "application/json",`, + ` },`, + ` body: JSON.stringify(${JSON.stringify(buildToolPreviewBody(input))}),`, + `});`, + `if (!response.ok) throw new Error(\`Tool preview failed: \${response.status}\`);`, + `const data = await response.json();`, + ].join("\n"); +} + export const TOOL_SNIPPETS: ToolSnippetSpec[] = [ { value: "curl", @@ -124,3 +187,34 @@ export const TOOL_SNIPPETS: ToolSnippetSpec[] = [ build: buildToolTypescript, }, ]; + +export const TOOL_PREVIEW_SNIPPETS: ToolSnippetSpec[] = [ + { + value: "curl", + labelId: "tools.details.code.tab.curl", + language: "curl", + prismLanguage: "bash", + build: buildToolPreviewCurl, + }, + { + value: "json", + labelId: "tools.details.code.tab.json", + language: "JSON", + prismLanguage: "json", + build: buildToolPreviewJson, + }, + { + value: "python", + labelId: "tools.details.code.tab.python", + language: "Python", + prismLanguage: "python", + build: buildToolPreviewPython, + }, + { + value: "typescript", + labelId: "tools.details.code.tab.typescript", + language: "TypeScript", + prismLanguage: "tsx", + build: buildToolPreviewTypescript, + }, +]; diff --git a/src/config/features.test.ts b/src/config/features.test.ts new file mode 100644 index 00000000..74691ff6 --- /dev/null +++ b/src/config/features.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isVirtualServerToolTryItEnabled } from "./features"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("feature flags", () => { + it("enables virtual-server tool Try-it only when explicitly set", () => { + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + expect(isVirtualServerToolTryItEnabled()).toBe(true); + + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "false"); + expect(isVirtualServerToolTryItEnabled()).toBe(false); + + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", undefined); + expect(isVirtualServerToolTryItEnabled()).toBe(false); + }); +}); diff --git a/src/config/features.ts b/src/config/features.ts new file mode 100644 index 00000000..b26542c4 --- /dev/null +++ b/src/config/features.ts @@ -0,0 +1,3 @@ +export function isVirtualServerToolTryItEnabled(): boolean { + return import.meta.env.VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT === "true"; +} diff --git a/src/hooks/useToolInvoke.test.tsx b/src/hooks/useToolInvoke.test.tsx index 4ed516ff..fcc66630 100644 --- a/src/hooks/useToolInvoke.test.tsx +++ b/src/hooks/useToolInvoke.test.tsx @@ -82,6 +82,41 @@ describe("useToolInvoke", () => { expect(toast.error).toHaveBeenCalledTimes(1); }); + it("passes serverId through for scoped live invokes", async () => { + vi.mocked(toolsApi.invoke).mockResolvedValue({ + id: "invoke-1", + result: { content: [] }, + status: 200, + }); + const { result } = renderHook( + () => + useToolInvoke( + "github.search_issues", + { query: "cloudflare" }, + {}, + { serverId: "virtual-server-1" }, + ), + { + wrapper: ({ children }) => {children}, + }, + ); + + await act(async () => { + await result.current.run(); + }); + + expect(toolsApi.invoke).toHaveBeenCalledWith( + "github.search_issues", + { query: "cloudflare" }, + {}, + expect.objectContaining({ + requestId: expect.stringMatching(/^tool-live-/), + serverId: "virtual-server-1", + signal: expect.any(AbortSignal), + }), + ); + }); + it("captures HTTP ApiError failures", async () => { vi.mocked(toolsApi.invoke).mockRejectedValue( new ApiError(403, { detail: "Forbidden" }, "HTTP 403"), diff --git a/src/hooks/useToolInvoke.ts b/src/hooks/useToolInvoke.ts index b23ddb56..a9e1e5a3 100644 --- a/src/hooks/useToolInvoke.ts +++ b/src/hooks/useToolInvoke.ts @@ -38,13 +38,23 @@ export interface ToolInvokeState { hasRun: boolean; } +export interface UseToolInvokeOptions { + serverId?: string; + timeoutMs?: number; +} + export function useToolInvoke( toolName: string, args: Record, passthroughHeaders: Record, - timeoutMs: number = TOOL_INVOKE_TIMEOUT_MS, + optionsOrTimeoutMs: UseToolInvokeOptions | number = {}, ): ToolInvokeState { const intl = useIntl(); + const serverId = typeof optionsOrTimeoutMs === "number" ? undefined : optionsOrTimeoutMs.serverId; + const timeoutMs = + typeof optionsOrTimeoutMs === "number" + ? optionsOrTimeoutMs + : (optionsOrTimeoutMs.timeoutMs ?? TOOL_INVOKE_TIMEOUT_MS); const [isLoading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -127,6 +137,7 @@ export function useToolInvoke( id, } = await toolsApi.invoke(toolName, args, passthroughHeaders, { requestId, + serverId, signal: controller.signal, }); if (controller.signal.aborted) return; @@ -179,6 +190,7 @@ export function useToolInvoke( clearRunTimer, intl, passthroughHeaders, + serverId, timeoutMs, toolName, ]); diff --git a/src/hooks/useToolPreview.test.tsx b/src/hooks/useToolPreview.test.tsx index fdbd0b5e..57bc99a3 100644 --- a/src/hooks/useToolPreview.test.tsx +++ b/src/hooks/useToolPreview.test.tsx @@ -22,8 +22,9 @@ function setup( toolName = "search", args: Record = {}, headers: Record = {}, + options?: Parameters[3], ) { - return renderHook(() => useToolPreview(toolName, args, headers), { + return renderHook(() => useToolPreview(toolName, args, headers, options), { wrapper: ({ children }) => {children}, }); } @@ -82,6 +83,40 @@ describe("useToolPreview", () => { expect(result.current.hasRun).toBe(true); }); + it("does not run preview requests when disabled", async () => { + const { result } = setup("search", { query: "cloudflare" }, {}, { enabled: false }); + + await act(async () => { + await result.current.run(); + }); + + expect(toolsApi.preview).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasRun).toBe(false); + }); + + it("passes serverId through for scoped previews", async () => { + vi.mocked(toolsApi.preview).mockResolvedValue({ + preview: { target: "local" }, + status: 200, + }); + const { result } = setup("github.search_issues", {}, {}, { serverId: "virtual-server-1" }); + + await act(async () => { + await result.current.run(); + }); + + expect(toolsApi.preview).toHaveBeenCalledWith( + "github.search_issues", + {}, + {}, + expect.objectContaining({ + serverId: "virtual-server-1", + signal: expect.any(AbortSignal), + }), + ); + }); + it("resets result and error state when the tool name changes", async () => { vi.mocked(toolsApi.preview).mockResolvedValue({ preview: { target: "local" }, diff --git a/src/hooks/useToolPreview.ts b/src/hooks/useToolPreview.ts index 0a5c7657..a1678127 100644 --- a/src/hooks/useToolPreview.ts +++ b/src/hooks/useToolPreview.ts @@ -27,21 +27,32 @@ export interface ToolPreviewState { hasRun: boolean; } +export interface UseToolPreviewOptions { + enabled?: boolean; + serverId?: string; +} + export function useToolPreview( toolName: string, args: Record, passthroughHeaders: Record, + options: UseToolPreviewOptions = {}, ): ToolPreviewState { const intl = useIntl(); + const enabled = options.enabled ?? true; + const serverId = options.serverId; const [isLoading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const abortRef = useRef(null); useEffect(() => { + abortRef.current?.abort(); + abortRef.current = null; setResult(null); setError(null); - }, [toolName]); + setLoading(false); + }, [enabled, toolName]); useEffect(() => { return () => { @@ -58,6 +69,7 @@ export function useToolPreview( }, []); const run = useCallback(async () => { + if (!enabled) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; @@ -67,6 +79,7 @@ export function useToolPreview( const startedAt = performance.now(); try { const { preview, status } = await toolsApi.preview(toolName, args, passthroughHeaders, { + serverId, signal: controller.signal, }); if (controller.signal.aborted) return; @@ -85,7 +98,7 @@ export function useToolPreview( setLoading(false); } } - }, [toolName, args, passthroughHeaders, intl]); + }, [enabled, toolName, args, passthroughHeaders, serverId, intl]); return { run, diff --git a/src/i18n/locales/en-US/gateways.json b/src/i18n/locales/en-US/gateways.json index 39c03efa..98e3da11 100644 --- a/src/i18n/locales/en-US/gateways.json +++ b/src/i18n/locales/en-US/gateways.json @@ -118,9 +118,12 @@ "gateways.details.searchComponents": "Search components", "gateways.details.filterComponents": "Filter components", "gateways.details.loadingServerDetails": "Loading server details...", + "gateways.details.loadingComponents": "Loading components...", "gateways.details.component.tools": "tool", "gateways.details.component.resources": "resource", "gateways.details.component.prompts": "prompt", + "gateways.details.component.test": "Test", + "gateways.details.component.backToList": "Back to components", "gateways.details.component.copyName.tools": "Copy tool name for {name}", "gateways.details.component.copyName.resources": "Copy URI for {name}", "gateways.details.component.copyName.prompts": "Copy prompt name for {name}", diff --git a/src/i18n/locales/en-US/tools.json b/src/i18n/locales/en-US/tools.json index 11854359..d0a1f2bf 100644 --- a/src/i18n/locales/en-US/tools.json +++ b/src/i18n/locales/en-US/tools.json @@ -119,6 +119,7 @@ "tools.details.preview.headers.error.denied": "This header is not forwardable from the web UI.", "tools.details.preview.headers.error.invalid": "Enter a valid HTTP header name.", "tools.details.code.tab.curl": "curl", + "tools.details.code.tab.json": "JSON", "tools.details.code.tab.jsonRpc": "JSON-RPC", "tools.details.code.tab.python": "Python", "tools.details.code.tab.typescript": "TypeScript", @@ -137,6 +138,10 @@ "tools.details.invoke.statusErrorWithStatus": "Live invoke failed {status}", "tools.details.invoke.rawResponse": "Raw live response", "tools.details.invoke.copyRawResponse": "Copy raw live response", + "tools.details.test.title": "Tool test", + "tools.details.test.liveMode": "Live invocation", + "tools.details.invoke.context.requestedThrough": "Requested through {name}", + "tools.details.invoke.context.answeredBy": "Answered by {name}", "tools.details.invoke.unavailable.checkingAccess": "Checking your tool permissions.", "tools.details.invoke.unavailable.missingExecutePermission": "Live invoke requires tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "Live invoke requires servers.use.", diff --git a/src/i18n/locales/es-ES/gateways.json b/src/i18n/locales/es-ES/gateways.json index 2afc38f7..d3660161 100644 --- a/src/i18n/locales/es-ES/gateways.json +++ b/src/i18n/locales/es-ES/gateways.json @@ -118,9 +118,12 @@ "gateways.details.searchComponents": "Buscar componentes", "gateways.details.filterComponents": "Filtrar componentes", "gateways.details.loadingServerDetails": "Cargando detalles del servidor...", + "gateways.details.loadingComponents": "Cargando componentes...", "gateways.details.component.tools": "herramienta", "gateways.details.component.resources": "recurso", "gateways.details.component.prompts": "prompt", + "gateways.details.component.test": "Probar", + "gateways.details.component.backToList": "Volver a componentes", "gateways.details.component.copyName.tools": "Copiar nombre de la herramienta de {name}", "gateways.details.component.copyName.resources": "Copiar URI de {name}", "gateways.details.component.copyName.prompts": "Copiar nombre del prompt de {name}", diff --git a/src/i18n/locales/es-ES/tools.json b/src/i18n/locales/es-ES/tools.json index a4cc05ef..5f9a8764 100644 --- a/src/i18n/locales/es-ES/tools.json +++ b/src/i18n/locales/es-ES/tools.json @@ -119,6 +119,7 @@ "tools.details.preview.headers.error.denied": "Este encabezado no se puede reenviar desde la interfaz web.", "tools.details.preview.headers.error.invalid": "Introduzca un nombre de encabezado HTTP válido.", "tools.details.code.tab.curl": "curl", + "tools.details.code.tab.json": "JSON", "tools.details.code.tab.jsonRpc": "JSON-RPC", "tools.details.code.tab.python": "Python", "tools.details.code.tab.typescript": "TypeScript", @@ -137,6 +138,10 @@ "tools.details.invoke.statusErrorWithStatus": "Error en la invocación en vivo {status}", "tools.details.invoke.rawResponse": "Respuesta en vivo sin procesar", "tools.details.invoke.copyRawResponse": "Copiar respuesta en vivo sin procesar", + "tools.details.test.title": "Prueba de herramienta", + "tools.details.test.liveMode": "Invocación en vivo", + "tools.details.invoke.context.requestedThrough": "Solicitado a través de {name}", + "tools.details.invoke.context.answeredBy": "Respondido por {name}", "tools.details.invoke.unavailable.checkingAccess": "Comprobando sus permisos de herramienta.", "tools.details.invoke.unavailable.missingExecutePermission": "La invocación en vivo requiere tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "La invocación en vivo requiere servers.use.", diff --git a/src/i18n/locales/pt-BR/gateways.json b/src/i18n/locales/pt-BR/gateways.json index 1813670b..5cfb530b 100644 --- a/src/i18n/locales/pt-BR/gateways.json +++ b/src/i18n/locales/pt-BR/gateways.json @@ -118,9 +118,12 @@ "gateways.details.searchComponents": "Buscar componentes", "gateways.details.filterComponents": "Filtrar componentes", "gateways.details.loadingServerDetails": "Carregando detalhes do servidor...", + "gateways.details.loadingComponents": "Carregando componentes...", "gateways.details.component.tools": "ferramenta", "gateways.details.component.resources": "recurso", "gateways.details.component.prompts": "prompt", + "gateways.details.component.test": "Testar", + "gateways.details.component.backToList": "Voltar aos componentes", "gateways.details.component.copyName.tools": "Copiar nome da ferramenta de {name}", "gateways.details.component.copyName.resources": "Copiar URI de {name}", "gateways.details.component.copyName.prompts": "Copiar nome do prompt de {name}", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 0f0172fe..e85e8e2b 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -119,6 +119,7 @@ "tools.details.preview.headers.error.denied": "Este cabeçalho não pode ser encaminhado pela interface web.", "tools.details.preview.headers.error.invalid": "Insira um nome de cabeçalho HTTP válido.", "tools.details.code.tab.curl": "curl", + "tools.details.code.tab.json": "JSON", "tools.details.code.tab.jsonRpc": "JSON-RPC", "tools.details.code.tab.python": "Python", "tools.details.code.tab.typescript": "TypeScript", @@ -137,6 +138,10 @@ "tools.details.invoke.statusErrorWithStatus": "Falha na invocação em tempo real {status}", "tools.details.invoke.rawResponse": "Resposta bruta em tempo real", "tools.details.invoke.copyRawResponse": "Copiar resposta bruta em tempo real", + "tools.details.test.title": "Teste de ferramenta", + "tools.details.test.liveMode": "Invocação em tempo real", + "tools.details.invoke.context.requestedThrough": "Solicitado por meio de {name}", + "tools.details.invoke.context.answeredBy": "Respondido por {name}", "tools.details.invoke.unavailable.checkingAccess": "Verificando suas permissões de ferramenta.", "tools.details.invoke.unavailable.missingExecutePermission": "A invocação em tempo real requer tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "A invocação em tempo real requer servers.use.", diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index ffe7f8e5..08730fcb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,9 @@ /// +interface ImportMetaEnv { + readonly VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT?: string; +} + // Injected by vite.config.ts `define` from package.json's version field. declare const __APP_VERSION__: string; // Injected by vite.config.ts `define` from openapi.json's info.version field. From 90af4121bcca4b08160fb926b02846d6bcebddc8 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Tue, 15 Sep 2026 13:56:18 +0100 Subject: [PATCH 2/8] Address virtual server tool testing review Signed-off-by: Pratik Gandhi --- e2e/virtual-servers.spec.ts | 27 +++- .../VirtualServerDetailsPanel.test.tsx | 52 +++++++- .../gateways/VirtualServerDetailsPanel.tsx | 122 ++++++++++-------- .../normalizeVirtualServerTool.test.ts | 56 ++++++++ .../gateways/normalizeVirtualServerTool.ts | 29 +++++ .../tools/ToolLiveInvokeResult.test.tsx | 36 ++++++ src/components/tools/ToolTryItTab.test.tsx | 85 +++++++++++- src/components/tools/ToolTryItTab.tsx | 33 +++-- 8 files changed, 372 insertions(+), 68 deletions(-) create mode 100644 src/components/gateways/normalizeVirtualServerTool.test.ts create mode 100644 src/components/gateways/normalizeVirtualServerTool.ts diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index fb714f33..bd5733c8 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -182,7 +182,7 @@ async function openVirtualServerToolTest(page: Page) { await panel.getByRole("tab", { name: "Components" }).click(); await panel.getByRole("button", { name: "Actions for Search issues" }).click(); await page.getByRole("menuitem", { name: "Test" }).click(); - await expect(panel.getByText("Tool test")).toBeVisible(); + await expect(panel.getByRole("heading", { name: "Tool test" })).toBeFocused(); return panel; } @@ -1383,6 +1383,9 @@ test.describe("Virtual Servers page", () => { }, }); expect(rpcHeaders["x-tenant-id"]).toBe("team-a"); + + await panel.getByRole("button", { name: "Back to components" }).click(); + await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused(); }); test("blocks live invoke without tools.execute", async ({ page, apiMock }) => { @@ -1399,6 +1402,28 @@ test.describe("Virtual Servers page", () => { 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: "Tool test" })).toBeFocused(); + + await panel.getByRole("button", { name: "Back 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()]); diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 70483d37..e8d92faa 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -383,7 +383,8 @@ describe("VirtualServerDetailsPanel tool testing", () => { await openToolTest(user, "Search issues"); - expect(await screen.findByText("Tool test")).toBeInTheDocument(); + const testHeading = await screen.findByRole("heading", { name: "Tool test" }); + await waitFor(() => expect(testHeading).toHaveFocus()); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Live invocation" })).not.toBeChecked(); expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); @@ -394,6 +395,52 @@ describe("VirtualServerDetailsPanel tool testing", () => { await user.click(screen.getByRole("button", { name: "Back to components" })); expect(await screen.findByText("Search issues")).toBeInTheDocument(); expect(screen.queryByText("Tool test")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Actions for Search issues" })).toHaveFocus(), + ); + }); + + it("uses snake_case tool fields for scoped testing", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + const tool: Record = { ...makeTool({ title: undefined }) }; + delete tool.displayName; + delete tool.originalName; + delete tool.gatewayId; + delete tool.gatewaySlug; + delete tool.inputSchema; + delete tool.outputSchema; + tool.display_name = "Find issues"; + tool.original_name = "upstream_search"; + tool.gateway_id = "gateway-id"; + tool.gateway_slug = "snake-gateway"; + tool.input_schema = { + type: "object", + required: ["query"], + properties: { query: { type: "string" } }, + }; + tool.output_schema = { type: "object" }; + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => HttpResponse.json({ tools: [tool] })), + ); + + render( + , + ); + + await openToolTest(user, "upstream_search"); + expect(screen.getByText("Find issues")).toBeInTheDocument(); + expect(screen.getByLabelText(/query/i)).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByLabelText(/query/i)).toHaveAttribute("type", "text"); + expect( + document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'), + ).toHaveTextContent('"server_id":"virtual-server-1"'); }); it("does not expose Test for associatedToolIds fallback rows", async () => { @@ -447,6 +494,9 @@ describe("VirtualServerDetailsPanel tool testing", () => { await user.type(screen.getByLabelText(/query/i), "cloudflare"); expect(screen.getByRole("button", { name: "Preview" })).toBeEnabled(); expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( + message, + ); expect(screen.getByText(message)).toBeInTheDocument(); }); }); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 2c589a00..266a3a2e 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { useIntl } from "react-intl"; import { Activity, @@ -40,7 +40,7 @@ import { ToolTryItTab } from "@/components/tools/ToolTryItTab"; import { isVirtualServerToolTryItEnabled } from "@/config/features"; import { cn } from "@/lib/utils"; import type { MCPServer, VirtualServer } from "@/types/server"; -import type { Tool as ApiTool } from "@/types/tool"; +import { normalizeVirtualServerTool, type VirtualServerTool } from "./normalizeVirtualServerTool"; import type { ComponentFilter } from "@/components/gateways/types"; import { buildComponentItems, @@ -63,10 +63,6 @@ type TopTab = "components" | "test"; const SEGMENTED_TRIGGER_CLASS = "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; -interface PanelTool extends ApiTool { - gateway_id?: string; -} - interface ComponentTool { id: string; name: string; @@ -147,45 +143,11 @@ function getMCPServers(data: MCPServersResponse | MCPServer[] | undefined): MCPS return data?.gateways ?? []; } -function getPanelTools(data: { tools: PanelTool[] } | PanelTool[] | undefined): PanelTool[] { +function getPanelTools( + data: { tools: VirtualServerTool[] } | VirtualServerTool[] | undefined, +): VirtualServerTool[] { const tools = Array.isArray(data) ? data : (data?.tools ?? []); - return tools.map(normalizePanelTool); -} - -function normalizePanelTool(tool: PanelTool): PanelTool { - const record = tool as unknown as Record; - return { - ...tool, - annotations: asRecord(tool.annotations) ?? {}, - displayName: getNonEmptyString(record.displayName) ?? getNonEmptyString(record.display_name), - gatewayId: tool.gatewayId ?? getNonEmptyString(record.gateway_id) ?? null, - gatewaySlug: tool.gatewaySlug ?? getNonEmptyString(record.gateway_slug) ?? "", - inputSchema: asRecord(tool.inputSchema) ?? asRecord(record.input_schema) ?? {}, - originalName: - getNonEmptyString(record.originalName) ?? - getNonEmptyString(record.original_name) ?? - tool.name, - outputSchema: asRecord(tool.outputSchema) ?? asRecord(record.output_schema), - }; -} - -function getFriendlyToolLabel(tool: ApiTool): string { - const record = tool as unknown as Record; - return ( - getNonEmptyString(record.displayName) ?? - getNonEmptyString(record.title) ?? - getNonEmptyString(record.originalName) ?? - tool.name - ); -} - -function getNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - return value as Record; + return tools.map(normalizeVirtualServerTool); } export function VirtualServerDetailsPanel({ @@ -222,6 +184,11 @@ export function VirtualServerDetailsPanel({ const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); const searchInputRef = useRef(null); + const toolTestHeadingRef = useRef(null); + const focusedToolTestIdRef = useRef(null); + const returnToToolActionsIdRef = useRef(null); + const restoreToolActionsFocusRef = useRef(false); + const openingToolTestRef = useRef(false); const headingId = useMemo(() => `server-details-heading-${server?.id ?? "none"}`, [server?.id]); const virtualServerToolTryItEnabled = isVirtualServerToolTryItEnabled(); @@ -295,7 +262,7 @@ export function VirtualServerDetailsPanel({ data: toolsData, isLoading: toolsLoading, error: toolsError, - } = useQuery<{ tools: PanelTool[] } | PanelTool[]>(toolsPath, { + } = useQuery<{ tools: VirtualServerTool[] } | VirtualServerTool[]>(toolsPath, { enabled: fetchEnabled, }); @@ -316,6 +283,10 @@ export function VirtualServerDetailsPanel({ }); const fetchedTools = useMemo(() => getPanelTools(toolsData), [toolsData]); + const fetchedToolsById = useMemo( + () => new Map(fetchedTools.map((tool) => [tool.id, tool])), + [fetchedTools], + ); const fetchedComponents = useMemo((): ComponentWithType[] => { const resources = Array.isArray(resourcesData) ? resourcesData : resourcesData?.resources || []; @@ -429,15 +400,38 @@ export function VirtualServerDetailsPanel({ const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; const selectedTestTool = useMemo( - () => fetchedTools.find((tool) => tool.id === selectedTestToolId) ?? null, - [fetchedTools, selectedTestToolId], + () => (selectedTestToolId ? (fetchedToolsById.get(selectedTestToolId) ?? null) : null), + [fetchedToolsById, selectedTestToolId], ); + useEffect(() => { + if (!open) return; + if (selectedTestTool) { + if (focusedToolTestIdRef.current !== selectedTestTool.id) { + focusedToolTestIdRef.current = selectedTestTool.id; + toolTestHeadingRef.current?.focus(); + } + } else { + focusedToolTestIdRef.current = null; + if (!restoreToolActionsFocusRef.current) return; + restoreToolActionsFocusRef.current = false; + const trigger = returnToToolActionsIdRef.current + ? document.getElementById(returnToToolActionsIdRef.current) + : null; + (trigger ?? document.getElementById("tab-tools"))?.focus(); + returnToToolActionsIdRef.current = null; + } + }, [open, selectedTestTool]); + // Reset tab, filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; setTopTab("test"); setSelectedTestToolId(null); + focusedToolTestIdRef.current = null; + returnToToolActionsIdRef.current = null; + restoreToolActionsFocusRef.current = false; + openingToolTestRef.current = false; setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); @@ -447,9 +441,10 @@ export function VirtualServerDetailsPanel({ useEffect(() => { if (!selectedTestToolId) return; if (!virtualServerToolTryItEnabled || !selectedTestTool) { + restoreToolActionsFocusRef.current = open && topTab === "components"; setSelectedTestToolId(null); } - }, [selectedTestTool, selectedTestToolId, virtualServerToolTryItEnabled]); + }, [open, selectedTestTool, selectedTestToolId, topTab, virtualServerToolTryItEnabled]); useEffect(() => { if (sourceFilter === "all") return; @@ -605,7 +600,11 @@ export function VirtualServerDetailsPanel({ setSelectedTestToolId(null)} + headingRef={toolTestHeadingRef} + onBack={() => { + restoreToolActionsFocusRef.current = true; + setSelectedTestToolId(null); + }} /> ) : ( <> @@ -752,7 +751,7 @@ export function VirtualServerDetailsPanel({ const identifier = getComponentIdentifier(component); const testableTool = component.type === "tools" - ? fetchedTools.find((tool) => tool.id === component.id) + ? fetchedToolsById.get(component.id) : undefined; return ( @@ -808,6 +807,7 @@ export function VirtualServerDetailsPanel({ - + { + if (openingToolTestRef.current) { + event.preventDefault(); + openingToolTestRef.current = false; + } + }} + > setSelectedTestToolId(testableTool.id)} + onSelect={() => { + openingToolTestRef.current = true; + returnToToolActionsIdRef.current = `virtual-server-tool-actions-${server.id}-${component.id}`; + setSelectedTestToolId(testableTool.id); + }} > {intl.formatMessage({ @@ -965,10 +977,12 @@ export function VirtualServerDetailsPanel({ function VirtualServerToolTestView({ server, tool, + headingRef, onBack, }: { server: VirtualServer; - tool: PanelTool; + tool: VirtualServerTool; + headingRef: Ref; onBack: () => void; }) { const intl = useIntl(); @@ -981,7 +995,7 @@ function VirtualServerToolTestView({ { + it("adapts snake_case virtual-server tool fields", () => { + const tool = { + id: "tool-1", + name: "github.search_issues", + display_name: "Find issues", + original_name: "search_issues", + gateway_id: "gateway-1", + gateway_slug: "github-mcp", + input_schema: { type: "object", properties: { query: { type: "string" } } }, + output_schema: { type: "object", properties: { count: { type: "integer" } } }, + annotations: { readOnlyHint: true }, + } as unknown as VirtualServerTool; + + expect(normalizeVirtualServerTool(tool)).toMatchObject({ + displayName: "Find issues", + originalName: "search_issues", + gatewayId: "gateway-1", + gatewaySlug: "github-mcp", + inputSchema: { properties: { query: { type: "string" } } }, + outputSchema: { properties: { count: { type: "integer" } } }, + annotations: { readOnlyHint: true }, + }); + }); + + it("keeps camelCase values when both shapes are present", () => { + const tool = { + id: "tool-1", + name: "github.search_issues", + displayName: "Camel label", + display_name: "Snake label", + originalName: "camel_name", + original_name: "snake_name", + gatewayId: "camel-gateway-id", + gateway_id: "snake-gateway-id", + gatewaySlug: "camel-gateway-slug", + gateway_slug: "snake-gateway-slug", + inputSchema: { type: "object", properties: { camel: { type: "string" } } }, + input_schema: { type: "object", properties: { snake: { type: "string" } } }, + outputSchema: { type: "object", properties: { camel: { type: "string" } } }, + output_schema: { type: "object", properties: { snake: { type: "string" } } }, + } as unknown as VirtualServerTool; + + const normalized = normalizeVirtualServerTool(tool); + expect(normalized.displayName).toBe("Camel label"); + expect(normalized.originalName).toBe("camel_name"); + expect(normalized.gatewayId).toBe("camel-gateway-id"); + expect(normalized.gatewaySlug).toBe("camel-gateway-slug"); + expect(normalized.inputSchema).toEqual(tool.inputSchema); + expect(normalized.outputSchema).toEqual(tool.outputSchema); + }); +}); diff --git a/src/components/gateways/normalizeVirtualServerTool.ts b/src/components/gateways/normalizeVirtualServerTool.ts new file mode 100644 index 00000000..3a49f0db --- /dev/null +++ b/src/components/gateways/normalizeVirtualServerTool.ts @@ -0,0 +1,29 @@ +import type { Tool } from "@/types/tool"; + +export interface VirtualServerTool extends Tool { + gateway_id?: string; +} + +export function normalizeVirtualServerTool(tool: VirtualServerTool): VirtualServerTool { + const record = tool as unknown as Record; + return { + ...tool, + annotations: asRecord(tool.annotations) ?? {}, + displayName: nonEmptyString(record.displayName) ?? nonEmptyString(record.display_name), + gatewayId: tool.gatewayId ?? nonEmptyString(record.gateway_id) ?? null, + gatewaySlug: tool.gatewaySlug ?? nonEmptyString(record.gateway_slug) ?? "", + inputSchema: asRecord(tool.inputSchema) ?? asRecord(record.input_schema) ?? {}, + originalName: + nonEmptyString(record.originalName) ?? nonEmptyString(record.original_name) ?? tool.name, + outputSchema: asRecord(tool.outputSchema) ?? asRecord(record.output_schema), + }; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} diff --git a/src/components/tools/ToolLiveInvokeResult.test.tsx b/src/components/tools/ToolLiveInvokeResult.test.tsx index f329e236..a4ab13cc 100644 --- a/src/components/tools/ToolLiveInvokeResult.test.tsx +++ b/src/components/tools/ToolLiveInvokeResult.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/test/test-utils"; import type { ToolInvokeState } from "@/hooks/useToolInvoke"; +import type { ToolPreviewResponse } from "@/api/tools"; import { ToolLiveInvokeResult } from "./ToolLiveInvokeResult"; function invokeProps( @@ -92,6 +93,41 @@ describe("ToolLiveInvokeResult", () => { expect(screen.getByText("Answered by github-mcp")).toBeInTheDocument(); }); + it.each([ + { metadata: { gateway_name: "root-gateway-name" }, expected: "root-gateway-name" }, + { metadata: { gatewayName: "rootGatewayName" }, expected: "rootGatewayName" }, + { + metadata: { resolved_gateway_name: "resolved-gateway-name" }, + expected: "resolved-gateway-name", + }, + { metadata: { resolvedGatewayName: "resolvedGatewayName" }, expected: "resolvedGatewayName" }, + { + metadata: { target: { gateway_name: "target-gateway-name" } }, + expected: "target-gateway-name", + }, + { metadata: { target: { gatewayName: "targetGatewayName" } }, expected: "targetGatewayName" }, + { + metadata: { target: { gateway_slug: "target-gateway-slug" } }, + expected: "target-gateway-slug", + }, + { metadata: { target: { gatewaySlug: "targetGatewaySlug" } }, expected: "targetGatewaySlug" }, + ])("renders backing gateway metadata from $expected", ({ metadata, expected }) => { + const response = { + content: [{ type: "text", text: "live result", mimeType: "text/plain" }], + ...metadata, + } as unknown as ToolPreviewResponse; + render( + , + ); + + expect(screen.getByText(`Answered by ${expected}`)).toBeInTheDocument(); + }); + it("renders HTTP errors and tool-level error results", () => { const { rerender } = render( ({ permissionsLoading: false })); + vi.mock("@/auth/useAuth", () => ({ useAuth: () => ({ hasPermission: (permission: string) => permission === "tools.execute" || permission === "servers.use", - permissionsLoading: false, + permissionsLoading: authMock.permissionsLoading, }), })); +beforeEach(() => { + authMock.permissionsLoading = false; +}); + function activeCode(): string { const pre = document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'); return pre?.textContent ?? ""; @@ -129,7 +137,6 @@ describe("ToolTryItTab", () => { render( tool.displayName ?? tool.name} serverScope={{ serverId: "virtual-server-1", serverName: "Developer tools" }} selectedTool={selectedTool} />, @@ -151,6 +158,73 @@ describe("ToolTryItTab", () => { await user.click(screen.getByRole("tab", { name: "JSON-RPC" })); expect(activeCode()).toContain('"server_id": "virtual-server-1"'); expect(activeCode()).toContain('"name": "github.search_issues"'); + + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "curl" })).toHaveAttribute("data-state", "active"); + expect(activeCode()).toContain("/v1/tools/preview/github.search_issues"); + expect(activeCode()).toContain('"server_id":"virtual-server-1"'); + }); + + it("disables live mode while access is being checked and describes why", () => { + authMock.permissionsLoading = true; + const selectedTool = makeTool({ annotations: { readOnlyHint: true } }); + + render( + , + ); + + const liveSwitch = screen.getByRole("switch", { name: "Live invocation" }); + expect(liveSwitch).toBeDisabled(); + expect(liveSwitch).toHaveAccessibleDescription("Checking your tool permissions."); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + }); + + it("clears preview and live results when switching modes", async () => { + const user = userEvent.setup(); + const selectedTool = makeTool({ annotations: { readOnlyHint: true } }); + let previewCalls = 0; + mswServer.use( + http.post("*/v1/tools/preview/:name", () => { + previewCalls += 1; + return HttpResponse.json({ target: "local", resolved_arguments: { query: "cloudflare" } }); + }), + http.post("*/rpc", async ({ request }) => { + const envelope = (await request.json()) as { id: string }; + return HttpResponse.json({ + jsonrpc: "2.0", + id: envelope.id, + result: { content: [{ type: "text", text: "live result" }] }, + }); + }), + ); + + render( + , + ); + + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + await user.click(screen.getByRole("button", { name: "Preview" })); + expect(await screen.findByText("Preview 200")).toBeInTheDocument(); + + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + expect(screen.queryByText("Preview 200")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Live invoke" })); + expect(await screen.findByText("Live invoke 200")).toBeInTheDocument(); + + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + expect(screen.queryByText("Live invoke 200")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Preview" })); + await waitFor(() => expect(previewCalls).toBe(2)); + expect(await screen.findByText("Preview 200")).toBeInTheDocument(); }); it("keeps scoped preview available when live invocation is unsafe", async () => { @@ -170,5 +244,8 @@ describe("ToolTryItTab", () => { expect( screen.getByText("Live invoke is not offered for federated tools without readOnlyHint."), ).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( + "Live invoke is not offered for federated tools without readOnlyHint.", + ); }); }); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index 1b30e8c2..c9262e1b 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import type { ComponentProps } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { ComponentProps, Ref } from "react"; import { useIntl } from "react-intl"; import { useAuth } from "@/auth/useAuth"; @@ -33,7 +33,7 @@ import { getToolAnnotationHints } from "./toolAnnotations"; const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; export interface ToolTryItTabProps { - getToolLabel?: (tool: Tool) => string; + headingRef?: Ref; resultContext?: ComponentProps["context"]; serverScope?: { serverId: string; serverName: string }; tools?: Tool[]; @@ -42,7 +42,7 @@ export interface ToolTryItTabProps { } export function ToolTryItTab({ - getToolLabel, + headingRef, resultContext, serverScope, tools, @@ -50,6 +50,7 @@ export function ToolTryItTab({ onSelectTool, }: ToolTryItTabProps) { const intl = useIntl(); + const liveModeReasonId = useId(); const { hasPermission, permissionsLoading } = useAuth(); const [args, setArgs] = useState>(() => seedToolArguments(selectedTool.inputSchema), @@ -86,7 +87,6 @@ export function ToolTryItTab({ const resetInvoke = invoke.reset; const previousToolIdRef = useRef(selectedTool.id); const availableTools = tools ?? [selectedTool]; - const toolLabel = getToolLabel ?? ((tool: Tool) => tool.name); const snippetSpecs = scopedMode && !liveMode ? TOOL_PREVIEW_SNIPPETS : TOOL_SNIPPETS; const snippets = useMemo( () => @@ -126,7 +126,11 @@ export function ToolTryItTab({
-

+

{intl.formatMessage({ id: scopedMode ? "tools.details.test.title" : "tools.details.preview.title", })} @@ -143,6 +147,15 @@ export function ToolTryItTab({ )}

+ {scopedMode && ( +

+ {selectedTool.displayName || + selectedTool.title || + selectedTool.originalName || + selectedTool.name} +

+ )} + {availableTools.length > 1 && onSelectTool && (
- {toolLabel(tool)} + {tool.name} ); })} @@ -190,13 +203,17 @@ export function ToolTryItTab({ {intl.formatMessage({ id: "tools.details.test.liveMode" })} {!liveModeAvailable && ( -

+

{getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)}

)}
Date: Wed, 16 Sep 2026 22:54:36 +0100 Subject: [PATCH 3/8] Address virtual server tool test review findings Signed-off-by: Pratik Gandhi --- .../VirtualServerDetailsPanel.test.tsx | 109 ++++++++++++++++++ .../gateways/VirtualServerDetailsPanel.tsx | 11 +- .../normalizeVirtualServerTool.test.ts | 31 +++++ .../gateways/normalizeVirtualServerTool.ts | 10 +- .../tools/ToolLiveInvokeGate.test.tsx | 20 ++++ src/components/tools/ToolLiveInvokeGate.tsx | 19 ++- .../tools/ToolLiveInvokeResult.test.tsx | 65 +++++++++++ src/components/tools/ToolLiveInvokeResult.tsx | 4 +- src/components/tools/ToolTryItTab.test.tsx | 1 + src/components/tools/ToolTryItTab.tsx | 11 +- src/i18n/locales/en-US/tools.json | 1 + src/i18n/locales/es-ES/tools.json | 1 + src/i18n/locales/pt-BR/tools.json | 1 + 13 files changed, 275 insertions(+), 9 deletions(-) diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index e8d92faa..c3b1c779 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -8,6 +8,7 @@ import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel"; import type { VirtualServer } from "@/types/server"; import type { Tool } from "@/types/tool"; import { copyToClipboard } from "@/lib/clipboard"; +import { toolsApi } from "@/api/tools"; vi.mock("@/lib/clipboard", () => ({ copyToClipboard: vi.fn() })); @@ -109,6 +110,8 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); + localStorage.removeItem("user-locale"); }); describe("VirtualServerDetailsPanel inline tag add", () => { @@ -400,6 +403,58 @@ describe("VirtualServerDetailsPanel tool testing", () => { ); }); + it.each(["close button", "Escape"])( + "cancels a pending live call when the drawer closes with %s", + async (closeWith) => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => + HttpResponse.json({ tools: [makeTool()] }), + ), + ); + let signal: AbortSignal | undefined; + const invokeSpy = vi + .spyOn(toolsApi, "invoke") + .mockImplementation((_name, _args, _headers, options) => { + signal = options?.signal; + return new Promise(() => {}); + }); + const cancelSpy = vi.spyOn(toolsApi, "cancelInvoke").mockResolvedValue(undefined); + const onClose = vi.fn(); + const server = makeServer({ id: "virtual-server-1" }); + const panel = (open: boolean) => ( + + ); + const { rerender } = render(panel(true)); + + await openToolTest(user, "Search issues"); + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + await user.click(screen.getByRole("button", { name: "Live invoke" })); + await waitFor(() => expect(invokeSpy).toHaveBeenCalledOnce()); + expect(signal?.aborted).toBe(false); + + if (closeWith === "Escape") { + await user.keyboard("{Escape}"); + } else { + await user.click(screen.getByRole("button", { name: "Close virtual server details" })); + } + expect(onClose).toHaveBeenCalledOnce(); + rerender(panel(false)); + + await waitFor(() => expect(signal?.aborted).toBe(true)); + expect(cancelSpy).toHaveBeenCalledWith(expect.stringMatching(/^tool-live-/), "unmount"); + expect(screen.queryByText("Tool test")).not.toBeInTheDocument(); + }, + ); + it("uses snake_case tool fields for scoped testing", async () => { const user = userEvent.setup(); vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); @@ -443,6 +498,35 @@ describe("VirtualServerDetailsPanel tool testing", () => { ).toHaveTextContent('"server_id":"virtual-server-1"'); }); + it("keeps Preview available but blocks Live for an explicitly blank gateway ID", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", () => + HttpResponse.json({ + tools: [makeTool({ gatewayId: "", annotations: { destructiveHint: true } })], + }), + ), + ); + + render( + , + ); + + await openToolTest(user, "Search issues"); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( + "Live invoke is unavailable because this tool's gateway ID is invalid.", + ); + }); + it("does not expose Test for associatedToolIds fallback rows", async () => { const user = userEvent.setup(); vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); @@ -510,6 +594,31 @@ describe("VirtualServerDetailsPanel render variants", () => { ); }); + it("localizes the components loading state", async () => { + const user = userEvent.setup(); + localStorage.setItem("user-locale", "es-ES"); + mswServer.use( + http.get("*/v1/virtual-servers/:id/tools", async () => { + await delay("infinite"); + return HttpResponse.json({ tools: [] }); + }), + ); + + render( + , + ); + + await user.click(screen.getByRole("tab", { name: "Componentes" })); + expect(await screen.findByText("Cargando componentes...")).toBeInTheDocument(); + expect(screen.queryByText("Loading components...")).not.toBeInTheDocument(); + }); + it("shows the internal visibility label", async () => { render( { + if (!open) setSelectedTestToolId(null); + }, [open]); + useEffect(() => { if (!selectedTestToolId) return; if (!virtualServerToolTryItEnabled || !selectedTestTool) { @@ -596,7 +600,7 @@ export function VirtualServerDetailsPanel({ - {virtualServerToolTryItEnabled && selectedTestTool ? ( + {open && virtualServerToolTryItEnabled && selectedTestTool ? (
)} @@ -996,6 +1002,7 @@ function VirtualServerToolTestView({ { expect(normalized.inputSchema).toEqual(tool.inputSchema); expect(normalized.outputSchema).toEqual(tool.outputSchema); }); + + it("uses a valid snake_case gateway ID when the camelCase field is blank", () => { + const tool = { + id: "tool-1", + name: "github.search_issues", + gatewayId: "", + gateway_id: "gateway-1", + } as VirtualServerTool; + + const normalized = normalizeVirtualServerTool(tool); + expect(normalized.gatewayId).toBe("gateway-1"); + expect(normalized.invalidGatewayId).toBe(false); + }); + + it("marks an explicitly blank gateway ID invalid without misclassifying local tools", () => { + const malformed = normalizeVirtualServerTool({ + id: "tool-1", + name: "github.search_issues", + gatewayId: " ", + } as VirtualServerTool); + const local = normalizeVirtualServerTool({ + id: "tool-2", + name: "local.search", + gatewayId: null, + } as VirtualServerTool); + + expect(malformed.gatewayId).toBeNull(); + expect(malformed.invalidGatewayId).toBe(true); + expect(local.gatewayId).toBeNull(); + expect(local.invalidGatewayId).toBe(false); + }); }); diff --git a/src/components/gateways/normalizeVirtualServerTool.ts b/src/components/gateways/normalizeVirtualServerTool.ts index 3a49f0db..afe4e8b5 100644 --- a/src/components/gateways/normalizeVirtualServerTool.ts +++ b/src/components/gateways/normalizeVirtualServerTool.ts @@ -2,15 +2,23 @@ import type { Tool } from "@/types/tool"; export interface VirtualServerTool extends Tool { gateway_id?: string; + invalidGatewayId?: boolean; } export function normalizeVirtualServerTool(tool: VirtualServerTool): VirtualServerTool { const record = tool as unknown as Record; + const gatewayId = nonEmptyString(record.gatewayId) ?? nonEmptyString(record.gateway_id); + const invalidGatewayId = + !gatewayId && + [record.gatewayId, record.gateway_id].some( + (value) => typeof value === "string" && !value.trim(), + ); return { ...tool, annotations: asRecord(tool.annotations) ?? {}, displayName: nonEmptyString(record.displayName) ?? nonEmptyString(record.display_name), - gatewayId: tool.gatewayId ?? nonEmptyString(record.gateway_id) ?? null, + gatewayId: gatewayId ?? null, + invalidGatewayId, gatewaySlug: tool.gatewaySlug ?? nonEmptyString(record.gateway_slug) ?? "", inputSchema: asRecord(tool.inputSchema) ?? asRecord(record.input_schema) ?? {}, originalName: diff --git a/src/components/tools/ToolLiveInvokeGate.test.tsx b/src/components/tools/ToolLiveInvokeGate.test.tsx index 631d3e64..6ac50926 100644 --- a/src/components/tools/ToolLiveInvokeGate.test.tsx +++ b/src/components/tools/ToolLiveInvokeGate.test.tsx @@ -106,6 +106,26 @@ describe("resolveToolLiveInvokeAvailability", () => { ).toEqual({ state: "requiresConfirmation" }); }); + it("blocks live invocation when gateway identity is malformed", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + invalidGatewayId: true, + tool: { annotations: { destructiveHint: true }, gatewayId: null }, + }), + ).toEqual({ state: "unavailableInvalidGateway" }); + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: { readOnlyHint: true }, gatewayId: "" }, + }), + ).toEqual({ state: "unavailableInvalidGateway" }); + }); + it("treats destructiveHint as higher priority than readOnlyHint", () => { expect( resolveToolLiveInvokeAvailability({ diff --git a/src/components/tools/ToolLiveInvokeGate.tsx b/src/components/tools/ToolLiveInvokeGate.tsx index 8fbc3ff9..de2e5bf3 100644 --- a/src/components/tools/ToolLiveInvokeGate.tsx +++ b/src/components/tools/ToolLiveInvokeGate.tsx @@ -14,6 +14,7 @@ export type ToolLiveInvokeAvailability = | { state: "missingPermission"; permission: "tools.execute" | "servers.use" } | { state: "available" } | { state: "requiresConfirmation" } + | { state: "unavailableInvalidGateway" } | { state: "unavailableFederated" } | { state: "unavailableUntagged" }; @@ -21,6 +22,7 @@ export interface ResolveToolLiveInvokeAvailabilityInput { canExecute: boolean; canUseServers: boolean; permissionsLoading: boolean; + invalidGatewayId?: boolean; tool: Pick; } @@ -28,11 +30,15 @@ export function resolveToolLiveInvokeAvailability({ canExecute, canUseServers, permissionsLoading, + invalidGatewayId = false, tool, }: ResolveToolLiveInvokeAvailabilityInput): ToolLiveInvokeAvailability { if (permissionsLoading) return { state: "checkingAccess" }; if (!canExecute) return { state: "missingPermission", permission: "tools.execute" }; if (!canUseServers) return { state: "missingPermission", permission: "servers.use" }; + if (invalidGatewayId || (typeof tool.gatewayId === "string" && !tool.gatewayId.trim())) { + return { state: "unavailableInvalidGateway" }; + } const hints = getToolAnnotationHints(tool.annotations); const isFederated = Boolean(tool.gatewayId); @@ -51,11 +57,17 @@ export function resolveToolLiveInvokeAvailability({ export interface ToolLiveInvokeGateProps { disabled?: boolean; + invalidGatewayId?: boolean; invoke: Pick; tool: Tool; } -export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveInvokeGateProps) { +export function ToolLiveInvokeGate({ + disabled = false, + invalidGatewayId = false, + invoke, + tool, +}: ToolLiveInvokeGateProps) { const intl = useIntl(); const { hasPermission, permissionsLoading } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); @@ -65,9 +77,10 @@ export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveI canExecute: hasPermission("tools.execute"), canUseServers: hasPermission("servers.use"), permissionsLoading, + invalidGatewayId, tool, }), - [hasPermission, permissionsLoading, tool], + [hasPermission, permissionsLoading, invalidGatewayId, tool], ); if (invoke.isLoading) { @@ -159,6 +172,8 @@ export function getToolLiveInvokeAvailabilityMessage( }); case "unavailableFederated": return formatMessage({ id: "tools.details.invoke.unavailable.federated" }); + case "unavailableInvalidGateway": + return formatMessage({ id: "tools.details.invoke.unavailable.invalidGateway" }); case "unavailableUntagged": return formatMessage({ id: "tools.details.invoke.unavailable.untagged" }); case "available": diff --git a/src/components/tools/ToolLiveInvokeResult.test.tsx b/src/components/tools/ToolLiveInvokeResult.test.tsx index a4ab13cc..9d2bcc22 100644 --- a/src/components/tools/ToolLiveInvokeResult.test.tsx +++ b/src/components/tools/ToolLiveInvokeResult.test.tsx @@ -93,6 +93,71 @@ describe("ToolLiveInvokeResult", () => { expect(screen.getByText("Answered by github-mcp")).toBeInTheDocument(); }); + it("shows the configured gateway after a successful call without response metadata", () => { + render( + , + ); + + expect(screen.getByText("Answered by github-mcp")).toBeInTheDocument(); + }); + + it.each([ + { + name: "network error", + invoke: invokeProps({ + hasRun: true, + error: { status: null, renderTimeMs: 3, message: "Network error" }, + }), + }, + { + name: "JSON-RPC error", + invoke: invokeProps({ + hasRun: true, + error: { code: -32003, status: null, renderTimeMs: 3, message: "Access denied" }, + }), + }, + { + name: "timeout", + invoke: invokeProps({ + hasRun: true, + error: { status: null, renderTimeMs: 3, message: "Timed out", timedOut: true }, + }), + }, + { + name: "tool-level error", + invoke: invokeProps({ + hasRun: true, + result: { + id: "invoke-1", + status: 200, + renderTimeMs: 3, + result: { content: [], isError: true }, + }, + }), + }, + ])("keeps request context but does not attribute a $name to a gateway", ({ invoke }) => { + render( + , + ); + + expect(screen.getByText("Requested through Developer tools")).toBeInTheDocument(); + expect(screen.queryByText("Answered by github-mcp")).not.toBeInTheDocument(); + }); + it.each([ { metadata: { gateway_name: "root-gateway-name" }, expected: "root-gateway-name" }, { metadata: { gatewayName: "rootGatewayName" }, expected: "rootGatewayName" }, diff --git a/src/components/tools/ToolLiveInvokeResult.tsx b/src/components/tools/ToolLiveInvokeResult.tsx index 5be22816..64a57b77 100644 --- a/src/components/tools/ToolLiveInvokeResult.tsx +++ b/src/components/tools/ToolLiveInvokeResult.tsx @@ -39,10 +39,12 @@ export function ToolLiveInvokeResult({ context, invoke }: ToolLiveInvokeResultPr const renderTimeMs = result?.renderTimeMs ?? error?.renderTimeMs ?? 0; const response = result?.result; - const backingGatewayName = context?.backingGatewayName ?? getBackingGatewayName(response); const toolResultIsError = response ? getToolResultIsError(response) : false; const succeeded = result !== null; const statusOk = succeeded && !toolResultIsError; + const backingGatewayName = statusOk + ? (context?.backingGatewayName ?? getBackingGatewayName(response)) + : undefined; const statusLabel = succeeded ? intl.formatMessage({ id: "tools.details.invoke.statusOk" }, { status: result.status }) : error?.code !== undefined diff --git a/src/components/tools/ToolTryItTab.test.tsx b/src/components/tools/ToolTryItTab.test.tsx index a2539e5c..ef488257 100644 --- a/src/components/tools/ToolTryItTab.test.tsx +++ b/src/components/tools/ToolTryItTab.test.tsx @@ -143,6 +143,7 @@ describe("ToolTryItTab", () => { ); expect(screen.getByText("Tool test")).toBeInTheDocument(); + expect(screen.getByText("Live invocation")).toHaveAttribute("data-slot", "label"); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index c9262e1b..12d830b9 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -5,6 +5,7 @@ import { useIntl } from "react-intl"; import { useAuth } from "@/auth/useAuth"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; import { CodeBlock } from "@/components/ui/code-block"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -34,6 +35,7 @@ const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; export interface ToolTryItTabProps { headingRef?: Ref; + invalidGatewayId?: boolean; resultContext?: ComponentProps["context"]; serverScope?: { serverId: string; serverName: string }; tools?: Tool[]; @@ -43,6 +45,7 @@ export interface ToolTryItTabProps { export function ToolTryItTab({ headingRef, + invalidGatewayId = false, resultContext, serverScope, tools, @@ -70,9 +73,10 @@ export function ToolTryItTab({ canExecute: hasPermission("tools.execute"), canUseServers: hasPermission("servers.use"), permissionsLoading, + invalidGatewayId, tool: selectedTool, }), - [hasPermission, permissionsLoading, selectedTool], + [hasPermission, permissionsLoading, invalidGatewayId, selectedTool], ); const liveModeAvailable = liveAvailability.state === "available" || liveAvailability.state === "requiresConfirmation"; @@ -196,12 +200,12 @@ export function ToolTryItTab({ {scopedMode && (
- + {!liveModeAvailable && (

diff --git a/src/i18n/locales/en-US/tools.json b/src/i18n/locales/en-US/tools.json index d0a1f2bf..463d8f3a 100644 --- a/src/i18n/locales/en-US/tools.json +++ b/src/i18n/locales/en-US/tools.json @@ -146,6 +146,7 @@ "tools.details.invoke.unavailable.missingExecutePermission": "Live invoke requires tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "Live invoke requires servers.use.", "tools.details.invoke.unavailable.federated": "Live invoke is not offered for federated tools without readOnlyHint.", + "tools.details.invoke.unavailable.invalidGateway": "Live invoke is unavailable because this tool's gateway ID is invalid.", "tools.details.invoke.unavailable.untagged": "Live invoke is not offered until the tool declares readOnlyHint or destructiveHint.", "tools.details.invoke.confirm.title": "Invoke destructive tool", "tools.details.invoke.confirm.description": "Invoke \"{name}\" against the live gateway? This can change external state.", diff --git a/src/i18n/locales/es-ES/tools.json b/src/i18n/locales/es-ES/tools.json index 5f9a8764..7295f586 100644 --- a/src/i18n/locales/es-ES/tools.json +++ b/src/i18n/locales/es-ES/tools.json @@ -146,6 +146,7 @@ "tools.details.invoke.unavailable.missingExecutePermission": "La invocación en vivo requiere tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "La invocación en vivo requiere servers.use.", "tools.details.invoke.unavailable.federated": "La invocación en vivo no se ofrece para herramientas federadas sin readOnlyHint.", + "tools.details.invoke.unavailable.invalidGateway": "La invocación en vivo no está disponible porque el ID de la puerta de enlace de esta herramienta no es válido.", "tools.details.invoke.unavailable.untagged": "La invocación en vivo no se ofrece hasta que la herramienta declare readOnlyHint o destructiveHint.", "tools.details.invoke.confirm.title": "Invocar herramienta destructiva", "tools.details.invoke.confirm.description": "¿Invocar \"{name}\" contra la puerta de enlace en vivo? Esto puede cambiar estado externo.", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index e85e8e2b..ac0d2110 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -146,6 +146,7 @@ "tools.details.invoke.unavailable.missingExecutePermission": "A invocação em tempo real requer tools.execute.", "tools.details.invoke.unavailable.missingServerUsePermission": "A invocação em tempo real requer servers.use.", "tools.details.invoke.unavailable.federated": "A invocação em tempo real não é oferecida para ferramentas federadas sem readOnlyHint.", + "tools.details.invoke.unavailable.invalidGateway": "A invocação em tempo real não está disponível porque o ID do gateway desta ferramenta é inválido.", "tools.details.invoke.unavailable.untagged": "A invocação em tempo real não é oferecida até que a ferramenta declare readOnlyHint ou destructiveHint.", "tools.details.invoke.confirm.title": "Invocar ferramenta destrutiva", "tools.details.invoke.confirm.description": "Invocar \"{name}\" no gateway em tempo real? Isso pode alterar estado externo.", From 9980c44a9559b27716c5dde6e2a2e1bb20aa3c4d Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 18 Sep 2026 15:20:31 +0100 Subject: [PATCH 4/8] Align virtual server tool test with design Signed-off-by: Pratik Gandhi --- e2e/virtual-servers.spec.ts | 21 +- .../VirtualServerDetailsPanel.test.tsx | 20 +- .../gateways/VirtualServerDetailsPanel.tsx | 12 +- src/components/tools/ToolTryItTab.test.tsx | 35 +- src/components/tools/ToolTryItTab.tsx | 207 ++++++----- src/i18n/locales/en-US/gateways.json | 1 - src/i18n/locales/en-US/tools.json | 6 +- src/i18n/locales/es-ES/gateways.json | 1 - src/i18n/locales/es-ES/tools.json | 6 +- src/i18n/locales/pt-BR/gateways.json | 1 - src/i18n/locales/pt-BR/tools.json | 6 +- virtual-server-try-it-manual.mjs | 329 ++++++++++++++++++ 12 files changed, 531 insertions(+), 114 deletions(-) create mode 100644 virtual-server-try-it-manual.mjs diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index bd5733c8..38503a3b 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -181,8 +181,10 @@ async function openVirtualServerToolTest(page: Page) { 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(); - await page.getByRole("menuitem", { name: "Test" }).click(); - await expect(panel.getByRole("heading", { name: "Tool test" })).toBeFocused(); + 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; } @@ -1350,6 +1352,10 @@ test.describe("Virtual Servers page", () => { 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); await panel.getByLabel("query").fill("cloudflare"); await panel.getByLabel("limit").fill("5"); @@ -1366,6 +1372,7 @@ test.describe("Virtual Servers page", () => { 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: "Live invoke" })).toBeVisible(); await panel.getByRole("button", { name: "Live invoke" }).click(); @@ -1384,7 +1391,9 @@ test.describe("Virtual Servers page", () => { }); expect(rpcHeaders["x-tenant-id"]).toBe("team-a"); - await panel.getByRole("button", { name: "Back to components" }).click(); + await panel + .getByRole("button", { name: "Clear selected tool and return to components" }) + .click(); await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused(); }); @@ -1417,9 +1426,11 @@ test.describe("Virtual Servers page", () => { 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: "Tool test" })).toBeFocused(); + await expect(panel.getByRole("heading", { name: "Test tool" })).toBeFocused(); - await panel.getByRole("button", { name: "Back to components" }).focus(); + 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(); }); diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index c3b1c779..59d1fb0d 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -334,7 +334,9 @@ describe("VirtualServerDetailsPanel tool testing", () => { async function openToolTest(user: ReturnType, actionName: string) { await user.click(await screen.findByRole("tab", { name: "Components" })); await user.click(await screen.findByRole("button", { name: `Actions for ${actionName}` })); - await user.click(await screen.findByRole("menuitem", { name: "Test" })); + const testAction = await screen.findByRole("menuitem", { name: "Test" }); + expect(testAction.querySelector("svg")).toBeNull(); + await user.click(testAction); } it("keeps the existing handshake Try-it tab and hides tool Test actions when disabled", async () => { @@ -386,8 +388,10 @@ describe("VirtualServerDetailsPanel tool testing", () => { await openToolTest(user, "Search issues"); - const testHeading = await screen.findByRole("heading", { name: "Tool test" }); + const testHeading = await screen.findByRole("heading", { name: "Test tool" }); await waitFor(() => expect(testHeading).toHaveFocus()); + expect(screen.getByText("Find issues")).toBeInTheDocument(); + expect(screen.queryByText("Search repository issues")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Live invocation" })).not.toBeChecked(); expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); @@ -395,9 +399,11 @@ describe("VirtualServerDetailsPanel tool testing", () => { document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'), ).toHaveTextContent('"server_id":"virtual-server-1"'); - await user.click(screen.getByRole("button", { name: "Back to components" })); + await user.click( + screen.getByRole("button", { name: "Clear selected tool and return to components" }), + ); expect(await screen.findByText("Search issues")).toBeInTheDocument(); - expect(screen.queryByText("Tool test")).not.toBeInTheDocument(); + expect(screen.queryByText("Test tool")).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByRole("button", { name: "Actions for Search issues" })).toHaveFocus(), ); @@ -451,7 +457,7 @@ describe("VirtualServerDetailsPanel tool testing", () => { await waitFor(() => expect(signal?.aborted).toBe(true)); expect(cancelSpy).toHaveBeenCalledWith(expect.stringMatching(/^tool-live-/), "unmount"); - expect(screen.queryByText("Tool test")).not.toBeInTheDocument(); + expect(screen.queryByText("Test tool")).not.toBeInTheDocument(); }, ); @@ -523,7 +529,7 @@ describe("VirtualServerDetailsPanel tool testing", () => { expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( - "Live invoke is unavailable because this tool's gateway ID is invalid.", + "Writes, external requests, and quota use happen immediately. Live invoke is unavailable because this tool's gateway ID is invalid.", ); }); @@ -579,7 +585,7 @@ describe("VirtualServerDetailsPanel tool testing", () => { expect(screen.getByRole("button", { name: "Preview" })).toBeEnabled(); expect(screen.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( - message, + `Writes, external requests, and quota use happen immediately. ${message}`, ); expect(screen.getByText(message)).toBeInTheDocument(); }); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 14513852..79e4ef5b 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -3,10 +3,8 @@ import type { ReactNode, Ref } from "react"; import { useIntl } from "react-intl"; import { Activity, - ArrowLeft, Box, EllipsisVertical, - FlaskConical, Loader2, MessageSquareCode, PanelRightClose, @@ -842,7 +840,6 @@ export function VirtualServerDetailsPanel({ setSelectedTestToolId(testableTool.id); }} > - {intl.formatMessage({ id: "gateways.details.component.test", })} @@ -991,17 +988,12 @@ function VirtualServerToolTestView({ headingRef: Ref; onBack: () => void; }) { - const intl = useIntl(); - return ( -

- +
{ expect(activeCode()).toContain('"method":"tools/call"'); expect(activeCode()).toContain('"name":"search_issues"'); expect(activeCode()).not.toContain("/api/rpc"); + expect( + screen.queryByText("Writes, external requests, and quota use happen immediately."), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /clear selected tool/i })).not.toBeInTheDocument(); await user.type(screen.getByLabelText(/query/i), "cloudflare"); expect(screen.getByRole("button", { name: "Live invoke" })).toBeEnabled(); @@ -129,6 +133,7 @@ describe("ToolTryItTab", () => { it("defaults scoped testing to preview and switches snippets with live mode", async () => { const user = userEvent.setup(); + const onClear = vi.fn(); const selectedTool = makeTool({ name: "github.search_issues", displayName: "Search issues", @@ -137,13 +142,24 @@ describe("ToolTryItTab", () => { render( , ); - expect(screen.getByText("Tool test")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Test tool" })).toBeInTheDocument(); + expect(screen.getByText("Search issues")).toBeInTheDocument(); + expect(screen.queryByText("Search repository issues")).not.toBeInTheDocument(); + expect(screen.queryByText("Read-only")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Clear selected tool and return to components" }), + ).toHaveTextContent("Clear"); expect(screen.getByText("Live invocation")).toHaveAttribute("data-slot", "label"); + expect( + screen.getByText("Writes, external requests, and quota use happen immediately."), + ).toBeInTheDocument(); + expect(screen.queryByText(/Live invocation is enabled/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); @@ -152,6 +168,11 @@ describe("ToolTryItTab", () => { await user.click(screen.getByRole("switch", { name: "Live invocation" })); + expect( + screen.getByText( + "Live invocation is enabled. Review your arguments carefully or switch back to preview mode.", + ), + ).toBeVisible(); expect(screen.getByRole("button", { name: "Live invoke" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Preview" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON-RPC" })).toBeInTheDocument(); @@ -161,12 +182,18 @@ describe("ToolTryItTab", () => { expect(activeCode()).toContain('"name": "github.search_issues"'); await user.click(screen.getByRole("switch", { name: "Live invocation" })); + expect(screen.queryByText(/Live invocation is enabled/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "curl" })).toHaveAttribute("data-state", "active"); expect(activeCode()).toContain("/v1/tools/preview/github.search_issues"); expect(activeCode()).toContain('"server_id":"virtual-server-1"'); + + await user.click( + screen.getByRole("button", { name: "Clear selected tool and return to components" }), + ); + expect(onClear).toHaveBeenCalledOnce(); }); it("disables live mode while access is being checked and describes why", () => { @@ -182,7 +209,9 @@ describe("ToolTryItTab", () => { const liveSwitch = screen.getByRole("switch", { name: "Live invocation" }); expect(liveSwitch).toBeDisabled(); - expect(liveSwitch).toHaveAccessibleDescription("Checking your tool permissions."); + expect(liveSwitch).toHaveAccessibleDescription( + "Writes, external requests, and quota use happen immediately. Checking your tool permissions.", + ); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); }); @@ -246,7 +275,7 @@ describe("ToolTryItTab", () => { screen.getByText("Live invoke is not offered for federated tools without readOnlyHint."), ).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( - "Live invoke is not offered for federated tools without readOnlyHint.", + "Writes, external requests, and quota use happen immediately. Live invoke is not offered for federated tools without readOnlyHint.", ); }); }); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index 12d830b9..17628dbb 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { ComponentProps, Ref } from "react"; import { useIntl } from "react-intl"; +import { TriangleAlert, Wrench } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { Button } from "@/components/ui/button"; @@ -36,6 +37,7 @@ const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; export interface ToolTryItTabProps { headingRef?: Ref; invalidGatewayId?: boolean; + onClear?: () => void; resultContext?: ComponentProps["context"]; serverScope?: { serverId: string; serverName: string }; tools?: Tool[]; @@ -46,6 +48,7 @@ export interface ToolTryItTabProps { export function ToolTryItTab({ headingRef, invalidGatewayId = false, + onClear, resultContext, serverScope, tools, @@ -53,6 +56,7 @@ export function ToolTryItTab({ onSelectTool, }: ToolTryItTabProps) { const intl = useIntl(); + const liveModeDescriptionId = useId(); const liveModeReasonId = useId(); const { hasPermission, permissionsLoading } = useAuth(); const [args, setArgs] = useState>(() => @@ -91,6 +95,11 @@ export function ToolTryItTab({ const resetInvoke = invoke.reset; const previousToolIdRef = useRef(selectedTool.id); const availableTools = tools ?? [selectedTool]; + const selectedToolLabel = + selectedTool.displayName || + selectedTool.title || + selectedTool.originalName || + selectedTool.name; const snippetSpecs = scopedMode && !liveMode ? TOOL_PREVIEW_SNIPPETS : TOOL_SNIPPETS; const snippets = useMemo( () => @@ -128,100 +137,132 @@ export function ToolTryItTab({ return (
-
-
-

+
+

+ {intl.formatMessage({ id: "tools.details.test.title" })} +

+ {onClear && ( + + )} +
+ - {intl.formatMessage({ - id: scopedMode ? "tools.details.test.title" : "tools.details.preview.title", - })} -

- {annotationHints.readOnlyHint && ( - - {intl.formatMessage({ id: "tools.details.preview.annotation.readOnly" })} - - )} - {annotationHints.destructiveHint && ( - - {intl.formatMessage({ id: "tools.details.preview.annotation.destructive" })} - - )} +
- - {scopedMode && ( -

- {selectedTool.displayName || - selectedTool.title || - selectedTool.originalName || - selectedTool.name} -

- )} - - {availableTools.length > 1 && onSelectTool && ( -
- {availableTools.map((tool) => { - const isSelected = tool.id === selectedTool.id; - return ( - - ); - })} + ) : ( +
+
+

+ {intl.formatMessage({ id: "tools.details.preview.title" })} +

+ {annotationHints.readOnlyHint && ( + + {intl.formatMessage({ id: "tools.details.preview.annotation.readOnly" })} + + )} + {annotationHints.destructiveHint && ( + + {intl.formatMessage({ id: "tools.details.preview.annotation.destructive" })} + + )}
- )} - {selectedTool.description && ( -

- {selectedTool.description} -

- )} -
+ {availableTools.length > 1 && onSelectTool && ( +
+ {availableTools.map((tool) => { + const isSelected = tool.id === selectedTool.id; + return ( + + ); + })} +
+ )} + + {selectedTool.description && ( +

+ {selectedTool.description} +

+ )} +
+ )} {scopedMode && ( -
-
- - {!liveModeAvailable && ( +
+
+
+

- {getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)} + {intl.formatMessage({ id: "tools.details.test.liveModeDescription" })}

- )} + {!liveModeAvailable && ( +

+ {getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)} +

+ )} +
+
- + {liveMode && ( +
+
+ )}
)} diff --git a/src/i18n/locales/en-US/gateways.json b/src/i18n/locales/en-US/gateways.json index 98e3da11..568a0d70 100644 --- a/src/i18n/locales/en-US/gateways.json +++ b/src/i18n/locales/en-US/gateways.json @@ -123,7 +123,6 @@ "gateways.details.component.resources": "resource", "gateways.details.component.prompts": "prompt", "gateways.details.component.test": "Test", - "gateways.details.component.backToList": "Back to components", "gateways.details.component.copyName.tools": "Copy tool name for {name}", "gateways.details.component.copyName.resources": "Copy URI for {name}", "gateways.details.component.copyName.prompts": "Copy prompt name for {name}", diff --git a/src/i18n/locales/en-US/tools.json b/src/i18n/locales/en-US/tools.json index 463d8f3a..d3945aac 100644 --- a/src/i18n/locales/en-US/tools.json +++ b/src/i18n/locales/en-US/tools.json @@ -138,8 +138,12 @@ "tools.details.invoke.statusErrorWithStatus": "Live invoke failed {status}", "tools.details.invoke.rawResponse": "Raw live response", "tools.details.invoke.copyRawResponse": "Copy raw live response", - "tools.details.test.title": "Tool test", + "tools.details.test.title": "Test tool", + "tools.details.test.clear": "Clear", + "tools.details.test.clearAccessible": "Clear selected tool and return to components", "tools.details.test.liveMode": "Live invocation", + "tools.details.test.liveModeDescription": "Writes, external requests, and quota use happen immediately.", + "tools.details.test.liveModeWarning": "Live invocation is enabled. Review your arguments carefully or switch back to preview mode.", "tools.details.invoke.context.requestedThrough": "Requested through {name}", "tools.details.invoke.context.answeredBy": "Answered by {name}", "tools.details.invoke.unavailable.checkingAccess": "Checking your tool permissions.", diff --git a/src/i18n/locales/es-ES/gateways.json b/src/i18n/locales/es-ES/gateways.json index d3660161..af90b022 100644 --- a/src/i18n/locales/es-ES/gateways.json +++ b/src/i18n/locales/es-ES/gateways.json @@ -123,7 +123,6 @@ "gateways.details.component.resources": "recurso", "gateways.details.component.prompts": "prompt", "gateways.details.component.test": "Probar", - "gateways.details.component.backToList": "Volver a componentes", "gateways.details.component.copyName.tools": "Copiar nombre de la herramienta de {name}", "gateways.details.component.copyName.resources": "Copiar URI de {name}", "gateways.details.component.copyName.prompts": "Copiar nombre del prompt de {name}", diff --git a/src/i18n/locales/es-ES/tools.json b/src/i18n/locales/es-ES/tools.json index 7295f586..c68ab843 100644 --- a/src/i18n/locales/es-ES/tools.json +++ b/src/i18n/locales/es-ES/tools.json @@ -138,8 +138,12 @@ "tools.details.invoke.statusErrorWithStatus": "Error en la invocación en vivo {status}", "tools.details.invoke.rawResponse": "Respuesta en vivo sin procesar", "tools.details.invoke.copyRawResponse": "Copiar respuesta en vivo sin procesar", - "tools.details.test.title": "Prueba de herramienta", + "tools.details.test.title": "Probar herramienta", + "tools.details.test.clear": "Limpiar", + "tools.details.test.clearAccessible": "Quitar la herramienta seleccionada y volver a los componentes", "tools.details.test.liveMode": "Invocación en vivo", + "tools.details.test.liveModeDescription": "Las escrituras, las solicitudes externas y el consumo de cuota ocurren de inmediato.", + "tools.details.test.liveModeWarning": "La invocación en vivo está activada. Revise los argumentos detenidamente o vuelva al modo de vista previa.", "tools.details.invoke.context.requestedThrough": "Solicitado a través de {name}", "tools.details.invoke.context.answeredBy": "Respondido por {name}", "tools.details.invoke.unavailable.checkingAccess": "Comprobando sus permisos de herramienta.", diff --git a/src/i18n/locales/pt-BR/gateways.json b/src/i18n/locales/pt-BR/gateways.json index 5cfb530b..60a5ee64 100644 --- a/src/i18n/locales/pt-BR/gateways.json +++ b/src/i18n/locales/pt-BR/gateways.json @@ -123,7 +123,6 @@ "gateways.details.component.resources": "recurso", "gateways.details.component.prompts": "prompt", "gateways.details.component.test": "Testar", - "gateways.details.component.backToList": "Voltar aos componentes", "gateways.details.component.copyName.tools": "Copiar nome da ferramenta de {name}", "gateways.details.component.copyName.resources": "Copiar URI de {name}", "gateways.details.component.copyName.prompts": "Copiar nome do prompt de {name}", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index ac0d2110..a7258a13 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -138,8 +138,12 @@ "tools.details.invoke.statusErrorWithStatus": "Falha na invocação em tempo real {status}", "tools.details.invoke.rawResponse": "Resposta bruta em tempo real", "tools.details.invoke.copyRawResponse": "Copiar resposta bruta em tempo real", - "tools.details.test.title": "Teste de ferramenta", + "tools.details.test.title": "Testar ferramenta", + "tools.details.test.clear": "Limpar", + "tools.details.test.clearAccessible": "Limpar a ferramenta selecionada e voltar aos componentes", "tools.details.test.liveMode": "Invocação em tempo real", + "tools.details.test.liveModeDescription": "Gravações, solicitações externas e uso de cota acontecem imediatamente.", + "tools.details.test.liveModeWarning": "A invocação em tempo real está ativada. Revise os argumentos com cuidado ou volte ao modo de pré-visualização.", "tools.details.invoke.context.requestedThrough": "Solicitado por meio de {name}", "tools.details.invoke.context.answeredBy": "Respondido por {name}", "tools.details.invoke.unavailable.checkingAccess": "Verificando suas permissões de ferramenta.", diff --git a/virtual-server-try-it-manual.mjs b/virtual-server-try-it-manual.mjs new file mode 100644 index 00000000..0096ad50 --- /dev/null +++ b/virtual-server-try-it-manual.mjs @@ -0,0 +1,329 @@ +// Manual UI testing for virtual-server scoped tool testing. +// +// Terminal A: +// VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true npm run dev +// +// Terminal B: +// node virtual-server-try-it-manual.mjs +// BASE_URL=http://localhost:5176 node virtual-server-try-it-manual.mjs # if Vite uses another port +// +// Optional modes: +// PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs +// PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs +// TOOLS=EMPTY node virtual-server-try-it-manual.mjs +// +// Ctrl-C in terminal B to close the headed browser. + +import { chromium } from "@playwright/test"; + +const BASE = process.env.BASE_URL ?? "http://localhost:5173"; +const HEADED = !process.env.HEADLESS; +const PERMISSIONS = + process.env.PERMISSIONS === "NO_EXECUTE" + ? ["servers.read", "servers.use"] + : process.env.PERMISSIONS === "NO_SERVERS_USE" + ? ["servers.read", "tools.execute"] + : ["*"]; +const PERMISSIONS_MODE = process.env.PERMISSIONS ?? "full access"; + +const SERVER_ID = "76c7b637dafc4d7197f14817ddffeda9"; // pragma: allowlist secret + +const USER = { + email: "test@example.com", + full_name: "Test User", + is_admin: true, + is_active: true, + auth_provider: "local", + email_verified: true, + password_change_required: false, +}; + +const VIRTUAL_SERVER = { + id: SERVER_ID, + name: "testVS", + description: "Virtual server endpoint: developer tooling server exposing repository workflows.", + icon: "", + createdAt: "2026-04-28T15:41:31.233166", + updatedAt: "2026-04-28T15:41:31.233168", + enabled: true, + associatedTools: ["Get Repo Issues", "Create New Issue"], + associatedToolIds: ["GITHUB_GET_REPO_ISSUES", "GITHUB_CREATE_ISSUE"], + associatedResources: ["github://repo/{owner}/{repo}"], + associatedPrompts: ["summarize_pull_request"], + associatedA2aAgents: [], + metrics: null, + tags: [{ id: "tag-development", label: "development" }], + createdBy: "admin@example.com", + createdFromIp: "127.0.0.1", + createdVia: "ui", + createdUserAgent: "Mozilla/5.0", + modifiedBy: null, + modifiedFromIp: null, + modifiedVia: null, + modifiedUserAgent: null, + importBatchId: null, + federationSource: null, + version: 1, + teamId: "0a9b06bd22974fe386dcacb18548ed61", // pragma: allowlist secret + team: "Platform Administrator's Team", + ownerEmail: "admin@example.com", + visibility: "public", + oauthEnabled: false, + oauthConfig: null, +}; + +const MCP_SERVER = { + id: "mcp-gateway-1", + name: "github-mcp", + url: "http://localhost:9000", + transport: "SSE", + enabled: true, + reachable: true, + visibility: "public", + tool_count: 1, + resource_count: 1, + prompt_count: 1, + created_at: "2026-04-28T15:41:31.233166", + updated_at: "2026-04-28T15:41:31.233168", +}; + +function makeTool(overrides = {}) { + return { + id: "tool-search", + name: "github.search_issues", + originalName: "search_issues", + description: "Search repository issues through the selected virtual server.", + originalDescription: "Search repository issues through the selected virtual server.", + 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", description: "Search query" }, + limit: { type: "integer", description: "Maximum results" }, + }, + }, + outputSchema: { type: "object" }, + ...overrides, + }; +} + +const TOOLS = process.env.TOOLS === "EMPTY" ? [] : [makeTool()]; + +function json(body, status = 200) { + return { + status, + contentType: "application/json", + body: JSON.stringify(body), + }; +} + +function fallbackApiBody(pathname) { + if (pathname.startsWith("/api/v1/resources")) return { resources: [] }; + if (pathname.startsWith("/api/v1/prompts")) return { prompts: [] }; + if (pathname.startsWith("/api/v1/tools")) return { tools: [] }; + if (pathname.startsWith("/api/v1/mcp-servers")) return { gateways: [], nextCursor: null }; + if (pathname.startsWith("/api/v1/virtual-servers")) return { servers: [] }; + return {}; +} + +function interestingHeaders(headers) { + return Object.fromEntries( + Object.entries(headers).filter(([name]) => + ["x-csrf-token", "x-tenant-id", "x-api-key", "authorization"].includes(name.toLowerCase()), + ), + ); +} + +function toolResult(text, extra = {}) { + return { + target: { kind: "federated", gateway_name: "github-mcp" }, + content: [{ type: "text", text, mimeType: "text/plain" }], + structured_output: extra, + }; +} + +const browser = await chromium.launch({ headless: !HEADED }); +const context = await browser.newContext({ viewport: { width: 1512, height: 950 } }); +const page = await context.newPage(); + +page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + console.log(`browser ${message.type()}: ${message.text()}`); + } +}); +page.on("pageerror", (error) => { + console.log(`browser pageerror: ${error.message}`); +}); + +await page.route("**/*", (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname.startsWith("/api/")) return route.fulfill(json(fallbackApiBody(pathname))); + return route.fallback(); +}); + +await page.route("**/auth/session", (route) => + route.fulfill( + json({ + authenticated: true, + user: USER, + csrfToken: "mock-csrf-token", + }), + ), +); + +await page.route("**/api/rbac/my/permissions**", (route) => route.fulfill(json(PERMISSIONS))); +await page.route("**/api/v1/virtual-servers?*", (route) => + route.fulfill(json({ servers: [VIRTUAL_SERVER] })), +); +await page.route(`**/api/v1/virtual-servers/${SERVER_ID}`, (route) => + route.fulfill(json(VIRTUAL_SERVER)), +); +await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/tools?*`, (route) => + route.fulfill(json({ tools: TOOLS })), +); +await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/resources?*`, (route) => + route.fulfill(json({ resources: [] })), +); +await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/prompts?*`, (route) => + route.fulfill(json({ prompts: [] })), +); +await page.route("**/api/v1/mcp-servers?*", (route) => + route.fulfill(json({ gateways: [MCP_SERVER], nextCursor: null })), +); + +await page.route("**/api/v1/tools/preview/github.search_issues", async (route) => { + const request = route.request(); + const body = request.postDataJSON(); + const headers = request.headers(); + + console.log("\n/api/v1/tools/preview/github.search_issues request body:"); + console.log(JSON.stringify(body, null, 2)); + console.log("preview interesting headers:"); + console.log(JSON.stringify(interestingHeaders(headers), null, 2)); + + return route.fulfill( + json({ + target: { kind: "federated", gateway_name: "github-mcp" }, + resolved_arguments: body?.arguments ?? {}, + annotations: { readOnlyHint: true }, + pre_hooks_run: [], + warnings: [], + }), + ); +}); + +await page.route("**/api/rpc", async (route) => { + const request = route.request(); + const body = request.postDataJSON(); + const headers = request.headers(); + + console.log("\n/api/rpc request body:"); + console.log(JSON.stringify(body, null, 2)); + console.log("/api/rpc interesting headers:"); + console.log(JSON.stringify(interestingHeaders(headers), null, 2)); + + return route.fulfill( + json({ + jsonrpc: "2.0", + id: body.id, + result: toolResult(`Scoped result for ${body?.params?.name}`, { + receivedArguments: body?.params?.arguments ?? {}, + serverId: body?.params?.server_id ?? null, + tenantHeader: headers["x-tenant-id"] ?? null, + }), + }), + ); +}); + +await page.addInitScript(() => { + sessionStorage.setItem("mcpgateway_token", "placeholder-token"); +}); + +await page.goto(`${BASE}/app/gateways`, { waitUntil: "networkidle" }); + +const cardCount = await page.getByRole("button", { name: "Actions for testVS" }).count(); +console.log(`virtual server actions: ${cardCount ? "ok" : "MISSING"}`); +console.log(`app URL: ${BASE}/app/gateways`); +console.log(`permissions mode: ${PERMISSIONS_MODE}`); +console.log(`tools mode: ${process.env.TOOLS ?? "attached tool"}`); + +if (!HEADED) { + await browser.close(); +} else { + console.log(` +Browser open. Try: + + 1. Open "Actions for testVS" -> "View details". + Expect the details drawer to open with the handshake "Try it" tab selected. + + 2. Click "Components", then open "Actions for Search issues" -> "Test". + The "Test" menu item is text-only. Expect "Test tool", a "Search issues" + selected-tool chip, and "Clear" instead of a back button. Preview mode is selected. + Under "Live invocation", expect "Writes, external requests, and quota use happen immediately." + + 3. Fill query="cloudflare" and limit="5". + Add header X-Tenant-Id=team-a. + Click "Preview". + Expect "Preview 200". + + 4. Terminal should show /api/v1/tools/preview/github.search_issues with: + server_id "${SERVER_ID}" + arguments query="cloudflare", limit=5 + x-tenant-id "team-a" + + 5. Enable "Live invocation". + Expect "Live invocation is enabled. Review your arguments carefully or switch back to preview mode." + Click "Live invoke". + Expect "Live invoke 200", "Requested through testVS", + "Answered by github-mcp", and "Scoped result for github.search_issues". + + 6. Terminal should show /api/rpc with: + method "tools/call" + params.name "github.search_issues" + params.server_id "${SERVER_ID}" + params.arguments query="cloudflare", limit=5 + x-tenant-id "team-a" + + 7. Click "Clear". Expect the Components list again, with keyboard focus on + "Actions for Search issues". + + 8. Optional RBAC denial (restart this script): + BASE_URL=${BASE} PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs + Expect Preview to remain available and the Live invocation switch to be disabled with + "Live invoke requires tools.execute." + + 9. Optional servers.use denial (restart this script): + BASE_URL=${BASE} PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs + Expect Preview to remain available and the Live invocation switch to be disabled with + "Live invoke requires servers.use." + + 10. Optional empty fetched tools (restart this script): + BASE_URL=${BASE} TOOLS=EMPTY node virtual-server-try-it-manual.mjs + Expect fallback component rows to remain visible without a "Test" action. + +Ctrl-C to close. +`); + await new Promise(() => {}); +} From c617309b2c7fd8e955c6e0ab130cb83bea1bb87e Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 18 Sep 2026 15:25:57 +0100 Subject: [PATCH 5/8] Keep manual mock script in PR description only Signed-off-by: Pratik Gandhi --- virtual-server-try-it-manual.mjs | 329 ------------------------------- 1 file changed, 329 deletions(-) delete mode 100644 virtual-server-try-it-manual.mjs diff --git a/virtual-server-try-it-manual.mjs b/virtual-server-try-it-manual.mjs deleted file mode 100644 index 0096ad50..00000000 --- a/virtual-server-try-it-manual.mjs +++ /dev/null @@ -1,329 +0,0 @@ -// Manual UI testing for virtual-server scoped tool testing. -// -// Terminal A: -// VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true npm run dev -// -// Terminal B: -// node virtual-server-try-it-manual.mjs -// BASE_URL=http://localhost:5176 node virtual-server-try-it-manual.mjs # if Vite uses another port -// -// Optional modes: -// PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs -// PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs -// TOOLS=EMPTY node virtual-server-try-it-manual.mjs -// -// Ctrl-C in terminal B to close the headed browser. - -import { chromium } from "@playwright/test"; - -const BASE = process.env.BASE_URL ?? "http://localhost:5173"; -const HEADED = !process.env.HEADLESS; -const PERMISSIONS = - process.env.PERMISSIONS === "NO_EXECUTE" - ? ["servers.read", "servers.use"] - : process.env.PERMISSIONS === "NO_SERVERS_USE" - ? ["servers.read", "tools.execute"] - : ["*"]; -const PERMISSIONS_MODE = process.env.PERMISSIONS ?? "full access"; - -const SERVER_ID = "76c7b637dafc4d7197f14817ddffeda9"; // pragma: allowlist secret - -const USER = { - email: "test@example.com", - full_name: "Test User", - is_admin: true, - is_active: true, - auth_provider: "local", - email_verified: true, - password_change_required: false, -}; - -const VIRTUAL_SERVER = { - id: SERVER_ID, - name: "testVS", - description: "Virtual server endpoint: developer tooling server exposing repository workflows.", - icon: "", - createdAt: "2026-04-28T15:41:31.233166", - updatedAt: "2026-04-28T15:41:31.233168", - enabled: true, - associatedTools: ["Get Repo Issues", "Create New Issue"], - associatedToolIds: ["GITHUB_GET_REPO_ISSUES", "GITHUB_CREATE_ISSUE"], - associatedResources: ["github://repo/{owner}/{repo}"], - associatedPrompts: ["summarize_pull_request"], - associatedA2aAgents: [], - metrics: null, - tags: [{ id: "tag-development", label: "development" }], - createdBy: "admin@example.com", - createdFromIp: "127.0.0.1", - createdVia: "ui", - createdUserAgent: "Mozilla/5.0", - modifiedBy: null, - modifiedFromIp: null, - modifiedVia: null, - modifiedUserAgent: null, - importBatchId: null, - federationSource: null, - version: 1, - teamId: "0a9b06bd22974fe386dcacb18548ed61", // pragma: allowlist secret - team: "Platform Administrator's Team", - ownerEmail: "admin@example.com", - visibility: "public", - oauthEnabled: false, - oauthConfig: null, -}; - -const MCP_SERVER = { - id: "mcp-gateway-1", - name: "github-mcp", - url: "http://localhost:9000", - transport: "SSE", - enabled: true, - reachable: true, - visibility: "public", - tool_count: 1, - resource_count: 1, - prompt_count: 1, - created_at: "2026-04-28T15:41:31.233166", - updated_at: "2026-04-28T15:41:31.233168", -}; - -function makeTool(overrides = {}) { - return { - id: "tool-search", - name: "github.search_issues", - originalName: "search_issues", - description: "Search repository issues through the selected virtual server.", - originalDescription: "Search repository issues through the selected virtual server.", - 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", description: "Search query" }, - limit: { type: "integer", description: "Maximum results" }, - }, - }, - outputSchema: { type: "object" }, - ...overrides, - }; -} - -const TOOLS = process.env.TOOLS === "EMPTY" ? [] : [makeTool()]; - -function json(body, status = 200) { - return { - status, - contentType: "application/json", - body: JSON.stringify(body), - }; -} - -function fallbackApiBody(pathname) { - if (pathname.startsWith("/api/v1/resources")) return { resources: [] }; - if (pathname.startsWith("/api/v1/prompts")) return { prompts: [] }; - if (pathname.startsWith("/api/v1/tools")) return { tools: [] }; - if (pathname.startsWith("/api/v1/mcp-servers")) return { gateways: [], nextCursor: null }; - if (pathname.startsWith("/api/v1/virtual-servers")) return { servers: [] }; - return {}; -} - -function interestingHeaders(headers) { - return Object.fromEntries( - Object.entries(headers).filter(([name]) => - ["x-csrf-token", "x-tenant-id", "x-api-key", "authorization"].includes(name.toLowerCase()), - ), - ); -} - -function toolResult(text, extra = {}) { - return { - target: { kind: "federated", gateway_name: "github-mcp" }, - content: [{ type: "text", text, mimeType: "text/plain" }], - structured_output: extra, - }; -} - -const browser = await chromium.launch({ headless: !HEADED }); -const context = await browser.newContext({ viewport: { width: 1512, height: 950 } }); -const page = await context.newPage(); - -page.on("console", (message) => { - if (["error", "warning"].includes(message.type())) { - console.log(`browser ${message.type()}: ${message.text()}`); - } -}); -page.on("pageerror", (error) => { - console.log(`browser pageerror: ${error.message}`); -}); - -await page.route("**/*", (route) => { - const pathname = new URL(route.request().url()).pathname; - if (pathname.startsWith("/api/")) return route.fulfill(json(fallbackApiBody(pathname))); - return route.fallback(); -}); - -await page.route("**/auth/session", (route) => - route.fulfill( - json({ - authenticated: true, - user: USER, - csrfToken: "mock-csrf-token", - }), - ), -); - -await page.route("**/api/rbac/my/permissions**", (route) => route.fulfill(json(PERMISSIONS))); -await page.route("**/api/v1/virtual-servers?*", (route) => - route.fulfill(json({ servers: [VIRTUAL_SERVER] })), -); -await page.route(`**/api/v1/virtual-servers/${SERVER_ID}`, (route) => - route.fulfill(json(VIRTUAL_SERVER)), -); -await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/tools?*`, (route) => - route.fulfill(json({ tools: TOOLS })), -); -await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/resources?*`, (route) => - route.fulfill(json({ resources: [] })), -); -await page.route(`**/api/v1/virtual-servers/${SERVER_ID}/prompts?*`, (route) => - route.fulfill(json({ prompts: [] })), -); -await page.route("**/api/v1/mcp-servers?*", (route) => - route.fulfill(json({ gateways: [MCP_SERVER], nextCursor: null })), -); - -await page.route("**/api/v1/tools/preview/github.search_issues", async (route) => { - const request = route.request(); - const body = request.postDataJSON(); - const headers = request.headers(); - - console.log("\n/api/v1/tools/preview/github.search_issues request body:"); - console.log(JSON.stringify(body, null, 2)); - console.log("preview interesting headers:"); - console.log(JSON.stringify(interestingHeaders(headers), null, 2)); - - return route.fulfill( - json({ - target: { kind: "federated", gateway_name: "github-mcp" }, - resolved_arguments: body?.arguments ?? {}, - annotations: { readOnlyHint: true }, - pre_hooks_run: [], - warnings: [], - }), - ); -}); - -await page.route("**/api/rpc", async (route) => { - const request = route.request(); - const body = request.postDataJSON(); - const headers = request.headers(); - - console.log("\n/api/rpc request body:"); - console.log(JSON.stringify(body, null, 2)); - console.log("/api/rpc interesting headers:"); - console.log(JSON.stringify(interestingHeaders(headers), null, 2)); - - return route.fulfill( - json({ - jsonrpc: "2.0", - id: body.id, - result: toolResult(`Scoped result for ${body?.params?.name}`, { - receivedArguments: body?.params?.arguments ?? {}, - serverId: body?.params?.server_id ?? null, - tenantHeader: headers["x-tenant-id"] ?? null, - }), - }), - ); -}); - -await page.addInitScript(() => { - sessionStorage.setItem("mcpgateway_token", "placeholder-token"); -}); - -await page.goto(`${BASE}/app/gateways`, { waitUntil: "networkidle" }); - -const cardCount = await page.getByRole("button", { name: "Actions for testVS" }).count(); -console.log(`virtual server actions: ${cardCount ? "ok" : "MISSING"}`); -console.log(`app URL: ${BASE}/app/gateways`); -console.log(`permissions mode: ${PERMISSIONS_MODE}`); -console.log(`tools mode: ${process.env.TOOLS ?? "attached tool"}`); - -if (!HEADED) { - await browser.close(); -} else { - console.log(` -Browser open. Try: - - 1. Open "Actions for testVS" -> "View details". - Expect the details drawer to open with the handshake "Try it" tab selected. - - 2. Click "Components", then open "Actions for Search issues" -> "Test". - The "Test" menu item is text-only. Expect "Test tool", a "Search issues" - selected-tool chip, and "Clear" instead of a back button. Preview mode is selected. - Under "Live invocation", expect "Writes, external requests, and quota use happen immediately." - - 3. Fill query="cloudflare" and limit="5". - Add header X-Tenant-Id=team-a. - Click "Preview". - Expect "Preview 200". - - 4. Terminal should show /api/v1/tools/preview/github.search_issues with: - server_id "${SERVER_ID}" - arguments query="cloudflare", limit=5 - x-tenant-id "team-a" - - 5. Enable "Live invocation". - Expect "Live invocation is enabled. Review your arguments carefully or switch back to preview mode." - Click "Live invoke". - Expect "Live invoke 200", "Requested through testVS", - "Answered by github-mcp", and "Scoped result for github.search_issues". - - 6. Terminal should show /api/rpc with: - method "tools/call" - params.name "github.search_issues" - params.server_id "${SERVER_ID}" - params.arguments query="cloudflare", limit=5 - x-tenant-id "team-a" - - 7. Click "Clear". Expect the Components list again, with keyboard focus on - "Actions for Search issues". - - 8. Optional RBAC denial (restart this script): - BASE_URL=${BASE} PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs - Expect Preview to remain available and the Live invocation switch to be disabled with - "Live invoke requires tools.execute." - - 9. Optional servers.use denial (restart this script): - BASE_URL=${BASE} PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs - Expect Preview to remain available and the Live invocation switch to be disabled with - "Live invoke requires servers.use." - - 10. Optional empty fetched tools (restart this script): - BASE_URL=${BASE} TOOLS=EMPTY node virtual-server-try-it-manual.mjs - Expect fallback component rows to remain visible without a "Test" action. - -Ctrl-C to close. -`); - await new Promise(() => {}); -} From 110a20ccba75f4e07a85bf5fb422e5be3619e785 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 18 Sep 2026 15:42:30 +0100 Subject: [PATCH 6/8] Place scoped live switch below heading Signed-off-by: Pratik Gandhi --- e2e/virtual-servers.spec.ts | 14 +++++++ src/components/tools/ToolTryItTab.tsx | 53 ++++++++++++++------------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index 38503a3b..e89ff9c3 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -1356,6 +1356,20 @@ test.describe("Virtual Servers page", () => { 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"); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index 17628dbb..de6f0d35 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -223,36 +223,39 @@ export function ToolTryItTab({ {scopedMode && (
-
-
- -

- {intl.formatMessage({ id: "tools.details.test.liveModeDescription" })} -

- {!liveModeAvailable && ( +
+ +
+ +

- {getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)} + {intl.formatMessage({ id: "tools.details.test.liveModeDescription" })}

- )} + {!liveModeAvailable && ( +

+ {getToolLiveInvokeAvailabilityMessage(liveAvailability, intl.formatMessage)} +

+ )} +
-
{liveMode && (
Date: Sun, 20 Sep 2026 09:51:15 +0100 Subject: [PATCH 7/8] Align virtual server tool test with design Signed-off-by: Pratik Gandhi --- .../VirtualServerDetailsPanel.test.tsx | 4 +- .../tools/ToolLiveInvokeGate.test.tsx | 24 +++++++++++ src/components/tools/ToolLiveInvokeGate.tsx | 28 ++++++++++--- src/components/tools/ToolTryItTab.test.tsx | 11 +++-- src/components/tools/ToolTryItTab.tsx | 40 ++++++++++++++----- src/i18n/locales/en-US/tools.json | 2 + src/i18n/locales/es-ES/tools.json | 2 + src/i18n/locales/pt-BR/tools.json | 2 + 8 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index 59d1fb0d..76de67e5 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -394,7 +394,7 @@ describe("VirtualServerDetailsPanel tool testing", () => { expect(screen.queryByText("Search repository issues")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Live invocation" })).not.toBeChecked(); - expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Invoke tool" })).not.toBeInTheDocument(); expect( document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'), ).toHaveTextContent('"server_id":"virtual-server-1"'); @@ -443,7 +443,7 @@ describe("VirtualServerDetailsPanel tool testing", () => { await openToolTest(user, "Search issues"); await user.type(screen.getByLabelText(/query/i), "cloudflare"); await user.click(screen.getByRole("switch", { name: "Live invocation" })); - await user.click(screen.getByRole("button", { name: "Live invoke" })); + await user.click(screen.getByRole("button", { name: "Invoke tool" })); await waitFor(() => expect(invokeSpy).toHaveBeenCalledOnce()); expect(signal?.aborted).toBe(false); diff --git a/src/components/tools/ToolLiveInvokeGate.test.tsx b/src/components/tools/ToolLiveInvokeGate.test.tsx index 6ac50926..b83d23b6 100644 --- a/src/components/tools/ToolLiveInvokeGate.test.tsx +++ b/src/components/tools/ToolLiveInvokeGate.test.tsx @@ -195,6 +195,30 @@ describe("ToolLiveInvokeGate", () => { expect(mockHasPermission).toHaveBeenCalledWith("servers.use"); }); + it("uses the tool action presentation when requested", async () => { + const user = userEvent.setup(); + const invoke = makeInvoke(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Invoke tool" })); + expect(invoke.run).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "Re-run tool" })).toBeInTheDocument(); + }); + it("confirms local destructive tools before running", async () => { const user = userEvent.setup(); const invoke = makeInvoke(); diff --git a/src/components/tools/ToolLiveInvokeGate.tsx b/src/components/tools/ToolLiveInvokeGate.tsx index de2e5bf3..6f06a477 100644 --- a/src/components/tools/ToolLiveInvokeGate.tsx +++ b/src/components/tools/ToolLiveInvokeGate.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Loader2, Play, Square } from "lucide-react"; +import { Loader2, Play, Square, Zap } from "lucide-react"; import { useIntl } from "react-intl"; import { useAuth } from "@/auth/useAuth"; @@ -59,6 +59,7 @@ export interface ToolLiveInvokeGateProps { disabled?: boolean; invalidGatewayId?: boolean; invoke: Pick; + presentation?: "live" | "tool"; tool: Tool; } @@ -66,6 +67,7 @@ export function ToolLiveInvokeGate({ disabled = false, invalidGatewayId = false, invoke, + presentation = "live", tool, }: ToolLiveInvokeGateProps) { const intl = useIntl(); @@ -99,17 +101,26 @@ export function ToolLiveInvokeGate({ } if (availability.state === "available") { + const ActionIcon = presentation === "tool" ? Zap : Play; return ( ); } if (availability.state === "requiresConfirmation") { + const ActionIcon = presentation === "tool" ? Zap : Play; return ( <> { screen.getByRole("button", { name: "Clear selected tool and return to components" }), ).toHaveTextContent("Clear"); expect(screen.getByText("Live invocation")).toHaveAttribute("data-slot", "label"); + expect( + screen.getByRole("button", { + name: "Writes, external requests, and quota use happen immediately.", + }), + ).toBeInTheDocument(); expect( screen.getByText("Writes, external requests, and quota use happen immediately."), ).toBeInTheDocument(); @@ -173,7 +178,7 @@ describe("ToolTryItTab", () => { "Live invocation is enabled. Review your arguments carefully or switch back to preview mode.", ), ).toBeVisible(); - expect(screen.getByRole("button", { name: "Live invoke" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Invoke tool" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Preview" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON-RPC" })).toBeInTheDocument(); expect(activeCode()).toContain("$MCPGATEWAY_URL/rpc"); @@ -184,7 +189,7 @@ describe("ToolTryItTab", () => { await user.click(screen.getByRole("switch", { name: "Live invocation" })); expect(screen.queryByText(/Live invocation is enabled/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Invoke tool" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "JSON" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "curl" })).toHaveAttribute("data-state", "active"); expect(activeCode()).toContain("/v1/tools/preview/github.search_issues"); @@ -247,7 +252,7 @@ describe("ToolTryItTab", () => { await user.click(screen.getByRole("switch", { name: "Live invocation" })); expect(screen.queryByText("Preview 200")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Live invoke" })); + await user.click(screen.getByRole("button", { name: "Invoke tool" })); expect(await screen.findByText("Live invoke 200")).toBeInTheDocument(); await user.click(screen.getByRole("switch", { name: "Live invocation" })); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index de6f0d35..e6dbeffe 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { ComponentProps, Ref } from "react"; import { useIntl } from "react-intl"; -import { TriangleAlert, Wrench } from "lucide-react"; +import { Info, TriangleAlert, Wrench } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { Button } from "@/components/ui/button"; @@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label"; import { CodeBlock } from "@/components/ui/code-block"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { Tool } from "@/types/tool"; import { useToolInvoke } from "@/hooks/useToolInvoke"; @@ -156,7 +157,7 @@ export function ToolTryItTab({ )}