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..e89ff9c3 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,108 @@ 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(); + const testAction = page.getByRole("menuitem", { name: "Test" }); + await expect(testAction.locator("svg")).toHaveCount(0); + await testAction.click(); + await expect(panel.getByRole("heading", { name: "Test tool" })).toBeFocused(); + return panel; +} + test.describe("Virtual Servers page", () => { test.beforeEach(async ({ page, apiMock }) => { // Mock authentication @@ -1176,6 +1292,178 @@ 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 expect( + panel.getByText("Writes, external requests, and quota use happen immediately."), + ).toBeVisible(); + await expect(panel.getByText(/Live invocation is enabled/)).toHaveCount(0); + const liveLabelBounds = await panel + .getByText("Live invocation", { exact: true }) + .boundingBox(); + const liveSwitchBounds = await panel + .getByRole("switch", { name: "Live invocation" }) + .boundingBox(); + const liveCopyBounds = await panel + .getByText("Writes, external requests, and quota use happen immediately.") + .boundingBox(); + expect(liveLabelBounds).not.toBeNull(); + expect(liveSwitchBounds).not.toBeNull(); + expect(liveCopyBounds).not.toBeNull(); + expect(liveSwitchBounds!.y).toBeGreaterThan(liveLabelBounds!.y + liveLabelBounds!.height); + expect(liveCopyBounds!.x).toBeGreaterThan(liveSwitchBounds!.x + liveSwitchBounds!.width); + + await panel.getByLabel("query").fill("cloudflare"); + await panel.getByLabel("limit").fill("5"); + await panel.getByRole("button", { name: "Add header" }).click(); + await panel.getByLabel("Header 1 name").fill("X-Tenant-Id"); + await panel.getByLabel("Header 1 value").fill("team-a"); + await panel.getByRole("button", { name: "Preview" }).click(); + + await expect(panel.getByText("Preview 200")).toBeVisible(); + expect(previewBody).toEqual({ + arguments: { query: "cloudflare", limit: 5 }, + server_id: MOCK_VIRTUAL_SERVER.id, + }); + expect(previewHeaders["x-tenant-id"]).toBe("team-a"); + + await panel.getByRole("switch", { name: "Live invocation" }).click(); + await expect(panel.getByText(/Live invocation is enabled/)).toBeVisible(); + await expect(panel.getByRole("button", { name: "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"); + + await panel + .getByRole("button", { name: "Clear selected tool and return to components" }) + .click(); + await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused(); + }); + + test("blocks live invoke without tools.execute", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["servers.read", "servers.use"] }); + await routeVirtualServerTryIt(page, [makeTryItTool()]); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + const panel = await openVirtualServerToolTest(page); + + await expect(panel.getByText("Live invoke requires tools.execute.")).toBeVisible(); + await expect(panel.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + await panel.getByLabel("query").fill("cloudflare"); + await expect(panel.getByRole("button", { name: "Preview" })).toBeEnabled(); + }); + + test("restores focus after keyboard navigation through tool testing", async ({ page }) => { + await routeVirtualServerTryIt(page, [makeTryItTool()]); + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "Actions for testVS" }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("menuitem", { name: "View details" }).press("Enter"); + + const panel = page.getByRole("region", { name: "testVS details" }); + await panel.getByRole("tab", { name: "Components" }).focus(); + await page.keyboard.press("Enter"); + await panel.getByRole("button", { name: "Actions for Search issues" }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("menuitem", { name: "Test" }).press("Enter"); + await expect(panel.getByRole("heading", { name: "Test tool" })).toBeFocused(); + + await panel + .getByRole("button", { name: "Clear selected tool and return to components" }) + .focus(); + await page.keyboard.press("Enter"); + await expect(panel.getByRole("button", { name: "Actions for Search issues" })).toBeFocused(); + }); + + test("blocks live invoke without servers.use", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["servers.read", "tools.execute"] }); + await routeVirtualServerTryIt(page, [makeTryItTool()]); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + const panel = await openVirtualServerToolTest(page); + + await expect(panel.getByText("Live invoke requires servers.use.")).toBeVisible(); + await expect(panel.getByRole("switch", { name: "Live invocation" })).toBeDisabled(); + await panel.getByLabel("query").fill("cloudflare"); + await expect(panel.getByRole("button", { name: "Preview" })).toBeEnabled(); + }); + }); + test("shows only the actions menu in the virtual server card header", async ({ page }) => { await page.route("**/v1/virtual-servers?*", async (route) => { await route.fulfill({ 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..14da9256 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,25 @@ 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"; +import { toolsApi } from "@/api/tools"; 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 +62,58 @@ 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(); + vi.restoreAllMocks(); + localStorage.removeItem("user-locale"); +}); + describe("VirtualServerDetailsPanel inline tag add", () => { it("calls onAddTag with the merged, de-duplicated tag list", async () => { const user = userEvent.setup(); @@ -255,6 +322,276 @@ 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}` })); + 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 () => { + 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"); + + 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: "Invoke tool" })).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: "Clear selected tool and return to components" }), + ); + expect(await screen.findByText("Search issues")).toBeInTheDocument(); + expect(screen.queryByText("Test tool")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Actions for Search issues" })).toHaveFocus(), + ); + }); + + 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: "Invoke tool" })); + 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("Test tool")).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"); + 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", "false"); + expect(screen.queryByText("Required")).not.toBeInTheDocument(); + 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("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( + "Writes, external requests, and quota use happen immediately. 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"); + 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.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( + `Writes, external requests, and quota use happen immediately. ${message}`, + ); + expect(screen.getByText(message)).toBeInTheDocument(); + }); +}); + describe("VirtualServerDetailsPanel render variants", () => { beforeEach(() => { mswServer.use( @@ -264,6 +601,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( 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(""); @@ -163,7 +182,13 @@ 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(); const getComponentLabel = useCallback( (type: Exclude) => @@ -235,7 +260,7 @@ export function VirtualServerDetailsPanel({ data: toolsData, isLoading: toolsLoading, error: toolsError, - } = useQuery<{ tools: Tool[] }>(toolsPath, { + } = useQuery<{ tools: VirtualServerTool[] } | VirtualServerTool[]>(toolsPath, { enabled: fetchEnabled, }); @@ -255,17 +280,22 @@ export function VirtualServerDetailsPanel({ enabled: fetchEnabled, }); + const fetchedTools = useMemo(() => getPanelTools(toolsData), [toolsData]); + const fetchedToolsById = useMemo( + () => new Map(fetchedTools.map((tool) => [tool.id, tool])), + [fetchedTools], + ); + 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 +397,57 @@ export function VirtualServerDetailsPanel({ }, [sourceIds, sourcesData]); const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; + const selectedTestTool = useMemo( + () => (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(""); setIsSearchExpanded(false); }, [open, server?.id]); + useEffect(() => { + if (!open) setSelectedTestToolId(null); + }, [open]); + + useEffect(() => { + if (!selectedTestToolId) return; + if (!virtualServerToolTryItEnabled || !selectedTestTool) { + restoreToolActionsFocusRef.current = open && topTab === "components"; + setSelectedTestToolId(null); + } + }, [open, selectedTestTool, selectedTestToolId, topTab, virtualServerToolTryItEnabled]); + useEffect(() => { if (sourceFilter === "all") return; if (!sourceIds.includes(sourceFilter)) { @@ -502,7 +572,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 +598,280 @@ 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 = ( + {open && virtualServerToolTryItEnabled && selectedTestTool ? ( + { + restoreToolActionsFocusRef.current = true; + 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" + ? fetchedToolsById.get(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 +976,32 @@ export function VirtualServerDetailsPanel({ ); } + +function VirtualServerToolTestView({ + server, + tool, + headingRef, + onBack, +}: { + server: VirtualServer; + tool: VirtualServerTool; + headingRef: Ref; + onBack: () => void; +}) { + return ( +
+ +
+ ); +} diff --git a/src/components/gateways/normalizeVirtualServerTool.test.ts b/src/components/gateways/normalizeVirtualServerTool.test.ts new file mode 100644 index 00000000..5be7e9c8 --- /dev/null +++ b/src/components/gateways/normalizeVirtualServerTool.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeVirtualServerTool, type VirtualServerTool } from "./normalizeVirtualServerTool"; + +describe("normalizeVirtualServerTool", () => { + 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); + }); + + 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 new file mode 100644 index 00000000..afe4e8b5 --- /dev/null +++ b/src/components/gateways/normalizeVirtualServerTool.ts @@ -0,0 +1,37 @@ +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: gatewayId ?? null, + invalidGatewayId, + 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/ToolArgumentsForm.test.tsx b/src/components/tools/ToolArgumentsForm.test.tsx index 77ac94e0..6c639407 100644 --- a/src/components/tools/ToolArgumentsForm.test.tsx +++ b/src/components/tools/ToolArgumentsForm.test.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/test/test-utils"; @@ -34,10 +34,12 @@ function FormHarness({ schema = SCHEMA, onValidityChange = vi.fn(), onArgsChange = vi.fn(), + validationAttempted = false, }: { schema?: Record; onValidityChange?: (valid: boolean) => void; onArgsChange?: (value: Record) => void; + validationAttempted?: boolean; }) { const [value, setValue] = useState(() => seedToolArguments(schema)); return ( @@ -49,6 +51,7 @@ function FormHarness({ onArgsChange(next); }} onValidityChange={onValidityChange} + validationAttempted={validationAttempted} /> ); } @@ -75,6 +78,46 @@ describe("ToolArgumentsForm", () => { }); }); + it("keeps required fields neutral until they are touched", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render(); + + const query = screen.getByLabelText(/query/i); + expect(query).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByText("Required")).not.toBeInTheDocument(); + await waitFor(() => expect(onValidityChange).toHaveBeenLastCalledWith(false)); + + await user.click(query); + await user.tab(); + expect(query).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByText("Required")).toBeInTheDocument(); + + await user.type(query, "cloudflare"); + expect(query).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByText("Required")).not.toBeInTheDocument(); + }); + + it("reveals all invalid fields after validation is attempted", () => { + render( + , + ); + + expect(screen.getByLabelText(/query/i)).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByRole("combobox", { name: /mode/i })).toHaveAttribute("aria-invalid", "true"); + expect(screen.getAllByText("Required")).toHaveLength(2); + }); + it("validates integer and number field types", () => { const spec = buildFormSpec({ type: "object", diff --git a/src/components/tools/ToolArgumentsForm.tsx b/src/components/tools/ToolArgumentsForm.tsx index 12a44adb..f19507e6 100644 --- a/src/components/tools/ToolArgumentsForm.tsx +++ b/src/components/tools/ToolArgumentsForm.tsx @@ -20,6 +20,7 @@ export interface ToolArgumentsFormProps { value: Record; onChange: (value: Record) => void; onValidityChange?: (valid: boolean) => void; + validationAttempted?: boolean; } export interface FieldSpec { @@ -132,12 +133,14 @@ export function ToolArgumentsForm({ value, onChange, onValidityChange, + validationAttempted = false, }: ToolArgumentsFormProps) { const intl = useIntl(); const spec = useMemo(() => buildFormSpec(schema), [schema]); const [rawJson, setRawJson] = useState(() => JSON.stringify(value, null, 2)); const [rawError, setRawError] = useState(null); const [arrayDrafts, setArrayDrafts] = useState>({}); + const [touchedFields, setTouchedFields] = useState>(() => new Set()); const errors = useMemo( () => (spec.complex ? {} : validateToolArguments(value, spec.fields)), [spec, value], @@ -225,7 +228,7 @@ export function ToolArgumentsForm({
{spec.fields.map((field) => { const key = field.path.join("."); - const error = errors[key]; + const error = validationAttempted || touchedFields.has(key) ? errors[key] : undefined; return (
updateField(field, next)} + onTouched={() => + setTouchedFields((current) => { + if (current.has(key)) return current; + const next = new Set(current); + next.add(key); + return next; + }) + } onArrayTextChange={ field.type === "array" ? (next) => updateArrayField(field, next) : undefined } @@ -255,12 +266,14 @@ function FieldControl({ value, error, onChange, + onTouched, onArrayTextChange, }: { field: FieldSpec; value: unknown; error?: string; onChange: (value: unknown) => void; + onTouched: () => void; onArrayTextChange?: (value: string) => void; }) { const intl = useIntl(); @@ -282,6 +295,7 @@ function FieldControl({ id={id} checked={value === true} onCheckedChange={(checked) => onChange(checked === true)} + onBlur={onTouched} /> {commonLabel}
@@ -294,7 +308,16 @@ function FieldControl({ return ( <> {commonLabel} - { + onTouched(); + onChange(next); + }} + onOpenChange={(open) => { + if (!open) onTouched(); + }} + > ) => field.type === "array" ? onArrayTextChange?.(event.target.value) diff --git a/src/components/tools/ToolLiveInvokeGate.test.tsx b/src/components/tools/ToolLiveInvokeGate.test.tsx index 631d3e64..36a4d2ec 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({ @@ -175,6 +195,58 @@ 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("does not run or confirm when the pre-run check fails", async () => { + const user = userEvent.setup(); + const invoke = makeInvoke(); + const onBeforeRun = vi.fn(() => false); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Live invoke" })); + expect(onBeforeRun).toHaveBeenCalledOnce(); + expect(invoke.run).not.toHaveBeenCalled(); + + rerender( + , + ); + await user.click(screen.getByRole("button", { name: "Live invoke" })); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(invoke.run).not.toHaveBeenCalled(); + }); + 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 d4d83363..7226ec7f 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"; @@ -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,23 +57,38 @@ export function resolveToolLiveInvokeAvailability({ export interface ToolLiveInvokeGateProps { disabled?: boolean; + invalidGatewayId?: boolean; invoke: Pick; + onBeforeRun?: () => boolean; + presentation?: "live" | "tool"; tool: Tool; } -export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveInvokeGateProps) { +export function ToolLiveInvokeGate({ + disabled = false, + invalidGatewayId = false, + invoke, + onBeforeRun, + presentation = "live", + tool, +}: ToolLiveInvokeGateProps) { const intl = useIntl(); const { hasPermission, permissionsLoading } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); + const run = () => { + if (onBeforeRun?.() === false) return; + void invoke.run(); + }; const availability = useMemo( () => resolveToolLiveInvokeAvailability({ canExecute: hasPermission("tools.execute"), canUseServers: hasPermission("servers.use"), permissionsLoading, + invalidGatewayId, tool, }), - [hasPermission, permissionsLoading, tool], + [hasPermission, permissionsLoading, invalidGatewayId, tool], ); if (invoke.isLoading) { @@ -86,29 +107,48 @@ export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveI } if (availability.state === "available") { + const ActionIcon = presentation === "tool" ? Zap : Play; return ( - ); } if (availability.state === "requiresConfirmation") { + const ActionIcon = presentation === "tool" ? Zap : Play; return ( <>

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

); } -function availabilityMessage( +export function getToolLiveInvokeAvailabilityMessage( availability: ToolLiveInvokeAvailability, formatMessage: (descriptor: { id: string }) => string, ) { @@ -159,6 +199,8 @@ function availabilityMessage( }); 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 835be8f0..9d2bcc22 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( @@ -69,6 +70,129 @@ 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("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" }, + { + 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( ; } -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; @@ -35,6 +42,9 @@ export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { 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 @@ -69,6 +79,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 +128,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/ToolPreviewButton.test.tsx b/src/components/tools/ToolPreviewButton.test.tsx index e3eb22f2..31001382 100644 --- a/src/components/tools/ToolPreviewButton.test.tsx +++ b/src/components/tools/ToolPreviewButton.test.tsx @@ -28,6 +28,23 @@ describe("ToolPreviewButton", () => { expect(run).not.toHaveBeenCalled(); }); + it("does not run when the pre-run check fails", async () => { + const user = userEvent.setup(); + const run = vi.fn(); + const onBeforeRun = vi.fn(() => false); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Preview" })); + expect(onBeforeRun).toHaveBeenCalledOnce(); + expect(run).not.toHaveBeenCalled(); + }); + it("renders the re-run state and respects external disablement", () => { render( , diff --git a/src/components/tools/ToolPreviewButton.tsx b/src/components/tools/ToolPreviewButton.tsx index 5be333c9..9c190771 100644 --- a/src/components/tools/ToolPreviewButton.tsx +++ b/src/components/tools/ToolPreviewButton.tsx @@ -7,9 +7,14 @@ import type { ToolPreviewState } from "@/hooks/useToolPreview"; export interface ToolPreviewButtonProps { preview: Pick; disabled?: boolean; + onBeforeRun?: () => boolean; } -export function ToolPreviewButton({ preview, disabled = false }: ToolPreviewButtonProps) { +export function ToolPreviewButton({ + preview, + disabled = false, + onBeforeRun, +}: ToolPreviewButtonProps) { const intl = useIntl(); const { run, isLoading, hasRun } = preview; @@ -18,7 +23,10 @@ export function ToolPreviewButton({ preview, disabled = false }: ToolPreviewButt type="button" variant="outline" size="sm" - onClick={run} + onClick={() => { + if (onBeforeRun?.() === false) return; + void run(); + }} disabled={disabled || isLoading} > {isLoading ? ( diff --git a/src/components/tools/ToolTryItTab.test.tsx b/src/components/tools/ToolTryItTab.test.tsx index 6bc188f2..a54c21a5 100644 --- a/src/components/tools/ToolTryItTab.test.tsx +++ b/src/components/tools/ToolTryItTab.test.tsx @@ -1,19 +1,27 @@ -import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; import { renderWithProviders as render } from "@/test/test-utils"; +import { server as mswServer } from "@/test/mocks/server"; import type { Tool } from "@/types/tool"; import { ToolTryItTab } from "./ToolTryItTab"; +const authMock = vi.hoisted(() => ({ 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 ?? ""; @@ -78,6 +86,10 @@ describe("ToolTryItTab", () => { 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(); @@ -118,4 +130,251 @@ 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 onClear = vi.fn(); + const selectedTool = makeTool({ + name: "github.search_issues", + displayName: "Search issues", + annotations: { readOnlyHint: true }, + }); + + render( + , + ); + + 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.getByRole("button", { + name: "Writes, external requests, and quota use happen immediately.", + }), + ).toBeInTheDocument(); + 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(); + 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.getByText( + "Live invocation is enabled. Review your arguments carefully or switch back to preview mode.", + ), + ).toBeVisible(); + 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"); + 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.queryByText(/Live invocation is enabled/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Preview" })).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"); + 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", () => { + authMock.permissionsLoading = true; + const selectedTool = makeTool({ annotations: { readOnlyHint: true } }); + + render( + , + ); + + const liveSwitch = screen.getByRole("switch", { name: "Live invocation" }); + expect(liveSwitch).toBeDisabled(); + expect(liveSwitch).toHaveAccessibleDescription( + "Writes, external requests, and quota use happen immediately. Checking your tool permissions.", + ); + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); + }); + + it("reveals argument errors on preview attempt without sending an invalid request", 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: {} }); + }), + ); + + render( + , + ); + + const query = screen.getByLabelText(/query/i); + const previewButton = screen.getByRole("button", { name: "Preview" }); + expect(query).toHaveAttribute("aria-invalid", "false"); + expect(previewButton).toBeEnabled(); + + await user.click(previewButton); + expect(query).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByText("Required")).toBeInTheDocument(); + expect(previewCalls).toBe(0); + + await user.type(query, "cloudflare"); + expect(query).toHaveAttribute("aria-invalid", "false"); + await user.click(previewButton); + await waitFor(() => expect(previewCalls).toBe(1)); + }); + + it("reveals argument errors on live invoke without sending an invalid request", async () => { + const user = userEvent.setup(); + const selectedTool = makeTool({ annotations: { readOnlyHint: true } }); + let invokeCalls = 0; + mswServer.use( + http.post("*/rpc", async ({ request }) => { + invokeCalls += 1; + const envelope = (await request.json()) as { id: string }; + return HttpResponse.json({ jsonrpc: "2.0", id: envelope.id, result: { content: [] } }); + }), + ); + + render( + , + ); + + await user.click(screen.getByRole("switch", { name: "Live invocation" })); + const query = screen.getByLabelText(/query/i); + const invokeButton = screen.getByRole("button", { name: "Invoke tool" }); + expect(invokeButton).toBeEnabled(); + + await user.click(invokeButton); + expect(query).toHaveAttribute("aria-invalid", "true"); + expect(invokeCalls).toBe(0); + + await user.type(query, "cloudflare"); + await user.click(invokeButton); + await waitFor(() => expect(invokeCalls).toBe(1)); + }); + + it("resets visible argument validation when switching tools", async () => { + const user = userEvent.setup(); + const firstTool = makeTool({ id: "tool-one" }); + const secondTool = makeTool({ id: "tool-two", name: "list_issues" }); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Preview" })); + expect(screen.getByLabelText(/query/i)).toHaveAttribute("aria-invalid", "true"); + + rerender( + , + ); + + await waitFor(() => + expect(screen.getByLabelText(/query/i)).toHaveAttribute("aria-invalid", "false"), + ); + expect(screen.queryByText("Required")).not.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: "Invoke tool" })); + 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 () => { + 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(); + expect(screen.getByRole("switch", { name: "Live invocation" })).toHaveAccessibleDescription( + "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 6fea71e7..6d816569 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -1,22 +1,33 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { ComponentProps, Ref } from "react"; import { useIntl } from "react-intl"; +import { Info, TriangleAlert, Wrench } from "lucide-react"; +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"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { Tool } from "@/types/tool"; 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,35 +36,80 @@ import { getToolAnnotationHints } from "./toolAnnotations"; const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; export interface ToolTryItTabProps { - tools: Tool[]; + headingRef?: Ref; + invalidGatewayId?: boolean; + onClear?: () => void; + 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({ + headingRef, + invalidGatewayId = false, + onClear, + resultContext, + serverScope, + tools, + selectedTool, + onSelectTool, +}: ToolTryItTabProps) { const intl = useIntl(); + const liveModeDescriptionId = useId(); + const liveModeReasonId = useId(); + const { hasPermission, permissionsLoading } = useAuth(); const [args, setArgs] = useState>(() => seedToolArguments(selectedTool.inputSchema), ); const [headers, setHeaders] = useState([]); const [argsValid, setArgsValid] = useState(true); + const [argsValidationAttempted, setArgsValidationAttempted] = useState(false); 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, + invalidGatewayId, + tool: selectedTool, + }), + [hasPermission, permissionsLoading, invalidGatewayId, 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 selectedToolLabel = + selectedTool.displayName || + selectedTool.title || + selectedTool.originalName || + selectedTool.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(() => { @@ -62,66 +118,182 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab setArgs(seedToolArguments(selectedTool.inputSchema)); setHeaders([]); setArgsValid(true); + setArgsValidationAttempted(false); 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(); + }; + + const validateArgumentsForRun = () => { + setArgsValidationAttempted(true); + return argsValid && headersValid; + }; + return (
-
-
-

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

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

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

+ {onClear && ( + + )} +
+ + +
+ ) : ( +
+
+

+ {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" })} + + )} +
+ + {availableTools.length > 1 && onSelectTool && ( +
+ {availableTools.map((tool) => { + const isSelected = tool.id === selectedTool.id; + return ( + + ); + })} +
)} - {annotationHints.destructiveHint && ( - - {intl.formatMessage({ id: "tools.details.preview.annotation.destructive" })} - + + {selectedTool.description && ( +

+ {selectedTool.description} +

)}
+ )} - {tools.length > 1 && ( -
- {tools.map((tool) => { - const isSelected = tool.id === selectedTool.id; - return ( - + + + {intl.formatMessage({ id: "tools.details.test.liveModeDescription" })} + + +
+
+ +
+

- {tool.name} - - ); - })} + {intl.formatMessage({ id: "tools.details.test.liveModeDescription" })} +

+ {!liveModeAvailable && ( +

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

+ )} +
+
- )} - - {selectedTool.description && ( -

- {selectedTool.description} -

- )} -
+ {liveMode && ( +
+
+ )} +
+ )} @@ -141,7 +314,7 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab
- {TOOL_SNIPPETS.map((spec) => ( + {snippetSpecs.map((spec) => ( {intl.formatMessage({ id: spec.labelId })} @@ -156,12 +329,23 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab
- - + {(!scopedMode || !liveMode) && ( + + )} + {(!scopedMode || liveMode) && ( + + )}
@@ -180,8 +364,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..568a0d70 100644 --- a/src/i18n/locales/en-US/gateways.json +++ b/src/i18n/locales/en-US/gateways.json @@ -118,9 +118,11 @@ "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.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..23337b8c 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", @@ -126,6 +127,8 @@ "tools.details.code.mcpVersionBadge": "MCP {version}", "tools.details.invoke.run": "Live invoke", "tools.details.invoke.rerun": "Re-run live", + "tools.details.invoke.runTool": "Invoke tool", + "tools.details.invoke.rerunTool": "Re-run tool", "tools.details.invoke.running": "Invoking...", "tools.details.invoke.stopWaiting": "Cancel request", "tools.details.invoke.checkingAccess": "Checking access", @@ -137,10 +140,19 @@ "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": "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.", "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/gateways.json b/src/i18n/locales/es-ES/gateways.json index 2afc38f7..af90b022 100644 --- a/src/i18n/locales/es-ES/gateways.json +++ b/src/i18n/locales/es-ES/gateways.json @@ -118,9 +118,11 @@ "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.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..321da812 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", @@ -126,6 +127,8 @@ "tools.details.code.mcpVersionBadge": "MCP {version}", "tools.details.invoke.run": "Invocación en vivo", "tools.details.invoke.rerun": "Volver a invocar", + "tools.details.invoke.runTool": "Invocar herramienta", + "tools.details.invoke.rerunTool": "Volver a invocar la herramienta", "tools.details.invoke.running": "Invocando...", "tools.details.invoke.stopWaiting": "Cancelar solicitud", "tools.details.invoke.checkingAccess": "Comprobando acceso", @@ -137,10 +140,19 @@ "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": "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.", "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/gateways.json b/src/i18n/locales/pt-BR/gateways.json index 1813670b..60a5ee64 100644 --- a/src/i18n/locales/pt-BR/gateways.json +++ b/src/i18n/locales/pt-BR/gateways.json @@ -118,9 +118,11 @@ "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.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..34fe4965 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", @@ -126,6 +127,8 @@ "tools.details.code.mcpVersionBadge": "MCP {version}", "tools.details.invoke.run": "Invocação em tempo real", "tools.details.invoke.rerun": "Executar novamente ao vivo", + "tools.details.invoke.runTool": "Invocar ferramenta", + "tools.details.invoke.rerunTool": "Executar ferramenta novamente", "tools.details.invoke.running": "Invocando...", "tools.details.invoke.stopWaiting": "Cancelar solicitação", "tools.details.invoke.checkingAccess": "Verificando acesso", @@ -137,10 +140,19 @@ "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": "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.", "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.", 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.