diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts index fcfc49a..3ceabfa 100644 --- a/e2e/tools.spec.ts +++ b/e2e/tools.spec.ts @@ -17,6 +17,13 @@ interface UpdateToolPayload { [key: string]: unknown; } +interface JsonRpcRequest { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} + /** Stub the tools list endpoint (`/tools?limit=0&include_inactive=true`). */ async function routeToolsList(page: Page, tools: Tool[]) { await page.route("**/tools?*", async (route) => { @@ -42,6 +49,15 @@ async function fillToolBasics(page: Page, name: string, url: string) { await page.locator("#tool-url").fill(url); } +async function openToolDetails(page: Page, gatewaySlug: string) { + await page.getByRole("button", { name: `More options for ${gatewaySlug}` }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const panel = page.getByRole("region", { name: new RegExp(`Tools for ${gatewaySlug}`, "i") }); + await expect(panel).toBeVisible(); + return panel; +} + function makeTool(id: string, gatewaySlug: string, overrides: Partial = {}): Tool { return { id, @@ -369,6 +385,234 @@ test.describe("Tools page", () => { expect(previewHeaders["x-tenant-id"]).toBe("team-a"); }); + test("live invokes a read-only tool with JSON-RPC args and passthrough headers", async ({ + page, + }) => { + const liveTool = makeTool("search_issues", "github-server", { + description: "Search repository issues", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "integer" }, + }, + }, + annotations: { readOnlyHint: true }, + }); + let rpcBody: JsonRpcRequest | null = null; + let rpcHeaders: Record = {}; + + await routeToolsList(page, [liveTool]); + await page.route("**/api/rpc", async (route) => { + rpcBody = route.request().postDataJSON() as JsonRpcRequest; + rpcHeaders = route.request().headers(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + jsonrpc: "2.0", + id: rpcBody.id ?? "invoke-1", + result: { + content: [{ type: "text", text: "Live result from gateway", mimeType: "text/plain" }], + structured_output: { total: 1 }, + }, + }), + }); + }); + + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "github-server"); + + await expect(panel.getByText("MCP 2025-11-25")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + + await panel.getByLabel("query").fill("cloudflare"); + await panel.getByLabel("limit").fill("5"); + await panel.getByRole("button", { name: "Add header" }).click(); + await panel.getByLabel("Header 1 name").fill("X-Tenant-Id"); + await panel.getByLabel("Header 1 value").fill("team-a"); + + await panel.getByRole("button", { name: "Live invoke" }).click(); + + await expect(panel.getByText("Live invoke 200")).toBeVisible(); + await expect(panel.getByText("Live result from gateway").first()).toBeVisible(); + await expect(panel.getByText("Structured output")).toBeVisible(); + expect(rpcBody).toMatchObject({ + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "search_issues", + arguments: { query: "cloudflare", limit: 5 }, + }, + }); + expect(rpcBody?.params).not.toHaveProperty("server_id"); + expect(rpcHeaders["x-tenant-id"]).toBe("team-a"); + expect(rpcHeaders["x-csrf-token"]).toBe("mock-csrf-token"); + }); + + test("confirms destructive local live invoke before calling /rpc", async ({ page }) => { + const destructiveTool = makeTool("delete_issue", "local-gateway", { + gatewayId: null, + annotations: { destructiveHint: true }, + inputSchema: { type: "object", properties: {} }, + }); + let rpcRequestCount = 0; + + await routeToolsList(page, [destructiveTool]); + await page.route("**/api/rpc", async (route) => { + rpcRequestCount += 1; + const body = route.request().postDataJSON() as JsonRpcRequest; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + jsonrpc: "2.0", + id: body.id ?? "invoke-1", + result: { content: [{ type: "text", text: "Deleted", mimeType: "text/plain" }] }, + }), + }); + }); + + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "local-gateway"); + + await panel.getByRole("button", { name: "Live invoke" }).click(); + const dialog = page.getByRole("alertdialog", { name: "Invoke destructive tool" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).not.toBeVisible(); + expect(rpcRequestCount).toBe(0); + + await panel.getByRole("button", { name: "Live invoke" }).click(); + await page + .getByRole("alertdialog", { name: "Invoke destructive tool" }) + .getByRole("button", { name: "Invoke tool" }) + .click(); + + await expect.poll(() => rpcRequestCount).toBe(1); + await expect(panel.getByText("Live invoke 200")).toBeVisible(); + await expect(panel.getByText("Deleted").first()).toBeVisible(); + }); + + test("sends MCP cancellation when cancelling live invoke", async ({ page }) => { + const slowTool = makeTool("slow_search", "github-server", { + annotations: { readOnlyHint: true }, + inputSchema: { type: "object", properties: {} }, + }); + const rpcBodies: JsonRpcRequest[] = []; + let releaseInvoke: (() => void) | undefined; + const releaseInvokePromise = new Promise((resolve) => { + releaseInvoke = resolve; + }); + + await routeToolsList(page, [slowTool]); + await page.route("**/api/rpc", async (route) => { + const body = route.request().postDataJSON() as JsonRpcRequest; + rpcBodies.push(body); + + if (body.method === "notifications/cancelled") { + releaseInvoke?.(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ jsonrpc: "2.0", id: body.id ?? "cancel-1", result: {} }), + }); + return; + } + + await releaseInvokePromise; + try { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + jsonrpc: "2.0", + id: body.id ?? "invoke-1", + result: { content: [{ type: "text", text: "Finished", mimeType: "text/plain" }] }, + }), + }); + } catch { + // The browser request is expected to be aborted after cancellation. + } + }); + + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "github-server"); + + await panel.getByRole("button", { name: "Live invoke" }).click(); + await expect(panel.getByRole("button", { name: "Cancel request" })).toBeVisible(); + await panel.getByRole("button", { name: "Cancel request" }).click(); + + await expect + .poll(() => rpcBodies.some((body) => body.method === "notifications/cancelled")) + .toBe(true); + const invokeBody = rpcBodies.find((body) => body.method === "tools/call"); + const cancelBody = rpcBodies.find((body) => body.method === "notifications/cancelled"); + + expect(invokeBody?.id).toEqual(expect.stringMatching(/^tool-live-/)); + expect(cancelBody).toMatchObject({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { + requestId: String(invokeBody?.id), + reason: "user", + }, + }); + }); + + test("hides live invoke when tools.execute is missing", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["tools.read", "servers.use"] }); + const liveTool = makeTool("search_issues", "github-server", { + annotations: { readOnlyHint: true }, + inputSchema: { type: "object", properties: {} }, + }); + + await routeToolsList(page, [liveTool]); + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "github-server"); + + await expect(panel.getByText("Live invoke requires tools.execute.")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + + test("hides live invoke when servers.use is missing", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["tools.read", "tools.execute"] }); + const liveTool = makeTool("search_issues", "github-server", { + annotations: { readOnlyHint: true }, + inputSchema: { type: "object", properties: {} }, + }); + + await routeToolsList(page, [liveTool]); + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "github-server"); + + await expect(panel.getByText("Live invoke requires servers.use.")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + + test("does not offer live invoke for federated tools without readOnlyHint", async ({ page }) => { + const federatedTool = makeTool("create_issue", "github-server", { + annotations: { destructiveHint: true }, + inputSchema: { type: "object", properties: {} }, + }); + + await routeToolsList(page, [federatedTool]); + await page.goto(APP.TOOLS); + await page.waitForLoadState("networkidle"); + const panel = await openToolDetails(page, "github-server"); + + await expect( + panel.getByText("Live invoke is not offered for federated tools without readOnlyHint."), + ).toBeVisible(); + await expect(panel.getByRole("button", { name: "Live invoke" })).toBeDisabled(); + }); + test("warns for denied passthrough headers and excludes them from preview", async ({ page }) => { const previewTool = makeTool("search_issues", "github-server", { inputSchema: { diff --git a/src/api/tools.test.ts b/src/api/tools.test.ts index 349cea8..a6f4ef8 100644 --- a/src/api/tools.test.ts +++ b/src/api/tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { toolsApi } from "./tools"; +import { ToolInvokeJsonRpcError, toolsApi } from "./tools"; import { setCsrfToken } from "./client"; describe("toolsApi", () => { @@ -106,6 +106,155 @@ describe("toolsApi", () => { }); }); + describe("invoke", () => { + it("POSTs a tools/call JSON-RPC envelope to /rpc with passthrough headers", async () => { + const body = { + jsonrpc: "2.0", + id: "invoke-1", + result: { + content: [{ type: "text", text: "done", mimeType: "text/plain" }], + }, + }; + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await toolsApi.invoke( + "search.issues", + { query: "cloudflare" }, + { "X-Api-Key": "session-key" }, + { requestId: "invoke-1" }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/rpc"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: "invoke-1", + method: "tools/call", + params: { + name: "search.issues", + arguments: { query: "cloudflare" }, + }, + }), + headers: expect.objectContaining({ + "X-CSRF-Token": "test-csrf-token", + "X-Api-Key": "session-key", + }), + credentials: "same-origin", // pragma: allowlist secret + }), + ); + expect(result).toEqual({ result: body.result, status: 200, id: "invoke-1" }); + }); + + it("throws ToolInvokeJsonRpcError for JSON-RPC error bodies even on HTTP 200", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "invoke-denied", + error: { code: -32003, message: "Access denied", data: { method: "tools/call" } }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + await expect( + toolsApi.invoke("search", {}, {}, { requestId: "invoke-denied" }), + ).rejects.toMatchObject({ + name: "ToolInvokeJsonRpcError", + rpcError: { code: -32003, message: "Access denied" }, + status: 200, + id: "invoke-denied", + }); + }); + + it("throws ToolInvokeJsonRpcError for malformed JSON-RPC success bodies", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ jsonrpc: "2.0", id: "bad" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(toolsApi.invoke("search", {}, {}, { requestId: "bad" })).rejects.toThrow( + ToolInvokeJsonRpcError, + ); + }); + + it("throws synchronously for unsafe live invoke names", () => { + expect(() => toolsApi.invoke("../etc/passwd")).toThrow("Invalid tool name format"); + }); + }); + + describe("cancelInvoke", () => { + it("POSTs an MCP cancellation notification to /rpc", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ jsonrpc: "2.0", id: "cancel-invoke-1", result: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await toolsApi.cancelInvoke("invoke-1", "user"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/rpc"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: "cancel-invoke-1", + method: "notifications/cancelled", + params: { + requestId: "invoke-1", + reason: "user", + }, + }), + headers: expect.objectContaining({ + "X-CSRF-Token": "test-csrf-token", + }), + credentials: "same-origin", // pragma: allowlist secret + }), + ); + }); + + it("throws ToolInvokeJsonRpcError for cancellation JSON-RPC errors", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "cancel-invoke-1", + error: { code: -32003, message: "Not authorized" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + await expect(toolsApi.cancelInvoke("invoke-1", "user")).rejects.toMatchObject({ + name: "ToolInvokeJsonRpcError", + rpcError: { code: -32003, message: "Not authorized" }, + status: 200, + id: "cancel-invoke-1", + }); + }); + + it("throws synchronously for empty cancellation request IDs", () => { + expect(() => toolsApi.cancelInvoke("")).toThrow("Invalid request ID"); + }); + }); + describe("delete", () => { it("calls DELETE /tools/:id with CSRF token and same-origin credentials", async () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); diff --git a/src/api/tools.ts b/src/api/tools.ts index d8ac29a..349106f 100644 --- a/src/api/tools.ts +++ b/src/api/tools.ts @@ -92,6 +92,58 @@ export interface ToolPreviewResult { status: number; } +export type ToolInvokeRequestId = string | number; + +export interface ToolInvokeRequest { + jsonrpc: "2.0"; + id: ToolInvokeRequestId; + method: "tools/call"; + params: { + name: string; + arguments: Record; + }; +} + +export interface ToolCancelInvokeRequest { + jsonrpc: "2.0"; + id: ToolInvokeRequestId; + method: "notifications/cancelled"; + params: { + requestId: string; + reason?: string; + }; +} + +export interface ToolJsonRpcErrorBody { + code: number; + message: string; + data?: unknown; +} + +export interface ToolInvokeJsonRpcResponse { + jsonrpc?: "2.0"; + id?: ToolInvokeRequestId | null; + result?: ToolPreviewResponse; + error?: ToolJsonRpcErrorBody; +} + +export interface ToolInvokeResult { + result: ToolPreviewResponse; + status: number; + id: ToolInvokeRequestId | null; +} + +export class ToolInvokeJsonRpcError extends Error { + constructor( + public readonly rpcError: ToolJsonRpcErrorBody, + public readonly status: number, + public readonly id: ToolInvokeRequestId | null, + ) { + super(rpcError.message); + this.name = "ToolInvokeJsonRpcError"; + } +} + /** * Validates tool ID to prevent path traversal and injection attacks * @param id - The tool ID to validate @@ -129,6 +181,12 @@ function validateToolName(name: string): string { return name; } +function validateRequestId(id: ToolInvokeRequestId): ToolInvokeRequestId { + if (typeof id === "string" && id.trim()) return id; + if (typeof id === "number" && Number.isFinite(id)) return id; + throw new Error("Invalid request ID"); +} + export const toolsApi = { /** * Fetch a single tool by ID. @@ -208,6 +266,89 @@ export const toolsApi = { .then(({ data, status }) => ({ preview: data, status })); }, + /** + * Invoke a tool through the production MCP JSON-RPC path. + * + * The browser calls `/rpc`; `api` resolves that to same-origin `/api/rpc`, + * where the BFF injects the upstream bearer token. JSON-RPC errors are body + * fields even when HTTP status is 200, so callers must not rely on HTTP + * status alone to classify invocation success. + */ + invoke: ( + name: string, + args: Record = {}, + passthroughHeaders: Record = {}, + options: { requestId?: ToolInvokeRequestId; signal?: AbortSignal } = {}, + ): Promise => { + const validName = validateToolName(name); + const requestId = options.requestId ?? `tool-live-${Date.now()}`; + const body: ToolInvokeRequest = { + jsonrpc: "2.0", + id: requestId, + method: "tools/call", + params: { + name: validName, + arguments: args, + }, + }; + + return api + .postWithMeta("/rpc", body, { + headers: passthroughHeaders, + signal: options.signal, + }) + .then(({ data, status }) => { + const id = data.id ?? null; + if (data.error) { + throw new ToolInvokeJsonRpcError(data.error, status, id); + } + if (!data.result) { + throw new ToolInvokeJsonRpcError( + { + code: -32603, + message: "Malformed JSON-RPC response", + data, + }, + status, + id, + ); + } + return { result: data.result, status, id }; + }); + }, + + /** + * Request cancellation for an in-flight MCP JSON-RPC tool call. + * + * Uses the owner-authorized MCP notification path on `/rpc` instead of the + * REST cancellation endpoint, which is admin-scoped in the gateway today. + */ + cancelInvoke: ( + requestId: ToolInvokeRequestId, + reason?: string, + options: { signal?: AbortSignal } = {}, + ): Promise => { + const validRequestId = validateRequestId(requestId); + const body: ToolCancelInvokeRequest = { + jsonrpc: "2.0", + id: `cancel-${String(validRequestId)}`, + method: "notifications/cancelled", + params: { + requestId: String(validRequestId), + ...(reason ? { reason } : {}), + }, + }; + + return api + .postWithMeta("/rpc", body, { signal: options.signal }) + .then(({ data, status }) => { + const id = data.id ?? null; + if (data.error) { + throw new ToolInvokeJsonRpcError(data.error, status, id); + } + }); + }, + /** * Generate input/output JSON schemas for a REST tool from its OpenAPI spec. * diff --git a/src/components/tools/ToolDetailsPanel.test.tsx b/src/components/tools/ToolDetailsPanel.test.tsx index d525f4b..1237207 100644 --- a/src/components/tools/ToolDetailsPanel.test.tsx +++ b/src/components/tools/ToolDetailsPanel.test.tsx @@ -5,6 +5,13 @@ import { renderWithProviders as render } from "@/test/test-utils"; import { ToolDetailsPanel } from "./ToolDetailsPanel"; import type { Tool } from "@/types/tool"; +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + hasPermission: () => true, + permissionsLoading: false, + }), +})); + // Helper to create mock tools function createMockTool(id: number, overrides?: Partial): Tool { return { diff --git a/src/components/tools/ToolLiveInvokeGate.test.tsx b/src/components/tools/ToolLiveInvokeGate.test.tsx new file mode 100644 index 0000000..631d3e6 --- /dev/null +++ b/src/components/tools/ToolLiveInvokeGate.test.tsx @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders as render } from "@/test/test-utils"; +import type { ToolInvokeState } from "@/hooks/useToolInvoke"; +import type { Tool } from "@/types/tool"; +import { resolveToolLiveInvokeAvailability, ToolLiveInvokeGate } from "./ToolLiveInvokeGate"; + +const mockHasPermission = vi.fn((_perm: string) => true); +let mockPermissionsLoading = false; + +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + hasPermission: mockHasPermission, + permissionsLoading: mockPermissionsLoading, + }), +})); + +function makeTool(overrides: Partial = {}): Tool { + return { + id: "tool-search", + name: "search_issues", + originalName: "search_issues", + description: "Search repository issues", + originalDescription: "Search repository issues", + title: "Search issues", + displayName: "Search issues", + gatewayId: null, + gatewaySlug: "local", + customName: "", + customNameSlug: "search_issues", + enabled: true, + reachable: true, + deprecated: false, + executionCount: 0, + tags: [], + integrationType: "MCP", + requestType: "http", + url: "https://example.com/mcp", + headers: {}, + annotations: {}, + jsonpathFilter: null, + auth: null, + version: 1, + visibility: "team", + createdAt: "2024-01-01T00:00:00", + updatedAt: "2024-01-02T00:00:00", + inputSchema: { type: "object", properties: {} }, + outputSchema: { type: "object" }, + ...overrides, + }; +} + +function makeInvoke( + overrides: Partial> = {}, +): Pick { + return { + run: vi.fn(), + stopWaiting: vi.fn(), + isLoading: false, + hasRun: false, + ...overrides, + }; +} + +describe("resolveToolLiveInvokeAvailability", () => { + it("checks permissions before annotations", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: false, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: { readOnlyHint: true }, gatewayId: null }, + }), + ).toEqual({ state: "missingPermission", permission: "tools.execute" }); + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: false, + permissionsLoading: false, + tool: { annotations: { readOnlyHint: true }, gatewayId: null }, + }), + ).toEqual({ state: "missingPermission", permission: "servers.use" }); + }); + + it("allows read-only tools including federated tools", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: { readOnlyHint: true }, gatewayId: "gw-1" }, + }), + ).toEqual({ state: "available" }); + }); + + it("requires confirmation for local destructive tools", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: { destructiveHint: true }, gatewayId: null }, + }), + ).toEqual({ state: "requiresConfirmation" }); + }); + + it("treats destructiveHint as higher priority than readOnlyHint", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { + annotations: { readOnlyHint: true, destructiveHint: true }, + gatewayId: null, + }, + }), + ).toEqual({ state: "requiresConfirmation" }); + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { + annotations: { readOnlyHint: true, destructiveHint: true }, + gatewayId: "gw-1", + }, + }), + ).toEqual({ state: "unavailableFederated" }); + }); + + it("does not offer federated or untagged tools pending approval policy", () => { + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: {}, gatewayId: "gw-1" }, + }), + ).toEqual({ state: "unavailableFederated" }); + expect( + resolveToolLiveInvokeAvailability({ + canExecute: true, + canUseServers: true, + permissionsLoading: false, + tool: { annotations: {}, gatewayId: null }, + }), + ).toEqual({ state: "unavailableUntagged" }); + }); +}); + +describe("ToolLiveInvokeGate", () => { + beforeEach(() => { + mockHasPermission.mockReset(); + mockHasPermission.mockReturnValue(true); + mockPermissionsLoading = false; + }); + + it("runs immediately for read-only tools", async () => { + const user = userEvent.setup(); + const invoke = makeInvoke(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Live invoke" })); + + expect(invoke.run).toHaveBeenCalledTimes(1); + expect(mockHasPermission).toHaveBeenCalledWith("tools.execute"); + expect(mockHasPermission).toHaveBeenCalledWith("servers.use"); + }); + + it("confirms local destructive tools before running", async () => { + const user = userEvent.setup(); + const invoke = makeInvoke(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Live invoke" })); + const dialog = screen.getByRole("alertdialog", { name: "Invoke destructive tool" }); + expect(dialog).toHaveTextContent('Invoke "delete_issue" against the live gateway?'); + + await user.click(screen.getByRole("button", { name: "Invoke tool" })); + + expect(invoke.run).toHaveBeenCalledTimes(1); + }); + + it("shows RBAC and loading gates", () => { + mockHasPermission.mockImplementation((permission) => permission !== "tools.execute"); + const { rerender } = render( + , + ); + + expect(screen.getByText("Live invoke requires tools.execute.")).toBeInTheDocument(); + + mockHasPermission.mockImplementation((permission) => permission === "tools.execute"); + rerender( + , + ); + expect(screen.getByText("Live invoke requires servers.use.")).toBeInTheDocument(); + + mockHasPermission.mockReturnValue(true); + mockPermissionsLoading = true; + rerender( + , + ); + expect(screen.getByRole("button", { name: "Checking access" })).toBeDisabled(); + expect(screen.getByText("Checking your tool permissions.")).toBeInTheDocument(); + + mockPermissionsLoading = false; + }); + + it("cancels active live invokes", async () => { + const user = userEvent.setup(); + const invoke = makeInvoke({ isLoading: true }); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Cancel request" })); + + expect(invoke.stopWaiting).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/tools/ToolLiveInvokeGate.tsx b/src/components/tools/ToolLiveInvokeGate.tsx new file mode 100644 index 0000000..d4d8336 --- /dev/null +++ b/src/components/tools/ToolLiveInvokeGate.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { Loader2, Play, Square } from "lucide-react"; +import { useIntl } from "react-intl"; + +import { useAuth } from "@/auth/useAuth"; +import { ConfirmDialog } from "@/components/servers/ConfirmDialog"; +import { Button } from "@/components/ui/button"; +import type { ToolInvokeState } from "@/hooks/useToolInvoke"; +import type { Tool } from "@/types/tool"; +import { getToolAnnotationHints } from "./toolAnnotations"; + +export type ToolLiveInvokeAvailability = + | { state: "checkingAccess" } + | { state: "missingPermission"; permission: "tools.execute" | "servers.use" } + | { state: "available" } + | { state: "requiresConfirmation" } + | { state: "unavailableFederated" } + | { state: "unavailableUntagged" }; + +export interface ResolveToolLiveInvokeAvailabilityInput { + canExecute: boolean; + canUseServers: boolean; + permissionsLoading: boolean; + tool: Pick; +} + +export function resolveToolLiveInvokeAvailability({ + canExecute, + canUseServers, + permissionsLoading, + tool, +}: ResolveToolLiveInvokeAvailabilityInput): ToolLiveInvokeAvailability { + if (permissionsLoading) return { state: "checkingAccess" }; + if (!canExecute) return { state: "missingPermission", permission: "tools.execute" }; + if (!canUseServers) return { state: "missingPermission", permission: "servers.use" }; + + const hints = getToolAnnotationHints(tool.annotations); + const isFederated = Boolean(tool.gatewayId); + + // Future #5437 approval states should plug in here before deciding that a + // non-read-only tool remains unavailable from this drawer. + if (hints.destructiveHint) { + return isFederated ? { state: "unavailableFederated" } : { state: "requiresConfirmation" }; + } + + if (hints.readOnlyHint) return { state: "available" }; + if (isFederated) return { state: "unavailableFederated" }; + + return { state: "unavailableUntagged" }; +} + +export interface ToolLiveInvokeGateProps { + disabled?: boolean; + invoke: Pick; + tool: Tool; +} + +export function ToolLiveInvokeGate({ disabled = false, invoke, tool }: ToolLiveInvokeGateProps) { + const intl = useIntl(); + const { hasPermission, permissionsLoading } = useAuth(); + const [confirmOpen, setConfirmOpen] = useState(false); + const availability = useMemo( + () => + resolveToolLiveInvokeAvailability({ + canExecute: hasPermission("tools.execute"), + canUseServers: hasPermission("servers.use"), + permissionsLoading, + tool, + }), + [hasPermission, permissionsLoading, tool], + ); + + if (invoke.isLoading) { + return ( +
+ + +
+ ); + } + + if (availability.state === "available") { + return ( + + ); + } + + if (availability.state === "requiresConfirmation") { + return ( + <> + + + + ); + } + + return ( +
+ +

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

+
+ ); +} + +function availabilityMessage( + availability: ToolLiveInvokeAvailability, + formatMessage: (descriptor: { id: string }) => string, +) { + switch (availability.state) { + case "checkingAccess": + return formatMessage({ id: "tools.details.invoke.unavailable.checkingAccess" }); + case "missingPermission": + return formatMessage({ + id: + availability.permission === "tools.execute" + ? "tools.details.invoke.unavailable.missingExecutePermission" + : "tools.details.invoke.unavailable.missingServerUsePermission", + }); + case "unavailableFederated": + return formatMessage({ id: "tools.details.invoke.unavailable.federated" }); + case "unavailableUntagged": + return formatMessage({ id: "tools.details.invoke.unavailable.untagged" }); + case "available": + case "requiresConfirmation": + return ""; + } +} diff --git a/src/components/tools/ToolLiveInvokeResult.test.tsx b/src/components/tools/ToolLiveInvokeResult.test.tsx new file mode 100644 index 0000000..835be8f --- /dev/null +++ b/src/components/tools/ToolLiveInvokeResult.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders as render } from "@/test/test-utils"; +import type { ToolInvokeState } from "@/hooks/useToolInvoke"; +import { ToolLiveInvokeResult } from "./ToolLiveInvokeResult"; + +function invokeProps( + overrides: Partial>, +): Pick { + return { + result: null, + error: null, + hasRun: false, + ...overrides, + }; +} + +describe("ToolLiveInvokeResult", () => { + it("renders nothing before the first run", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders live invoke success through the tool result renderer", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByText("Live invoke 200")).toBeInTheDocument(); + expect(screen.getByText("14 ms")).toBeInTheDocument(); + expect(screen.getByText("Tool result")).toBeInTheDocument(); + expect(screen.getByText("live result")).toBeInTheDocument(); + expect(screen.getByText("Structured output")).toBeInTheDocument(); + expect(screen.getByText("Raw live response")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Raw live response" })); + + expect(screen.getByLabelText("Copy raw live response")).toBeVisible(); + }); + + it("renders JSON-RPC errors with their code", () => { + render( + , + ); + + expect(screen.getByText("Live invoke failed -32003")).toBeInTheDocument(); + expect(screen.getByText("8 ms")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Access denied"); + }); + + it("renders HTTP errors and tool-level error results", () => { + const { rerender } = render( + , + ); + + expect(screen.getByText("Live invoke failed 403")).toBeInTheDocument(); + expect(screen.getByText("Forbidden")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Live invoke 200")).toBeInTheDocument(); + expect(screen.getByText("Error response")).toBeInTheDocument(); + expect(screen.getByText("tool failed")).toBeInTheDocument(); + }); +}); diff --git a/src/components/tools/ToolLiveInvokeResult.tsx b/src/components/tools/ToolLiveInvokeResult.tsx new file mode 100644 index 0000000..1d3003c --- /dev/null +++ b/src/components/tools/ToolLiveInvokeResult.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useIntl } from "react-intl"; + +import type { ToolInvokeState } from "@/hooks/useToolInvoke"; +import { Button } from "@/components/ui/button"; +import { CodeBlock } from "@/components/ui/code-block"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; +import { ToolResultRenderer } from "./ToolResultRenderer"; +import { + estimateJsonByteSize, + formatToolResultBytes, + getToolResultIsError, + TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES, +} from "./toolResultContent"; + +export interface ToolLiveInvokeResultProps { + invoke: Pick; +} + +export function ToolLiveInvokeResult({ invoke }: ToolLiveInvokeResultProps) { + const intl = useIntl(); + const { result, error, hasRun } = invoke; + + if (!hasRun) return null; + + const renderTimeMs = result?.renderTimeMs ?? error?.renderTimeMs ?? 0; + const response = result?.result; + const toolResultIsError = response ? getToolResultIsError(response) : false; + const succeeded = result !== null; + const statusOk = succeeded && !toolResultIsError; + const statusLabel = succeeded + ? intl.formatMessage({ id: "tools.details.invoke.statusOk" }, { status: result.status }) + : error?.code !== undefined + ? intl.formatMessage({ id: "tools.details.invoke.statusErrorWithCode" }, { code: error.code }) + : error?.status !== null && error?.status !== undefined + ? intl.formatMessage( + { id: "tools.details.invoke.statusErrorWithStatus" }, + { status: error.status }, + ) + : intl.formatMessage({ id: "tools.details.invoke.statusError" }); + + return ( +
+
+ {statusOk ? ( + + ) : ( + + )} + + {statusLabel} + + + + {intl.formatMessage({ id: "tools.details.preview.renderMs" }, { ms: renderTimeMs })} + +
+ + {response && } + + {response && ( + + + + {intl.formatMessage({ id: "tools.details.invoke.rawResponse" })} + + + + + + + )} + + {error && ( +
+          {error.message}
+        
+ )} +
+ ); +} + +function RawLiveResponse({ response }: { response: unknown }) { + const intl = useIntl(); + const byteSize = estimateJsonByteSize( + response, + TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES + 1, + ); + const isLarge = byteSize > TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES; + const [expanded, setExpanded] = useState(!isLarge); + const sizeLabel = isLarge + ? `>${formatToolResultBytes(TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES)}` + : formatToolResultBytes(byteSize); + + if (isLarge && !expanded) { + return ( +
+ + {intl.formatMessage( + { id: "tools.details.preview.result.largeContent" }, + { size: sizeLabel }, + )} + + +
+ ); + } + + return ( + + ); +} diff --git a/src/components/tools/ToolTryItTab.test.tsx b/src/components/tools/ToolTryItTab.test.tsx index 1e9e08d..6bc188f 100644 --- a/src/components/tools/ToolTryItTab.test.tsx +++ b/src/components/tools/ToolTryItTab.test.tsx @@ -6,6 +6,19 @@ import { renderWithProviders as render } from "@/test/test-utils"; import type { Tool } from "@/types/tool"; import { ToolTryItTab } from "./ToolTryItTab"; +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + hasPermission: (permission: string) => + permission === "tools.execute" || permission === "servers.use", + permissionsLoading: false, + }), +})); + +function activeCode(): string { + const pre = document.querySelector('[data-slot="tabs-content"][data-state="active"] pre'); + return pre?.textContent ?? ""; +} + function makeTool(overrides: Partial = {}): Tool { return { id: "tool-search", @@ -48,6 +61,33 @@ function makeTool(overrides: Partial = {}): Tool { } describe("ToolTryItTab", () => { + it("renders live tools/call snippets against a gateway placeholder", async () => { + const user = userEvent.setup(); + const selectedTool = makeTool({ annotations: { readOnlyHint: true } }); + + render( + , + ); + + expect(screen.getByRole("tab", { name: "curl" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "JSON-RPC" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Python" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "TypeScript" })).toBeInTheDocument(); + expect(screen.getByText("MCP 2025-11-25")).toBeInTheDocument(); + expect(activeCode()).toContain("$MCPGATEWAY_URL/rpc"); + expect(activeCode()).toContain('"method":"tools/call"'); + expect(activeCode()).toContain('"name":"search_issues"'); + expect(activeCode()).not.toContain("/api/rpc"); + + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + expect(screen.getByRole("button", { name: "Live invoke" })).toBeEnabled(); + + await user.click(screen.getByRole("tab", { name: "JSON-RPC" })); + expect(activeCode()).toContain('"method": "tools/call"'); + expect(activeCode()).toContain('"name": "search_issues"'); + expect(activeCode()).not.toContain("server_id"); + }); + it("preserves draft arguments and headers when the same tool is refreshed", async () => { const user = userEvent.setup(); const selectedTool = makeTool(); diff --git a/src/components/tools/ToolTryItTab.tsx b/src/components/tools/ToolTryItTab.tsx index 81f7c4c..6fea71e 100644 --- a/src/components/tools/ToolTryItTab.tsx +++ b/src/components/tools/ToolTryItTab.tsx @@ -3,15 +3,27 @@ import { useIntl } from "react-intl"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { CodeBlock } from "@/components/ui/code-block"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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_SNIPPETS, + type ToolSnippetLanguage, +} from "./buildToolSnippets"; import { ToolArgumentsForm, seedToolArguments } from "./ToolArgumentsForm"; import { getForwardableHeaders, type ToolHeaderRow, ToolHeadersEditor } from "./ToolHeadersEditor"; +import { ToolLiveInvokeGate } from "./ToolLiveInvokeGate"; +import { ToolLiveInvokeResult } from "./ToolLiveInvokeResult"; import { ToolPreviewButton } from "./ToolPreviewButton"; import { ToolPreviewResult } from "./ToolPreviewResult"; import { getToolAnnotationHints } from "./toolAnnotations"; +const DEFAULT_SNIPPET_LANGUAGE: ToolSnippetLanguage = "curl"; + export interface ToolTryItTabProps { tools: Tool[]; selectedTool: Tool; @@ -26,11 +38,23 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab const [headers, setHeaders] = useState([]); const [argsValid, setArgsValid] = useState(true); const [headersValid, setHeadersValid] = useState(true); + const [snippetLanguage, setSnippetLanguage] = + useState(DEFAULT_SNIPPET_LANGUAGE); 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 resetPreview = preview.reset; + const resetInvoke = invoke.reset; const previousToolIdRef = useRef(selectedTool.id); + const snippets = useMemo( + () => + TOOL_SNIPPETS.map((spec) => ({ + ...spec, + text: spec.build({ toolName: selectedTool.name, args }), + })), + [args, selectedTool.name], + ); useEffect(() => { if (previousToolIdRef.current === selectedTool.id) return; @@ -40,7 +64,8 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab setArgsValid(true); setHeadersValid(true); resetPreview(); - }, [resetPreview, selectedTool]); + resetInvoke(); + }, [resetInvoke, resetPreview, selectedTool]); return (
@@ -108,11 +133,55 @@ export function ToolTryItTab({ tools, selectedTool, onSelectTool }: ToolTryItTab -
- +
+ setSnippetLanguage(value as ToolSnippetLanguage)} + > +
+
+ + {TOOL_SNIPPETS.map((spec) => ( + + {intl.formatMessage({ id: spec.labelId })} + + ))} + + + {intl.formatMessage( + { id: "tools.details.code.mcpVersionBadge" }, + { version: TOOL_SNIPPET_MCP_VERSION }, + )} + +
+ +
+ + +
+
+ + {snippets.map((snippet) => ( + + + + ))} +
+
); } diff --git a/src/components/tools/buildToolSnippets.test.ts b/src/components/tools/buildToolSnippets.test.ts new file mode 100644 index 0000000..f822f17 --- /dev/null +++ b/src/components/tools/buildToolSnippets.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + buildToolCurl, + buildToolJsonRpc, + buildToolPython, + buildToolTypescript, + TOOL_SNIPPET_MCP_VERSION, +} from "./buildToolSnippets"; + +const input = { + toolName: "gateway.search_issues", + args: { query: "can't reproduce", limit: 5, dryRun: false }, +}; + +describe("buildToolSnippets", () => { + it("pins the supported MCP version used by the badge", () => { + expect(TOOL_SNIPPET_MCP_VERSION).toBe("2025-11-25"); + }); + + it("builds the canonical tools/call JSON-RPC envelope", () => { + expect(JSON.parse(buildToolJsonRpc(input))).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "gateway.search_issues", + arguments: { query: "can't reproduce", limit: 5, dryRun: false }, + }, + }); + }); + + it("targets a real gateway placeholder instead of the browser BFF path", () => { + const snippet = buildToolCurl(input); + + expect(snippet).toContain("$MCPGATEWAY_URL/rpc"); + expect(snippet).toContain("Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN"); + expect(snippet).not.toContain("/api/rpc"); + expect(snippet).toContain(`"method":"tools/call"`); + expect(snippet).toContain(`"name":"gateway.search_issues"`); + expect(snippet).toContain(`can'\\''t reproduce`); + }); + + it("escapes newline arguments inside the curl payload", () => { + const snippet = buildToolCurl({ + toolName: "line_tool", + args: { query: "first\nsecond" }, + }); + const lines = snippet.split("\n"); + const dataLine = lines[lines.length - 1] ?? ""; + + expect(lines).toHaveLength(4); + expect(dataLine).toContain("first\\nsecond"); + expect(dataLine).not.toContain("first\nsecond"); + }); + + it("emits Python that keeps JSON booleans and nulls valid", () => { + const snippet = buildToolPython({ + toolName: "nullable_tool", + args: { active: true, optional: null }, + }); + + expect(snippet).toContain("json.loads(payload)"); + expect(snippet).toContain('\\"active\\": true'); + expect(snippet).toContain('\\"optional\\": null'); + expect(snippet).toContain("os.environ['MCPGATEWAY_URL']"); + expect(snippet).not.toContain("$MCPGATEWAY_URL"); + }); + + it("omits undefined arguments from the Python JSON payload", () => { + const snippet = buildToolPython({ + toolName: "missing_arg_tool", + args: { query: "cloudflare", optional: undefined }, + }); + + expect(snippet).toContain('\\"query\\": \\"cloudflare\\"'); + expect(snippet).not.toContain("undefined"); + expect(snippet).not.toContain("optional"); + }); + + it("checks both HTTP and JSON-RPC failures in TypeScript", () => { + const snippet = buildToolTypescript(input); + + expect(snippet).toContain("process.env.MCPGATEWAY_URL"); + expect(snippet).toContain("process.env.MCPGATEWAY_BEARER_TOKEN"); + expect(snippet).toContain('method: "tools/call"'); + expect(snippet).toContain("if (!response.ok)"); + expect(snippet).toContain("if (data.error)"); + }); +}); diff --git a/src/components/tools/buildToolSnippets.ts b/src/components/tools/buildToolSnippets.ts new file mode 100644 index 0000000..7381ff2 --- /dev/null +++ b/src/components/tools/buildToolSnippets.ts @@ -0,0 +1,126 @@ +import type { CodeBlockLanguage } from "@/components/ui/code-block"; + +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 interface ToolSnippetInput { + args: Record; + toolName: string; +} + +export interface ToolSnippetSpec { + value: ToolSnippetLanguage; + labelId: string; + language: string; + prismLanguage: CodeBlockLanguage; + build: (input: ToolSnippetInput) => string; +} + +function buildToolCallEnvelope({ toolName, args }: ToolSnippetInput) { + return { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + }; +} + +function bashSingleQuoteLiteral(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function buildToolCurl(input: ToolSnippetInput): string { + const body = JSON.stringify(buildToolCallEnvelope(input)); + return [ + `curl -X POST "$${URL_ENV}/rpc" \\`, + ` -H "Authorization: Bearer $${TOKEN_ENV}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d ${bashSingleQuoteLiteral(body)}`, + ].join("\n"); +} + +export function buildToolJsonRpc(input: ToolSnippetInput): string { + return JSON.stringify(buildToolCallEnvelope(input), null, 2); +} + +export function buildToolPython({ toolName, args }: ToolSnippetInput): string { + const payload = JSON.stringify( + JSON.stringify(buildToolCallEnvelope({ toolName, args }), null, 2), + ); + return [ + "import json", + "import os", + "import requests", + "", + `payload = ${payload}`, + "", + "response = requests.post(", + ` f"{os.environ['${URL_ENV}']}/rpc",`, + ` headers={"Authorization": f"Bearer {os.environ['${TOKEN_ENV}']}"},`, + " json=json.loads(payload),", + ")", + "response.raise_for_status()", + "print(response.json())", + ].join("\n"); +} + +export function buildToolTypescript({ toolName, args }: ToolSnippetInput): string { + return [ + `const response = await fetch(\`\${process.env.${URL_ENV}}/rpc\`, {`, + ` method: "POST",`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${TOKEN_ENV}}\`,`, + ` "Content-Type": "application/json",`, + ` },`, + ` body: JSON.stringify({`, + ` jsonrpc: "2.0",`, + ` id: 1,`, + ` method: "tools/call",`, + ` params: {`, + ` name: ${JSON.stringify(toolName)},`, + ` arguments: ${JSON.stringify(args)},`, + ` },`, + ` }),`, + `});`, + `if (!response.ok) throw new Error(\`Tool call failed: \${response.status}\`);`, + `const data = await response.json();`, + `if (data.error) throw new Error(data.error.message);`, + ].join("\n"); +} + +export const TOOL_SNIPPETS: ToolSnippetSpec[] = [ + { + value: "curl", + labelId: "tools.details.code.tab.curl", + language: "curl", + prismLanguage: "bash", + build: buildToolCurl, + }, + { + value: "jsonRpc", + labelId: "tools.details.code.tab.jsonRpc", + language: "JSON-RPC", + prismLanguage: "json", + build: buildToolJsonRpc, + }, + { + value: "python", + labelId: "tools.details.code.tab.python", + language: "Python", + prismLanguage: "python", + build: buildToolPython, + }, + { + value: "typescript", + labelId: "tools.details.code.tab.typescript", + language: "TypeScript", + prismLanguage: "tsx", + build: buildToolTypescript, + }, +]; diff --git a/src/hooks/useToolInvoke.test.tsx b/src/hooks/useToolInvoke.test.tsx new file mode 100644 index 0000000..4ed516f --- /dev/null +++ b/src/hooks/useToolInvoke.test.tsx @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { ApiError } from "@/api/client"; +import { I18nProvider } from "@/i18n"; +import { ToolInvokeJsonRpcError, toolsApi } from "@/api/tools"; +import { TOOL_INVOKE_TIMEOUT_MS, useToolInvoke } from "./useToolInvoke"; + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/api/tools", async () => { + const actual = await vi.importActual("@/api/tools"); + return { + ...actual, + toolsApi: { invoke: vi.fn(), cancelInvoke: vi.fn() }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + vi.mocked(toolsApi.cancelInvoke).mockResolvedValue(undefined); +}); + +function setup( + toolName = "search", + args: Record = {}, + headers: Record = {}, + timeoutMs?: number, +) { + return renderHook(() => useToolInvoke(toolName, args, headers, timeoutMs), { + wrapper: ({ children }) => {children}, + }); +} + +describe("useToolInvoke", () => { + it("captures successful live invoke results", async () => { + vi.mocked(toolsApi.invoke).mockResolvedValue({ + id: "invoke-1", + result: { content: [{ type: "text", text: "done", mimeType: "text/plain" }] }, + status: 200, + }); + const { result } = setup("search", { query: "cloudflare" }, { "X-Api-Key": "abc" }); + + await act(async () => { + await result.current.run(); + }); + + expect(toolsApi.invoke).toHaveBeenCalledWith( + "search", + { query: "cloudflare" }, + { "X-Api-Key": "abc" }, + expect.objectContaining({ + requestId: expect.stringMatching(/^tool-live-/), + signal: expect.any(AbortSignal), + }), + ); + expect(result.current.result?.status).toBe(200); + expect(result.current.result?.result.content?.[0]?.text).toBe("done"); + expect(result.current.error).toBeNull(); + expect(result.current.hasRun).toBe(true); + expect(toolsApi.cancelInvoke).not.toHaveBeenCalled(); + }); + + it("captures JSON-RPC error codes and messages", async () => { + const { toast } = await import("sonner"); + vi.mocked(toolsApi.invoke).mockRejectedValue( + new ToolInvokeJsonRpcError({ code: -32003, message: "Access denied" }, 200, "invoke-1"), + ); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.error?.message).toBe("Access denied"); + expect(result.current.error?.code).toBe(-32003); + expect(result.current.error?.status).toBeNull(); + expect(result.current.result).toBeNull(); + expect(toast.error).toHaveBeenCalledTimes(1); + }); + + it("captures HTTP ApiError failures", async () => { + vi.mocked(toolsApi.invoke).mockRejectedValue( + new ApiError(403, { detail: "Forbidden" }, "HTTP 403"), + ); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.error?.message).toBe("Forbidden"); + expect(result.current.error?.status).toBe(403); + expect(result.current.error?.code).toBeUndefined(); + }); + + it("resets result and error state when the tool name changes", async () => { + vi.mocked(toolsApi.invoke).mockResolvedValue({ + id: "invoke-1", + result: { content: [] }, + status: 200, + }); + const { result, rerender } = renderHook(({ toolName }) => useToolInvoke(toolName, {}, {}), { + initialProps: { toolName: "search" }, + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current.run(); + }); + expect(result.current.result).not.toBeNull(); + + rerender({ toolName: "lookup" }); + await waitFor(() => expect(result.current.result).toBeNull()); + expect(result.current.error).toBeNull(); + }); + + it("stopWaiting sends MCP cancellation and aborts in-flight requests", async () => { + let capturedSignal: AbortSignal | undefined; + vi.mocked(toolsApi.invoke).mockImplementation((_name, _args, _headers, opts) => { + capturedSignal = opts?.signal; + return new Promise(() => {}); + }); + const { result } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + act(() => { + result.current.stopWaiting(); + }); + + expect(capturedSignal?.aborted).toBe(true); + expect(toolsApi.cancelInvoke).toHaveBeenCalledWith( + expect.stringMatching(/^tool-live-/), + "user", + ); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasRun).toBe(false); + }); + + it("records timeout failures with a distinct message", async () => { + vi.useFakeTimers(); + vi.mocked(toolsApi.invoke).mockImplementation( + (_name, _args, _headers, opts) => + new Promise((_resolve, reject) => { + opts?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted"))); + }), + ); + const { result } = setup("search", {}, {}, TOOL_INVOKE_TIMEOUT_MS); + let runPromise!: Promise; + + act(() => { + runPromise = result.current.run(); + }); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(TOOL_INVOKE_TIMEOUT_MS); + await runPromise; + }); + + expect(toolsApi.cancelInvoke).toHaveBeenCalledWith( + expect.stringMatching(/^tool-live-/), + "timeout", + ); + expect(result.current.error?.timedOut).toBe(true); + expect(result.current.error?.message).toContain("timed out"); + expect(result.current.isLoading).toBe(false); + }); + + it("reset sends best-effort cancellation for active requests", async () => { + let capturedSignal: AbortSignal | undefined; + vi.mocked(toolsApi.invoke).mockImplementation((_name, _args, _headers, opts) => { + capturedSignal = opts?.signal; + return new Promise(() => {}); + }); + const { result } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + act(() => { + result.current.reset(); + }); + + expect(toolsApi.cancelInvoke).toHaveBeenCalledWith( + expect.stringMatching(/^tool-live-/), + "reset", + ); + expect(capturedSignal?.aborted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasRun).toBe(false); + }); +}); diff --git a/src/hooks/useToolInvoke.ts b/src/hooks/useToolInvoke.ts new file mode 100644 index 0000000..b23ddb5 --- /dev/null +++ b/src/hooks/useToolInvoke.ts @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useIntl } from "react-intl"; +import { toast } from "sonner"; + +import { ApiError } from "@/api/client"; +import { + ToolInvokeJsonRpcError, + toolsApi, + type ToolInvokeRequestId, + type ToolPreviewResponse, +} from "@/api/tools"; +import { parseApiError } from "@/lib/errorUtils"; + +export const TOOL_INVOKE_TIMEOUT_MS = 120_000; + +export interface ToolInvokeSuccess { + id: ToolInvokeRequestId | null; + result: ToolPreviewResponse; + renderTimeMs: number; + status: number; +} + +export interface ToolInvokeFailure { + code?: number; + message: string; + renderTimeMs: number; + status: number | null; + timedOut?: boolean; +} + +export interface ToolInvokeState { + run: () => Promise; + reset: () => void; + stopWaiting: () => void; + isLoading: boolean; + result: ToolInvokeSuccess | null; + error: ToolInvokeFailure | null; + hasRun: boolean; +} + +export function useToolInvoke( + toolName: string, + args: Record, + passthroughHeaders: Record, + timeoutMs: number = TOOL_INVOKE_TIMEOUT_MS, +): ToolInvokeState { + const intl = useIntl(); + const [isLoading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const activeRequestIdRef = useRef(null); + const timeoutRef = useRef | null>(null); + const timeoutAbortRef = useRef(false); + + const clearRunTimer = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const cancelActiveRun = useCallback((reason: string) => { + const requestId = activeRequestIdRef.current; + if (requestId === null) return; + activeRequestIdRef.current = null; + void toolsApi.cancelInvoke(requestId, reason).catch(() => undefined); + }, []); + + const abortCurrent = useCallback( + (cancelReason?: string) => { + if (cancelReason) cancelActiveRun(cancelReason); + clearRunTimer(); + abortRef.current?.abort(); + abortRef.current = null; + }, + [cancelActiveRun, clearRunTimer], + ); + + useEffect(() => { + abortCurrent("tool-changed"); + setResult(null); + setError(null); + }, [abortCurrent, toolName]); + + useEffect(() => { + return () => { + abortCurrent("unmount"); + }; + }, [abortCurrent]); + + const reset = useCallback(() => { + timeoutAbortRef.current = false; + abortCurrent("reset"); + setResult(null); + setError(null); + setLoading(false); + }, [abortCurrent]); + + const stopWaiting = useCallback(() => { + timeoutAbortRef.current = false; + abortCurrent("user"); + setLoading(false); + }, [abortCurrent]); + + const run = useCallback(async () => { + abortCurrent("superseded"); + const controller = new AbortController(); + const requestId = `tool-live-${Date.now()}`; + abortRef.current = controller; + activeRequestIdRef.current = requestId; + timeoutAbortRef.current = false; + + timeoutRef.current = setTimeout(() => { + timeoutAbortRef.current = true; + cancelActiveRun("timeout"); + controller.abort(); + }, timeoutMs); + + setLoading(true); + setError(null); + const startedAt = performance.now(); + try { + const { + result: invokeResult, + status, + id, + } = await toolsApi.invoke(toolName, args, passthroughHeaders, { + requestId, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + const renderTimeMs = Math.round(performance.now() - startedAt); + setResult({ id, result: invokeResult, status, renderTimeMs }); + } catch (err) { + const renderTimeMs = Math.round(performance.now() - startedAt); + if (controller.signal.aborted && !timeoutAbortRef.current) return; + if (timeoutAbortRef.current) { + setError({ + message: intl.formatMessage( + { id: "tools.details.invoke.timeout" }, + { seconds: Math.round(timeoutMs / 1000) }, + ), + renderTimeMs, + status: null, + timedOut: true, + }); + setResult(null); + toast.error(intl.formatMessage({ id: "tools.details.invoke.error" })); + return; + } + + const status = err instanceof ApiError ? err.status : null; + const code = err instanceof ToolInvokeJsonRpcError ? err.rpcError.code : undefined; + const message = + err instanceof ToolInvokeJsonRpcError + ? err.message + : parseApiError(err, err instanceof Error ? err.message : "Unknown error"); + setError({ code, message, renderTimeMs, status }); + setResult(null); + toast.error(intl.formatMessage({ id: "tools.details.invoke.error" })); + } finally { + clearRunTimer(); + if (!controller.signal.aborted || timeoutAbortRef.current) { + setLoading(false); + } + if (abortRef.current === controller) { + abortRef.current = null; + } + if (activeRequestIdRef.current === requestId) { + activeRequestIdRef.current = null; + } + timeoutAbortRef.current = false; + } + }, [ + abortCurrent, + args, + cancelActiveRun, + clearRunTimer, + intl, + passthroughHeaders, + timeoutMs, + toolName, + ]); + + return { + run, + reset, + stopWaiting, + isLoading, + result, + error, + hasRun: result !== null || error !== null, + }; +} diff --git a/src/i18n/locales/en-US/tools.json b/src/i18n/locales/en-US/tools.json index bba8838..4c34e51 100644 --- a/src/i18n/locales/en-US/tools.json +++ b/src/i18n/locales/en-US/tools.json @@ -116,6 +116,34 @@ "tools.details.preview.headers.remove": "Remove header {number}", "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.jsonRpc": "JSON-RPC", + "tools.details.code.tab.python": "Python", + "tools.details.code.tab.typescript": "TypeScript", + "tools.details.code.copyAriaLabel": "Copy {language} snippet", + "tools.details.code.mcpVersionBadge": "MCP {version}", + "tools.details.invoke.run": "Live invoke", + "tools.details.invoke.rerun": "Re-run live", + "tools.details.invoke.running": "Invoking...", + "tools.details.invoke.stopWaiting": "Cancel request", + "tools.details.invoke.checkingAccess": "Checking access", + "tools.details.invoke.error": "Tool invocation failed", + "tools.details.invoke.timeout": "Tool invocation timed out after {seconds} seconds. A cancellation request was sent.", + "tools.details.invoke.statusOk": "Live invoke {status}", + "tools.details.invoke.statusError": "Live invoke failed", + "tools.details.invoke.statusErrorWithCode": "Live invoke failed {code}", + "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.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.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.", + "tools.details.invoke.confirm.button": "Invoke tool", + "tools.details.invoke.confirm.cancel": "Cancel", "tools.form.heading.add": "Add tool", "tools.form.heading.edit": "Edit tool", "tools.form.description": "Convert REST API to a tool and expose it for use", diff --git a/src/i18n/locales/es-ES/tools.json b/src/i18n/locales/es-ES/tools.json index 8982a78..655e57f 100644 --- a/src/i18n/locales/es-ES/tools.json +++ b/src/i18n/locales/es-ES/tools.json @@ -116,6 +116,34 @@ "tools.details.preview.headers.remove": "Eliminar encabezado {number}", "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.jsonRpc": "JSON-RPC", + "tools.details.code.tab.python": "Python", + "tools.details.code.tab.typescript": "TypeScript", + "tools.details.code.copyAriaLabel": "Copiar fragmento de {language}", + "tools.details.code.mcpVersionBadge": "MCP {version}", + "tools.details.invoke.run": "Invocación en vivo", + "tools.details.invoke.rerun": "Volver a invocar", + "tools.details.invoke.running": "Invocando...", + "tools.details.invoke.stopWaiting": "Cancelar solicitud", + "tools.details.invoke.checkingAccess": "Comprobando acceso", + "tools.details.invoke.error": "Error al invocar la herramienta", + "tools.details.invoke.timeout": "La invocación de la herramienta agotó el tiempo tras {seconds} segundos. Se envió una solicitud de cancelación.", + "tools.details.invoke.statusOk": "Invocación en vivo {status}", + "tools.details.invoke.statusError": "Error en la invocación en vivo", + "tools.details.invoke.statusErrorWithCode": "Error en la invocación en vivo {code}", + "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.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.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.", + "tools.details.invoke.confirm.button": "Invocar herramienta", + "tools.details.invoke.confirm.cancel": "Cancelar", "tools.form.heading.add": "Añadir herramienta", "tools.form.heading.edit": "Editar herramienta", "tools.form.description": "Convierta una API REST en una herramienta y expóngala para su uso", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index f17770c..4e96e17 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -116,6 +116,34 @@ "tools.details.preview.headers.remove": "Remover cabeçalho {number}", "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.jsonRpc": "JSON-RPC", + "tools.details.code.tab.python": "Python", + "tools.details.code.tab.typescript": "TypeScript", + "tools.details.code.copyAriaLabel": "Copiar snippet de {language}", + "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.running": "Invocando...", + "tools.details.invoke.stopWaiting": "Cancelar solicitação", + "tools.details.invoke.checkingAccess": "Verificando acesso", + "tools.details.invoke.error": "Falha na invocação da ferramenta", + "tools.details.invoke.timeout": "A invocação da ferramenta atingiu o tempo limite após {seconds} segundos. Uma solicitação de cancelamento foi enviada.", + "tools.details.invoke.statusOk": "Invocação em tempo real {status}", + "tools.details.invoke.statusError": "Falha na invocação em tempo real", + "tools.details.invoke.statusErrorWithCode": "Falha na invocação em tempo real {code}", + "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.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.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.", + "tools.details.invoke.confirm.button": "Invocar ferramenta", + "tools.details.invoke.confirm.cancel": "Cancelar", "tools.form.heading.add": "Adicionar ferramenta", "tools.form.heading.edit": "Editar ferramenta", "tools.form.description": "Converta uma API REST em uma ferramenta e exponha-a para uso",