From 724ab190c5d88a927c6755063c323775257f6529 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 28 Aug 2026 13:38:04 +0100 Subject: [PATCH] Add virtual server scoped tool Try-it UI Signed-off-by: Pratik Gandhi --- .env.example | 3 + e2e/virtual-servers.spec.ts | 204 ++++++ playwright.config.ts | 6 +- src/api/tools.test.ts | 40 ++ src/api/tools.ts | 4 +- .../VirtualServerDetailsPanel.test.tsx | 211 +++++- .../gateways/VirtualServerDetailsPanel.tsx | 667 ++++++++++++------ .../tools/ToolLiveInvokeResult.test.tsx | 23 + src/components/tools/ToolLiveInvokeResult.tsx | 51 +- src/components/tools/ToolTryItTab.test.tsx | 32 + src/components/tools/ToolTryItTab.tsx | 44 +- .../tools/buildToolSnippets.test.ts | 18 + src/components/tools/buildToolSnippets.ts | 12 +- src/config/features.test.ts | 20 + src/config/features.ts | 4 + src/hooks/useToolInvoke.test.tsx | 35 + src/hooks/useToolInvoke.ts | 14 +- src/hooks/useToolPreview.test.tsx | 15 +- src/hooks/useToolPreview.ts | 14 +- src/i18n/locales/en-US/gateways.json | 5 + src/i18n/locales/en-US/tools.json | 3 + src/i18n/locales/es-ES/gateways.json | 5 + src/i18n/locales/es-ES/tools.json | 3 + src/i18n/locales/pt-BR/gateways.json | 5 + src/i18n/locales/pt-BR/tools.json | 3 + src/vite-env.d.ts | 1 + 26 files changed, 1213 insertions(+), 229 deletions(-) create mode 100644 src/config/features.test.ts diff --git a/.env.example b/.env.example index f2796f2b..c11dc9d1 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,9 @@ SSE_SESSION_RECHECK_SECONDS=15 LOG_LEVEL=info +# Build-time UI feature flags. These are read by Vite when the frontend starts. +VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=false + # Used by `npm run e2e:docker` — credentials for a real backend user the e2e # auth fixture logs in as, and docker-compose.e2e.yml's gateway admin # password — must be 22+ chars (privileged-account minimum) and not contain diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index e794b03d..0b7f774e 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -1,6 +1,15 @@ import { test, expect } from "./fixtures/api-mock"; import { APP } from "./utils/paths"; import type { VirtualServer } from "../src/types/server"; +import type { Tool } from "../src/types/tool"; +import type { Page } from "@playwright/test"; + +interface JsonRpcRequest { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} const MOCK_VIRTUAL_SERVER: VirtualServer = { id: "76c7b637dafc4d7197f14817ddffeda9", // pragma: allowlist secret @@ -72,6 +81,93 @@ 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("**/servers?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ servers: [MOCK_VIRTUAL_SERVER] }), + }); + }); + await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_VIRTUAL_SERVER_DETAILS), + }); + }); + await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}/tools?*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ tools }), + }); + }); + await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}/resources?*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ resources: [] }), + }); + }); + await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}/prompts?*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ prompts: [] }), + }); + }); + await page.route("**/gateways?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ gateways: [MOCK_MCP_SERVER] }), + }); + }); +} + test.describe("Virtual Servers page", () => { test.beforeEach(async ({ page, apiMock }) => { // Mock authentication @@ -1116,6 +1212,114 @@ test.describe("Virtual Servers page", () => { ); }); + test.describe("virtual server Try-it tab", () => { + test.skip( + process.env.VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT !== "true", + "requires VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true before Vite starts", + ); + + test("live invokes an attached tool through the virtual server", async ({ page }) => { + const tool = makeTryItTool(); + let rpcBody: JsonRpcRequest | null = null; + let rpcHeaders: Record = {}; + + await routeVirtualServerTryIt(page, [tool]); + await page.route("**/api/rpc", async (route) => { + rpcBody = route.request().postDataJSON() as JsonRpcRequest; + rpcHeaders = route.request().headers(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + jsonrpc: "2.0", + id: rpcBody.id ?? "invoke-1", + result: { + target: { kind: "federated", gateway_name: "github-mcp" }, + content: [ + { + type: "text", + text: "Scoped result from virtual server", + mimeType: "text/plain", + }, + ], + }, + }), + }); + }); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "Actions for testVS" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const panel = page.getByRole("region", { name: "testVS details" }); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("tab", { name: "Components" })).toHaveAttribute( + "data-state", + "active", + ); + + await panel.getByRole("tab", { name: "Try it" }).click(); + await expect(panel.getByText("Live tool call")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Preview" })).toHaveCount(0); + + await panel.getByLabel("query").fill("cloudflare"); + await panel.getByLabel("limit").fill("5"); + await panel.getByRole("button", { name: "Add header" }).click(); + await panel.getByLabel("Header 1 name").fill("X-Tenant-Id"); + await panel.getByLabel("Header 1 value").fill("team-a"); + await panel.getByRole("button", { name: "Live invoke" }).click(); + + await expect(panel.getByText("Live invoke 200")).toBeVisible(); + await expect(panel.getByText("Requested through testVS")).toBeVisible(); + await expect(panel.getByText("Answered by github-mcp")).toBeVisible(); + await expect(panel.getByText("Scoped result from virtual server").first()).toBeVisible(); + expect(rpcBody).toMatchObject({ + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "github.search_issues", + server_id: MOCK_VIRTUAL_SERVER.id, + arguments: { query: "cloudflare", limit: 5 }, + }, + }); + expect(rpcHeaders["x-tenant-id"]).toBe("team-a"); + }); + + test("blocks live invoke without tools.execute", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["servers.read", "servers.use"] }); + await routeVirtualServerTryIt(page, [makeTryItTool()]); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Actions for testVS" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const panel = page.getByRole("region", { name: "testVS details" }); + await panel.getByRole("tab", { name: "Try it" }).click(); + + await expect(panel.getByText("Live invoke requires tools.execute.")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + + test("blocks live invoke without servers.use", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["servers.read", "tools.execute"] }); + await routeVirtualServerTryIt(page, [makeTryItTool()]); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Actions for testVS" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const panel = page.getByRole("region", { name: "testVS details" }); + await panel.getByRole("tab", { name: "Try it" }).click(); + + await expect(panel.getByText("Live invoke requires servers.use.")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + }); + test("shows only the actions menu in the virtual server card header", async ({ page }) => { await page.route("**/servers?*", async (route) => { await route.fulfill({ diff --git a/playwright.config.ts b/playwright.config.ts index 119d9972..28edf4d1 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: "VITE_ENABLE_TOOL_PREVIEW=true npm run dev:e2e", + command: `VITE_ENABLE_TOOL_PREVIEW=true${VIRTUAL_SERVER_TOOL_TRY_IT_FLAG} npm run dev:e2e`, url: BASE_URL, reuseExistingServer: REUSE_EXISTING_SERVER, timeout: 120_000, diff --git a/src/api/tools.test.ts b/src/api/tools.test.ts index a6f4ef85..cbcfbf66 100644 --- a/src/api/tools.test.ts +++ b/src/api/tools.test.ts @@ -177,6 +177,46 @@ describe("toolsApi", () => { }); }); + it("includes server_id for scoped live invokes", async () => { + const body = { + jsonrpc: "2.0", + id: "invoke-scoped", + result: { + content: [{ type: "text", text: "scoped", mimeType: "text/plain" }], + }, + }; + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await toolsApi.invoke( + "github.search_issues", + { query: "cloudflare" }, + {}, + { requestId: "invoke-scoped", serverId: "virtual-server-1" }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/rpc"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: "invoke-scoped", + method: "tools/call", + params: { + name: "github.search_issues", + server_id: "virtual-server-1", + arguments: { query: "cloudflare" }, + }, + }), + }), + ); + }); + it("throws ToolInvokeJsonRpcError for malformed JSON-RPC success bodies", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ jsonrpc: "2.0", id: "bad" }), { diff --git a/src/api/tools.ts b/src/api/tools.ts index 349106f2..34616288 100644 --- a/src/api/tools.ts +++ b/src/api/tools.ts @@ -100,6 +100,7 @@ export interface ToolInvokeRequest { method: "tools/call"; params: { name: string; + server_id?: string; arguments: Record; }; } @@ -278,7 +279,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()}`; @@ -288,6 +289,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 afbe8737..9e86b9fe 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 } from "msw"; @@ -6,10 +6,24 @@ import { server as mswServer } from "@/test/mocks/server"; import { renderWithProviders as render } from "@/test/test-utils"; import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel"; import type { VirtualServer } from "@/types/server"; +import type { Tool } from "@/types/tool"; import { copyToClipboard } from "@/lib/clipboard"; vi.mock("@/lib/clipboard", () => ({ copyToClipboard: vi.fn() })); +const authMock = vi.hoisted(() => ({ + permissions: ["*"] as string[], + permissionsLoading: false, +})); + +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + hasPermission: (permission: string) => + authMock.permissions.includes("*") || authMock.permissions.includes(permission), + permissionsLoading: authMock.permissionsLoading, + }), +})); + function makeServer(overrides: Partial = {}): VirtualServer { return { id: "gateway-1", @@ -47,6 +61,56 @@ function makeServer(overrides: Partial = {}): VirtualServer { }; } +function makeTool(overrides: Partial = {}): Tool { + return { + id: "tool-search", + name: "github.search_issues", + originalName: "search_issues", + description: "Search repository issues", + originalDescription: "Search repository issues", + title: "Search issues", + displayName: "Search issues", + gatewayId: "gateway-id", + gatewaySlug: "github-server", + customName: "", + customNameSlug: "search_issues", + enabled: true, + reachable: true, + deprecated: false, + executionCount: 0, + tags: [], + integrationType: "MCP", + requestType: "http", + url: "https://example.com/mcp", + headers: {}, + annotations: { readOnlyHint: true }, + jsonpathFilter: null, + auth: null, + version: 1, + visibility: "team", + createdAt: "2024-01-01T00:00:00", + updatedAt: "2024-01-02T00:00:00", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + outputSchema: { type: "object" }, + ...overrides, + }; +} + +beforeEach(() => { + authMock.permissions = ["*"]; + authMock.permissionsLoading = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("VirtualServerDetailsPanel inline tag add", () => { it("calls onAddTag with the merged, de-duplicated tag list", async () => { const user = userEvent.setup(); @@ -248,6 +312,151 @@ describe("VirtualServerDetailsPanel components list", () => { }); }); +describe("VirtualServerDetailsPanel Try-it flag", () => { + beforeEach(() => { + mswServer.use( + http.get("*/servers/:id/resources", () => HttpResponse.json({ resources: [] })), + http.get("*/servers/:id/prompts", () => HttpResponse.json({ prompts: [] })), + http.get("*/gateways", () => HttpResponse.json({ gateways: [] })), + ); + }); + + it("keeps the drawer components-only when the flag is disabled", async () => { + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "false"); + mswServer.use(http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [] }))); + + render( + , + ); + + expect(await screen.findByText("Titled Tool")).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Try it" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Components" })).not.toBeInTheDocument(); + expect(screen.queryByText("Live tool call")).not.toBeInTheDocument(); + }); + + it("shows a flag-gated Try-it tab using fetched tools", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ tools: [makeTool({ id: "tool-1", displayName: "Find issues" })] }), + ), + ); + + render( + , + ); + + const componentsTab = await screen.findByRole("tab", { name: "Components" }); + expect(componentsTab).toHaveAttribute("data-state", "active"); + expect(screen.getByRole("tab", { name: "Try it" })).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Try it" })); + + expect(await screen.findByText("Live tool call")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Preview" })).not.toBeInTheDocument(); + expect(screen.queryByText("Fallback Tool")).not.toBeInTheDocument(); + expect( + document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'), + ).toHaveTextContent('"server_id":"virtual-server-1"'); + }); + + it("does not fall back to associatedToolIds for Try-it", async () => { + const user = userEvent.setup(); + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use(http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [] }))); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + + expect(await screen.findByText("No attached tools are available to test.")).toBeInTheDocument(); + expect(screen.queryByText("Live tool call")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Live invoke" })).not.toBeInTheDocument(); + }); + + it("blocks Try-it when tools.execute is missing", async () => { + const user = userEvent.setup(); + authMock.permissions = ["servers.use"]; + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [makeTool()] })), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + + expect(await screen.findByText("Live invoke requires tools.execute.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + + it("blocks Try-it when servers.use is missing", async () => { + const user = userEvent.setup(); + authMock.permissions = ["tools.execute"]; + vi.stubEnv("VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT", "true"); + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [makeTool()] })), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + + expect(await screen.findByText("Live invoke requires servers.use.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); +}); + describe("VirtualServerDetailsPanel render variants", () => { beforeEach(() => { mswServer.use( diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 76984ec2..1a04a477 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; -import type { ReactNode } from "react"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; import { useIntl } from "react-intl"; import { Activity, @@ -23,11 +23,15 @@ import { CopyButton } from "@/components/ui/copy-button"; import { InlineTagAdd } from "@/components/ui/inline-tag-add"; import { CopyValue } from "@/components/ui/copy-value"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TruncatedText } from "@/components/ui/truncated-text"; import { getTruncatedMiddle } from "@/components/ui/truncated-middle-text"; +import { ToolTryItTab } from "@/components/tools/ToolTryItTab"; +import { isVirtualServerToolTryItEnabled } from "@/config/features"; import { cn } from "@/lib/utils"; import type { MCPServer, VirtualServer } from "@/types/server"; +import type { Tool as ApiTool } from "@/types/tool"; import type { ComponentFilter } from "@/components/gateways/types"; import { buildComponentItems, @@ -44,13 +48,20 @@ const COMPONENT_FILTER_OPTIONS: Array<{ value: ComponentFilter; labelId: string { value: "prompts", labelId: "gateways.details.filter.prompts" }, ]; -interface Tool { +const DRAWER_TAB_TRIGGER_CLASS = + "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; + +interface PanelTool extends ApiTool { + gateway_id?: string; +} + +interface ComponentTool { id: string; name: string; - title?: string; + title?: string | null; originalName: string; - description?: string; - gatewayId?: string; + description?: string | null; + gatewayId?: string | null; gateway_id?: string; } @@ -74,7 +85,9 @@ interface Prompt { } type ComponentWithType = - (Tool & { type: "tools" }) | (Resource & { type: "resources" }) | (Prompt & { type: "prompts" }); + | (ComponentTool & { type: "tools" }) + | (Resource & { type: "resources" }) + | (Prompt & { type: "prompts" }); interface MCPServersResponse { gateways?: MCPServer[]; @@ -119,6 +132,47 @@ function getMCPServers(data: MCPServersResponse | MCPServer[] | undefined): MCPS return data?.gateways ?? []; } +function getPanelTools(data: { tools: PanelTool[] } | PanelTool[] | undefined): PanelTool[] { + const tools = Array.isArray(data) ? data : (data?.tools ?? []); + return tools.map(normalizePanelTool); +} + +function normalizePanelTool(tool: PanelTool): PanelTool { + const record = tool as unknown as Record; + return { + ...tool, + annotations: asRecord(tool.annotations) ?? {}, + displayName: getNonEmptyString(record.displayName) ?? getNonEmptyString(record.display_name), + gatewayId: tool.gatewayId ?? getNonEmptyString(record.gateway_id) ?? null, + gatewaySlug: tool.gatewaySlug ?? getNonEmptyString(record.gateway_slug) ?? "", + inputSchema: asRecord(tool.inputSchema) ?? asRecord(record.input_schema) ?? {}, + originalName: + getNonEmptyString(record.originalName) ?? + getNonEmptyString(record.original_name) ?? + tool.name, + outputSchema: asRecord(tool.outputSchema) ?? asRecord(record.output_schema), + }; +} + +function getFriendlyToolLabel(tool: ApiTool): string { + const record = tool as Record; + return ( + getNonEmptyString(record.displayName) ?? + getNonEmptyString(record.title) ?? + getNonEmptyString(record.originalName) ?? + tool.name + ); +} + +function getNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + export function VirtualServerDetailsPanel({ server, error, @@ -146,12 +200,15 @@ export function VirtualServerDetailsPanel({ const tags = (server?.tags ?? []).map((tag, index) => getTagDisplay(tag, index, tagFallback)); const [sourceFilter, setSourceFilter] = useState("all"); const [componentFilter, setComponentFilter] = useState("all"); + const [activeDrawerTab, setActiveDrawerTab] = useState<"components" | "tryIt">("components"); + const [selectedTryItToolId, setSelectedTryItToolId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); const searchInputRef = useRef(null); const headingId = useMemo(() => `server-details-heading-${server?.id ?? "none"}`, [server?.id]); + const virtualServerTryItEnabled = isVirtualServerToolTryItEnabled(); const getComponentLabel = useCallback( (type: Exclude) => @@ -219,7 +276,9 @@ export function VirtualServerDetailsPanel({ // Fetch components data - only when panel is open and server exists const fetchEnabled = open && Boolean(server?.id); - const { data: toolsData, isLoading: toolsLoading } = useQuery<{ tools: Tool[] }>(toolsPath, { + const { data: toolsData, isLoading: toolsLoading } = useQuery< + { tools: PanelTool[] } | PanelTool[] + >(toolsPath, { enabled: fetchEnabled, }); @@ -237,17 +296,18 @@ export function VirtualServerDetailsPanel({ }, ); + const fetchedTools = useMemo(() => getPanelTools(toolsData), [toolsData]); + const fetchedComponents = useMemo((): ComponentWithType[] => { - const tools = Array.isArray(toolsData) ? toolsData : toolsData?.tools || []; const resources = Array.isArray(resourcesData) ? resourcesData : resourcesData?.resources || []; const prompts = Array.isArray(promptsData) ? promptsData : promptsData?.prompts || []; return [ - ...tools.map((t): ComponentWithType => ({ ...t, type: "tools" as const })), + ...fetchedTools.map((t): ComponentWithType => ({ ...t, type: "tools" as const })), ...resources.map((r): ComponentWithType => ({ ...r, type: "resources" as const })), ...prompts.map((p): ComponentWithType => ({ ...p, type: "prompts" as const })), ]; - }, [toolsData, resourcesData, promptsData]); + }, [fetchedTools, resourcesData, promptsData]); const fallbackComponents = useMemo((): ComponentWithType[] => { if (!server) return []; @@ -315,16 +375,33 @@ export function VirtualServerDetailsPanel({ }, [sourceIds, sourcesData]); const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; + const selectedTryItTool = useMemo(() => { + if (fetchedTools.length === 0) return null; + return fetchedTools.find((tool) => tool.id === selectedTryItToolId) ?? fetchedTools[0]; + }, [fetchedTools, selectedTryItToolId]); // Reset filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; + setActiveDrawerTab("components"); + setSelectedTryItToolId(null); setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); setIsSearchExpanded(false); }, [open, server?.id]); + useEffect(() => { + if (!open || !virtualServerTryItEnabled) return; + if (fetchedTools.length === 0) { + setSelectedTryItToolId(null); + return; + } + setSelectedTryItToolId((current) => + current && fetchedTools.some((tool) => tool.id === current) ? current : fetchedTools[0].id, + ); + }, [fetchedTools, open, virtualServerTryItEnabled]); + useEffect(() => { if (sourceFilter === "all") return; if (!sourceIds.includes(sourceFilter)) { @@ -447,205 +524,75 @@ 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 = ( - - ); - - return ( - - {tabButton} - {source.isTruncated && {source.fullValue}} - - ); - })} -
- )} - -
-
- {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 && ( -
+ setActiveDrawerTab(value === "tryIt" ? "tryIt" : "components") + } > - {error.message} -
+ + + {intl.formatMessage({ id: "gateways.details.tab.components" })} + + + {intl.formatMessage({ id: "gateways.details.tab.tryIt" })} + + + + + + + + + setSelectedTryItToolId(tool.id)} + /> + + + ) : ( + )} - -
- {componentsLoading && ( -
-
- )} - - {!componentsLoading && - visibleComponents.map((component) => { - const title = component.title; - const identifier = getComponentIdentifier(component); - - return ( -
- - - {getComponentIcon(component.type)} - - {getComponentLabel(component.type)} - - {title ? ( - <> - - {title} - - - {identifier} - - - - ) : ( - <> - - {identifier} - - -
- ); - })} - - {!componentsLoading && visibleComponents.length === 0 && ( -
- No {componentFilter === "all" ? "components" : componentFilter} found -
- )} -