diff --git a/e2e/agents.spec.ts b/e2e/agents.spec.ts index 37acac83..9eeb2ed3 100644 --- a/e2e/agents.spec.ts +++ b/e2e/agents.spec.ts @@ -63,6 +63,70 @@ async function mockAgentMutation( ); } +const FOUND_CARD = { + found: true, + outcome: "found", + detectedVersion: "1.0", + card: { + name: "Visual Router", + description: "Routes visual-production briefs to vendors.", + version: "1.1.0", + supportedInterfaces: [ + { url: "https://agent.example.com/rpc", protocolBinding: "JSONRPC", protocolVersion: "1.0" }, + ], + skills: [ + { id: "fit", name: "Vendor-fit routing" }, + { id: "hybrid", name: "Hybrid production routing" }, + ], + securitySchemes: { bearerAuth: { httpAuthSecurityScheme: { scheme: "bearer" } } }, + securityRequirements: [{ schemes: { bearerAuth: {} } }], + }, + suggestedAgent: { + name: "Visual Router", + description: "Routes visual-production briefs to vendors.", + endpoint_url: "https://agent.example.com/rpc", + protocol_version: "1.0", + version: "1.1.0", + auth_type: "bearer", + }, + warnings: [], +}; + +/** Stubs POST /a2a/fetch-card and records the request bodies. */ +async function mockFetchCard(page: Page, result: unknown, status = 200) { + const bodies: unknown[] = []; + await page.route( + (url) => url.pathname.endsWith("/a2a/fetch-card"), + async (route) => { + bodies.push(route.request().postDataJSON()); + await json(route, result, status); + }, + ); + return bodies; +} + +/** Stubs POST /a2a (register) and records the request bodies. */ +async function mockRegisterAgent(page: Page, respond: (route: Route) => Promise | void) { + const bodies: Array> = []; + await page.route( + (url) => /\/a2a\/?$/.test(url.pathname), + async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + bodies.push(route.request().postDataJSON()); + return respond(route); + }, + ); + return bodies; +} + +async function startConnect(page: Page, url = "https://agent.example.com") { + await page.getByRole("button", { name: /Connect agent/ }).click(); + await page.getByPlaceholder("Add URL for a running agent...").fill(url); + await page.getByRole("button", { name: "Continue" }).click(); +} + +const CARD_UNAVAILABLE = "Agent card not available: add the details for a running agent manually."; + const toast = (page: Page, text: RegExp) => page.locator("[data-sonner-toast]").filter({ hasText: text }); @@ -116,6 +180,278 @@ test.describe("Agents page", () => { }); }); + test.describe("connect an agent that publishes a card", () => { + test("reads the card, collects the key and registers the agent", async ({ page }) => { + let registered = false; + await mockAgentList(page, () => + registered ? [makeAgent("visual-router", { name: "Visual Router" })] : [], + ); + const fetchBodies = await mockFetchCard(page, FOUND_CARD); + const createBodies = await mockRegisterAgent(page, async (route) => { + registered = true; + await json(route, makeAgent("visual-router", { name: "Visual Router" }), 201); + }); + + await page.goto(APP.AGENTS); + await startConnect(page); + + // Review: what the card declares + await expect(page.getByRole("heading", { name: "Visual Router" })).toBeVisible(); + await expect(page.getByText("agent v1.1.0")).toBeVisible(); + await expect(page.getByText("Skills (2)")).toBeVisible(); + await expect(page.getByText("Vendor-fit routing")).toBeVisible(); + await expect(page.getByText("agent.example.com", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Authenticate and register + await expect(page.getByRole("heading", { name: "Authenticate agent" })).toBeVisible(); + await page.getByLabel(/Bearer token/i).fill("s3cret"); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(toast(page, /Connected Visual Router/)).toBeVisible(); + await expect(page.getByText("Visual Router", { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder("Add URL for a running agent...")).toBeHidden(); + + expect(fetchBodies).toEqual([{ baseUrl: "https://agent.example.com" }]); + expect(createBodies).toHaveLength(1); + expect(createBodies[0]).toMatchObject({ + agent: { + name: "Visual Router", + endpoint_url: "https://agent.example.com/rpc", + auth_type: "bearer", + auth_token: "s3cret", + }, + visibility: "public", + }); + }); + + test("does not register until the required key is entered", async ({ page }) => { + await mockAgentList(page, []); + await mockFetchCard(page, FOUND_CARD); + const createBodies = await mockRegisterAgent(page, (route) => json(route, {}, 201)); + + await page.goto(APP.AGENTS); + await startConnect(page); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByText("Enter the credentials the agent requires.")).toBeVisible(); + expect(createBodies).toHaveLength(0); + }); + + test("shows the server's reason and keeps the form when registration fails", async ({ + page, + }) => { + await mockAgentList(page, []); + await mockFetchCard(page, FOUND_CARD); + await mockRegisterAgent(page, (route) => + json(route, { detail: "Agent name already exists" }, 409), + ); + + await page.goto(APP.AGENTS); + await startConnect(page); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByLabel(/Bearer token/i).fill("s3cret"); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByText("Agent name already exists")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Authenticate agent" })).toBeVisible(); + }); + + test("goes back from the review to the URL step with the URL kept", async ({ page }) => { + await mockAgentList(page, []); + await mockFetchCard(page, FOUND_CARD); + + await page.goto(APP.AGENTS); + await startConnect(page, "https://agent.example.com"); + await expect(page.getByRole("heading", { name: "Visual Router" })).toBeVisible(); + await page.getByRole("button", { name: "Back" }).click(); + + await expect(page.getByPlaceholder("Add URL for a running agent...")).toHaveValue( + "https://agent.example.com", + ); + }); + + test("returns to the list on Cancel without registering anything", async ({ page }) => { + await mockAgentList(page, [ALPHA]); + const createBodies = await mockRegisterAgent(page, (route) => json(route, {}, 201)); + + await page.goto(APP.AGENTS); + await page.getByRole("button", { name: /Connect agent/ }).click(); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByText("Agent alpha")).toBeVisible(); + expect(createBodies).toHaveLength(0); + }); + + test("rejects a URL that is not http(s) without calling the gateway", async ({ page }) => { + await mockAgentList(page, []); + const fetchBodies = await mockFetchCard(page, FOUND_CARD); + + await page.goto(APP.AGENTS); + await startConnect(page, "ftp://agent.example.com"); + + await expect(page.getByText("Enter a valid http or https URL.")).toBeVisible(); + expect(fetchBodies).toHaveLength(0); + }); + + for (const [name, respond] of [ + ["publishes no card", (route: Route) => json(route, { found: false, outcome: "no_card" })], + [ + "needs credentials to read its card", + (route: Route) => + json(route, { found: false, outcome: "auth_required", authScheme: "Bearer" }), + ], + [ + "cannot be reached", + (route: Route) => json(route, { found: false, outcome: "unreachable" }), + ], + [ + "is refused by the gateway", + (route: Route) => json(route, { detail: "Insufficient permissions" }, 403), + ], + ] as const) { + test(`says the card is not available and opens manual entry when the agent ${name}`, async ({ + page, + }) => { + await mockAgentList(page, []); + await page.route( + (url) => url.pathname.endsWith("/a2a/fetch-card"), + (route) => respond(route), + ); + + await page.goto(APP.AGENTS); + await startConnect(page); + + await expect(page.getByText(CARD_UNAVAILABLE)).toBeVisible(); + await expect(page.getByRole("button", { name: "Enter manually" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByPlaceholder("Add an agent name")).toBeVisible(); + }); + } + }); + + test.describe("enter an agent manually", () => { + const fillRequired = async (page: Page) => { + await page.getByPlaceholder("Add an agent name").fill("Echo agent"); + await page.getByPlaceholder("https://agents.example.com/a2a").fill("http://127.0.0.1:9100"); + }; + + test("is collapsed until expanded", async ({ page }) => { + await mockAgentList(page, []); + + await page.goto(APP.AGENTS); + await page.getByRole("button", { name: /Connect agent/ }).click(); + + const toggle = page.getByRole("button", { name: "Enter manually" }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByPlaceholder("Add an agent name")).toBeHidden(); + + await toggle.click(); + + await expect(page.getByPlaceholder("Add an agent name")).toBeVisible(); + await expect(page.getByRole("radio", { name: "JSON-RPC" })).toBeChecked(); + await expect(page.getByRole("radio", { name: "A2A 1.0" })).toBeChecked(); + await expect(page.getByRole("radio", { name: "None" })).toBeChecked(); + }); + + test("registers an agent from the details, with credentials", async ({ page }) => { + let registered = false; + await mockAgentList(page, () => + registered ? [makeAgent("echo", { name: "Echo agent" })] : [], + ); + const fetchBodies = await mockFetchCard(page, { found: false, outcome: "no_card" }); + const createBodies = await mockRegisterAgent(page, async (route) => { + registered = true; + await json(route, makeAgent("echo", { name: "Echo agent" }), 201); + }); + + await page.goto(APP.AGENTS); + await startConnect(page, "https://agent.example.com/agent.json"); + await expect(page.getByText(CARD_UNAVAILABLE)).toBeVisible(); + + await fillRequired(page); + await page.getByRole("radio", { name: "A2A 0.3" }).click(); + // The radio is visually hidden; users click its tab label. + await page.locator('label[for="agent-auth-bearer"]').click(); + await page.getByPlaceholder("Paste bearer token...").fill("s3cret"); + await page.getByPlaceholder("Add optional tags separated with commas").fill("testing, echo"); + await page.getByPlaceholder("Add an optional description...").fill("Echoes text back"); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(toast(page, /Connected Echo agent/)).toBeVisible(); + await expect(page.getByText("Echo agent", { exact: true })).toBeVisible(); + + expect(fetchBodies).toHaveLength(1); + expect(createBodies).toHaveLength(1); + expect(createBodies[0]).toMatchObject({ + agent: { + name: "Echo agent", + endpoint_url: "http://127.0.0.1:9100", + protocol_version: "0.3", + auth_type: "bearer", + auth_token: "s3cret", + description: "Echoes text back", + tags: ["testing", "echo"], + }, + visibility: "public", + }); + }); + + test("registers without reading a card or needing the URL", async ({ page }) => { + await mockAgentList(page, []); + const fetchBodies = await mockFetchCard(page, FOUND_CARD); + const createBodies = await mockRegisterAgent(page, (route) => + json(route, makeAgent("echo", { name: "Echo agent" }), 201), + ); + + await page.goto(APP.AGENTS); + await page.getByRole("button", { name: /Connect agent/ }).click(); + await page.getByRole("button", { name: "Enter manually" }).click(); + await fillRequired(page); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(toast(page, /Connected Echo agent/)).toBeVisible(); + expect(fetchBodies).toHaveLength(0); + expect(createBodies).toHaveLength(1); + }); + + test("needs a name and a valid endpoint before registering", async ({ page }) => { + await mockAgentList(page, []); + const createBodies = await mockRegisterAgent(page, (route) => json(route, {}, 201)); + + await page.goto(APP.AGENTS); + await page.getByRole("button", { name: /Connect agent/ }).click(); + await page.getByRole("button", { name: "Enter manually" }).click(); + await page.getByPlaceholder("https://agents.example.com/a2a").fill("ftp://nope"); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByText("Enter a name for the agent.")).toBeVisible(); + await expect(page.getByText("Enter a valid http or https endpoint URL.")).toBeVisible(); + expect(createBodies).toHaveLength(0); + }); + + test("shows the server's reason and keeps the form when registration fails", async ({ + page, + }) => { + await mockAgentList(page, []); + await mockRegisterAgent(page, (route) => + json(route, { detail: "Agent name already exists" }, 409), + ); + + await page.goto(APP.AGENTS); + await page.getByRole("button", { name: /Connect agent/ }).click(); + await page.getByRole("button", { name: "Enter manually" }).click(); + await fillRequired(page); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByText("Agent name already exists")).toBeVisible(); + await expect(page.getByPlaceholder("Add an agent name")).toHaveValue("Echo agent"); + }); + }); + test.describe("list", () => { test("renders a card per agent with its status indicator", async ({ page }) => { await mockAgentList(page, [ALPHA, BETA]); @@ -128,6 +464,34 @@ test.describe("Agents page", () => { await expect(page.getByRole("img", { name: "Inactive", exact: true })).toHaveCount(1); }); + test("shows the agent's skills with a count, and not its tags", async ({ page }) => { + await mockAgentList(page, [ + makeAgent("skilled", { + name: "Skilled agent", + tags: [{ id: "billing", label: "billing" }], + capabilities: { + skills: [ + { id: "echo", name: "Echo" }, + { id: "route", name: "Route" }, + ], + }, + }), + makeAgent("plain", { name: "Plain agent", tags: [{ id: "ops", label: "ops" }] }), + ]); + + await page.goto(APP.AGENTS); + + const skilled = page.locator('[data-slot="card"]').filter({ hasText: "Skilled agent" }); + await expect(skilled.getByText("2 skills")).toBeVisible(); + await expect(skilled.getByText("Echo")).toBeVisible(); + await expect(skilled.getByText("Route")).toBeVisible(); + await expect(page.getByText("billing")).toBeHidden(); + await expect(page.getByText("ops", { exact: true })).toBeHidden(); + + const plain = page.locator('[data-slot="card"]').filter({ hasText: "Plain agent" }); + await expect(plain.getByText(/skills?$/)).toBeHidden(); + }); + test("requests disabled agents too (include_inactive=true)", async ({ page }) => { let requestedUrl = ""; await page.route( diff --git a/src/api/agents.test.ts b/src/api/agents.test.ts new file mode 100644 index 00000000..edf33a7e --- /dev/null +++ b/src/api/agents.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { agentsApi } from "./agents"; +import { setCsrfToken } from "./client"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +describe("agentsApi", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + vi.clearAllMocks(); + setCsrfToken("test-csrf-token"); + }); + + afterEach(() => { + setCsrfToken(null); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("fetchCard", () => { + it("POSTs the URL as baseUrl to /v1/a2a/fetch-card and returns the result", async () => { + const result = { found: false, outcome: "no_card" }; + mockFetch.mockResolvedValueOnce(json(result)); + + await expect(agentsApi.fetchCard("https://agent.example.com")).resolves.toEqual(result); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/a2a/fetch-card"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ baseUrl: "https://agent.example.com" }), + }), + ); + }); + }); + + describe("create", () => { + it("POSTs the embedded body (agent, visibility, team_id) to /v1/a2a", async () => { + const body = { + agent: { name: "Alpha", endpoint_url: "https://agent.example.com/rpc" }, + visibility: "team", + team_id: "team-1", + }; + mockFetch.mockResolvedValueOnce(json({ id: "a1", name: "Alpha" }, 201)); + + await agentsApi.create(body); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v1\/a2a$/), + expect.objectContaining({ method: "POST", body: JSON.stringify(body) }), + ); + }); + }); + + describe("setState / delete", () => { + it("POSTs /v1/a2a/:id/state with the activate flag", async () => { + mockFetch.mockResolvedValueOnce(json({ id: "a1", enabled: false })); + + await agentsApi.setState("a1", false); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/a2a/a1/state?activate=false"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("DELETEs /v1/a2a/:id", async () => { + mockFetch.mockResolvedValueOnce(json({ status: "success" })); + + await agentsApi.delete("a1"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v1\/a2a\/a1$/), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it.each([ + ["", "Invalid agent ID"], + ["../etc", "Invalid agent ID format"], + ["a/b", "Invalid agent ID format"], + ])("rejects the unsafe id %j before any request", (id, message) => { + expect(() => agentsApi.delete(id)).toThrow(message); + expect(() => agentsApi.setState(id, true)).toThrow(message); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/api/agents.ts b/src/api/agents.ts index 7de454f7..85d35098 100644 --- a/src/api/agents.ts +++ b/src/api/agents.ts @@ -3,7 +3,8 @@ */ import { api } from "./client"; -import type { A2AAgentRead } from "@/generated/types"; +import type { A2AAgentRead, BodyCreateA2aAgentV1A2aPost } from "@/generated/types"; +import type { FetchCardResult } from "@/types/agentCard"; /** * Validates an agent ID to prevent path traversal and injection attacks @@ -28,6 +29,25 @@ function validateAgentId(id: string): string { } export const agentsApi = { + /** + * Read an Agent Card from an endpoint that is not registered yet. + * + * A reachable host that publishes no card, one that wants credentials, and an + * unreachable one all come back as 200 with a distinct `outcome`; only a + * missing permission or a malformed request is an error. + */ + fetchCard: (baseUrl: string): Promise => { + return api.post("/v1/a2a/fetch-card", { baseUrl }); + }, + + /** + * Register an agent. The route embeds its body parameters, so the agent + * fields travel under `agent` beside `team_id` and `visibility`. + */ + create: (body: BodyCreateA2aAgentV1A2aPost): Promise => { + return api.post("/v1/a2a", body); + }, + /** * Enable or disable an agent. Returns the agent as stored after the change. */ diff --git a/src/components/agents/ConnectAgentForm.test.tsx b/src/components/agents/ConnectAgentForm.test.tsx new file mode 100644 index 00000000..9f6225b7 --- /dev/null +++ b/src/components/agents/ConnectAgentForm.test.tsx @@ -0,0 +1,704 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("@/auth/AuthContext", () => ({ + useAuthContext: () => ({ + user: { email: "test@example.com", is_admin: true, is_active: true }, + isAuthenticated: true, + isLoading: false, + selectedTeamId: null, + login: vi.fn(), + logout: vi.fn(), + setSelectedTeamId: vi.fn(), + hasPermission: () => true, + permissionsLoading: false, + }), +})); + +import userEvent from "@testing-library/user-event"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/test/mocks/server"; +import { renderWithProviders } from "@/test/test-utils"; +import { ConnectAgentForm } from "./ConnectAgentForm"; +import type { FetchCardResult, SuggestedAgent } from "@/types/agentCard"; + +const CARD_URL = "https://agent.example.com"; +const URL_PLACEHOLDER = "Add URL for a running agent..."; +const CARD_UNAVAILABLE = "Agent card not available: add the details for a running agent manually."; +const NAME_PLACEHOLDER = "Add an agent name"; +const ENDPOINT_PLACEHOLDER = "https://agents.example.com/a2a"; + +function foundResult(suggested: SuggestedAgent = {}, warnings: string[] = []): FetchCardResult { + return { + found: true, + outcome: "found", + sourceUrl: `${CARD_URL}/.well-known/agent-card.json`, + detectedVersion: "1.0", + card: { + name: "Visual Router", + description: "Routes briefs to vendors.", + version: "1.1.0", + supportedInterfaces: [ + { + url: "https://agent.example.com/rpc", + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + }, + { + url: "https://agent.example.com/http", + protocolBinding: "HTTP+JSON", + protocolVersion: "1.0", + }, + ], + skills: [ + { id: "fit", name: "Vendor-fit routing" }, + { id: "hybrid", name: "Hybrid production routing" }, + ], + securitySchemes: { bearerAuth: { httpAuthSecurityScheme: { scheme: "bearer" } } }, + securityRequirements: [{ schemes: { bearerAuth: {} } }], + }, + suggestedAgent: { + name: "Visual Router", + description: "Routes briefs to vendors.", + endpoint_url: "https://agent.example.com/rpc", + protocol_version: "1.0", + version: "1.1.0", + capabilities: { streaming: false, skills: [] }, + ...suggested, + }, + warnings, + }; +} + +function mockFetchCard(result: FetchCardResult | (() => Response)) { + server.use( + http.post("/api/v1/a2a/fetch-card", () => + typeof result === "function" ? result() : HttpResponse.json(result), + ), + ); +} + +/** Captures the body of POST /api/v1/a2a. */ +function mockCreate( + respond: () => Response = () => + HttpResponse.json({ id: "a1", name: "Visual Router" }, { status: 201 }), +) { + const captured: { body?: Record; count: number } = { count: 0 }; + server.use( + http.post("/api/v1/a2a", async ({ request }) => { + captured.count += 1; + captured.body = (await request.json()) as Record; + return respond(); + }), + ); + return captured; +} + +async function submitUrl(user: ReturnType, url = CARD_URL) { + await user.type(await screen.findByPlaceholderText(URL_PLACEHOLDER), url); + await user.click(screen.getByRole("button", { name: "Continue" })); +} + +describe("ConnectAgentForm", () => { + const onCancel = vi.fn(); + const onCreated = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + function setup() { + renderWithProviders(); + return userEvent.setup(); + } + + describe("URL step", () => { + it("rejects a value that is not an http(s) URL without calling the API", async () => { + let fetched = false; + server.use( + http.post("/api/v1/a2a/fetch-card", () => { + fetched = true; + return HttpResponse.json({}); + }), + ); + const user = setup(); + + await submitUrl(user, "ftp://agent.example.com"); + + expect(await screen.findByText("Enter a valid http or https URL.")).toBeInTheDocument(); + expect(fetched).toBe(false); + }); + + it("sends the trimmed URL as baseUrl", async () => { + let body: unknown; + server.use( + http.post("/api/v1/a2a/fetch-card", async ({ request }) => { + body = await request.json(); + return HttpResponse.json(foundResult()); + }), + ); + const user = setup(); + + await submitUrl(user, ` ${CARD_URL} `); + + await screen.findByText("Visual Router"); + expect(body).toEqual({ baseUrl: CARD_URL }); + }); + + it.each([ + ["no card is published", () => HttpResponse.json({ found: false, outcome: "no_card" })], + [ + "credentials are needed to read it", + () => HttpResponse.json({ found: false, outcome: "auth_required", authScheme: "Bearer" }), + ], + [ + "the agent is unreachable", + () => HttpResponse.json({ found: false, outcome: "unreachable" }), + ], + [ + "the request is refused", + () => HttpResponse.json({ detail: "Insufficient permissions" }, { status: 403 }), + ], + ["the gateway errors", () => HttpResponse.json({ detail: "boom" }, { status: 500 })], + ])("says the card is not available and opens manual entry when %s", async (_name, respond) => { + mockFetchCard(respond); + const user = setup(); + + await submitUrl(user); + + expect(await screen.findByText(CARD_UNAVAILABLE)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Enter manually" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).toBeVisible(); + expect(screen.queryByText("Visual Router")).not.toBeInTheDocument(); + // The server's own wording is not shown; the message is the same for every failure. + expect(screen.queryByText("Insufficient permissions")).not.toBeInTheDocument(); + }); + + it("clears the message once the URL is edited, but keeps manual entry open", async () => { + mockFetchCard({ found: false, outcome: "no_card" }); + const user = setup(); + + await submitUrl(user); + await screen.findByText(CARD_UNAVAILABLE); + await user.type(screen.getByPlaceholderText(URL_PLACEHOLDER), "/x"); + + expect(screen.queryByText(CARD_UNAVAILABLE)).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).toBeVisible(); + }); + + it("leaves the form from Cancel and from Back", async () => { + const user = setup(); + + await user.click(await screen.findByRole("button", { name: "Cancel" })); + await user.click(screen.getByRole("button", { name: "Back" })); + + expect(onCancel).toHaveBeenCalledTimes(2); + }); + }); + + describe("review step", () => { + it("shows what the card declares", async () => { + mockFetchCard(foundResult()); + const user = setup(); + + await submitUrl(user); + + expect(await screen.findByText("Visual Router")).toBeInTheDocument(); + expect(screen.getByText("agent v1.1.0")).toBeInTheDocument(); + expect(screen.getByText("Routes briefs to vendors.")).toBeInTheDocument(); + expect(screen.getByText("Skills (2)")).toBeInTheDocument(); + expect(screen.getByText("Vendor-fit routing")).toBeInTheDocument(); + expect(screen.getByText("A2A v1.0")).toBeInTheDocument(); + expect(screen.getByText("agent.example.com")).toBeInTheDocument(); + expect(screen.getByText("JSONRPC 1.0 (preferred), HTTP+JSON 1.0")).toBeInTheDocument(); + expect(screen.getByText("bearer")).toBeInTheDocument(); + }); + + it("lists the card's warnings under View more", async () => { + mockFetchCard( + foundResult({}, ["Security scheme 'oauth' is an alternative that was not selected."]), + ); + const user = setup(); + + await submitUrl(user); + await user.click(await screen.findByRole("button", { name: "View more" })); + + expect( + await screen.findByText("Security scheme 'oauth' is an alternative that was not selected."), + ).toBeInTheDocument(); + }); + + it("goes back to the URL step with the URL kept", async () => { + mockFetchCard(foundResult()); + const user = setup(); + + await submitUrl(user); + await screen.findByText("Visual Router"); + await user.click(screen.getByRole("button", { name: "Back" })); + + expect(await screen.findByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(CARD_URL); + expect(onCancel).not.toHaveBeenCalled(); + }); + }); + + describe("authenticate step", () => { + async function toAuthStep(user: ReturnType) { + await submitUrl(user); + await user.click(await screen.findByRole("button", { name: "Continue" })); + } + + it("registers a bearer agent with the key, defaulting to internal visibility", async () => { + mockFetchCard(foundResult({ auth_type: "bearer" })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + expect(await screen.findByText("Authenticate agent")).toBeInTheDocument(); + await user.type(screen.getByLabelText(/Bearer token/i), "s3cret"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)); + expect(created.body).toEqual({ + agent: expect.objectContaining({ + name: "Visual Router", + endpoint_url: "https://agent.example.com/rpc", + protocol_version: "1.0", + auth_type: "bearer", + auth_token: "s3cret", + }), + visibility: "public", + }); + }); + + it("blocks registration until the required credentials are entered", async () => { + mockFetchCard(foundResult({ auth_type: "bearer" })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + await user.click(await screen.findByRole("button", { name: "Connect" })); + + expect( + await screen.findByText("Enter the credentials the agent requires."), + ).toBeInTheDocument(); + expect(created.count).toBe(0); + }); + + it("sends username and password for basic auth", async () => { + mockFetchCard(foundResult({ auth_type: "basic" })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + await user.type(await screen.findByLabelText(/Username/i), "alice"); + await user.type(screen.getByLabelText(/Password/i), "pw"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect((created.body?.agent as Record).auth_username).toBe("alice"); + expect((created.body?.agent as Record).auth_password).toBe("pw"); + }); + + it("prefills the header name for an API key sent in a header", async () => { + mockFetchCard(foundResult({ auth_type: "authheaders", auth_header_key: "X-API-Key" })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + expect(await screen.findByDisplayValue("X-API-Key")).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText("Add header value..."), "k-123"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect((created.body?.agent as Record).auth_headers).toEqual([ + { key: "X-API-Key", value: "k-123" }, + ]); + }); + + it("needs no credentials when the card declares none", async () => { + mockFetchCard(foundResult({ auth_type: null })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + expect(await screen.findByText("Agent settings")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + const agent = created.body?.agent as Record; + expect(agent).not.toHaveProperty("auth_token"); + expect(agent).not.toHaveProperty("auth_password"); + }); + + it("cannot register an OAuth 2.0 agent from this form", async () => { + mockFetchCard( + foundResult({ auth_type: "oauth", oauth_config: { grant_type: "client_credentials" } }), + ); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + + expect( + await screen.findByText(/uses OAuth 2.0, which can't be set up from this form yet/), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + expect(created.count).toBe(0); + }); + + it("sends the chosen visibility", async () => { + mockFetchCard(foundResult({ auth_type: null })); + const created = mockCreate(); + const user = setup(); + + await toAuthStep(user); + await user.click(await screen.findByRole("combobox", { name: /Visibility/ })); + await user.click(await screen.findByRole("option", { name: "Private" })); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(created.body?.visibility).toBe("private"); + expect(created.body).not.toHaveProperty("team_id"); + }); + + it("keeps the form and shows the server's reason when registration fails", async () => { + mockFetchCard(foundResult({ auth_type: null })); + mockCreate(() => HttpResponse.json({ detail: "Agent name already exists" }, { status: 409 })); + const user = setup(); + + await toAuthStep(user); + await user.click(await screen.findByRole("button", { name: "Connect" })); + + expect(await screen.findByText("Agent name already exists")).toBeInTheDocument(); + expect(onCreated).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + }); + }); + describe("manual entry", () => { + async function openManual(user: ReturnType) { + await user.click(await screen.findByRole("button", { name: "Enter manually" })); + await screen.findByPlaceholderText(NAME_PLACEHOLDER); + } + + async function fillRequired(user: ReturnType) { + await user.type(screen.getByPlaceholderText(NAME_PLACEHOLDER), "Echo agent"); + await user.type(screen.getByPlaceholderText(ENDPOINT_PLACEHOLDER), "http://127.0.0.1:9100"); + } + + const agentOf = (created: { body?: Record }) => + created.body?.agent as Record; + + it("is collapsed until expanded", async () => { + const user = setup(); + + const toggle = await screen.findByRole("button", { name: "Enter manually" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByPlaceholderText(NAME_PLACEHOLDER)).not.toBeInTheDocument(); + + await user.click(toggle); + + expect(await screen.findByPlaceholderText(NAME_PLACEHOLDER)).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument(); + }); + + it("registers an agent from just a name and an endpoint, without reading a card", async () => { + let fetched = false; + server.use( + http.post("/api/v1/a2a/fetch-card", () => { + fetched = true; + return HttpResponse.json({}); + }), + ); + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)); + expect(fetched).toBe(false); + expect(created.body).toEqual({ + agent: { + name: "Echo agent", + endpoint_url: "http://127.0.0.1:9100", + protocol_version: "1.0", + }, + visibility: "public", + }); + }); + + it("needs a name and a valid endpoint, and does not register until both are given", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await user.type(screen.getByPlaceholderText(ENDPOINT_PLACEHOLDER), "ftp://nope"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + expect(await screen.findByText("Enter a name for the agent.")).toBeInTheDocument(); + expect(screen.getByText("Enter a valid http or https endpoint URL.")).toBeInTheDocument(); + expect(created.count).toBe(0); + }); + + it("does not need the card URL", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(""); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(created.count).toBe(1)); + }); + + it("sends the optional description and tags, trimming and de-duplicating the tags", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.type( + screen.getByPlaceholderText("Add optional tags separated with commas"), + " testing, echo ,, testing ", + ); + await user.type( + screen.getByPlaceholderText("Add an optional description..."), + " Echoes text back ", + ); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ + description: "Echoes text back", + tags: ["testing", "echo"], + }); + }); + + it("leaves description and tags out when they are blank", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.type( + screen.getByPlaceholderText("Add optional tags separated with commas"), + " , ", + ); + await user.type(screen.getByPlaceholderText("Add an optional description..."), " "); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).not.toHaveProperty("description"); + expect(agentOf(created)).not.toHaveProperty("tags"); + }); + + it("labels the description for screen readers", async () => { + const user = setup(); + + await openManual(user); + + expect(screen.getByLabelText("Description")).toBe( + screen.getByPlaceholderText("Add an optional description..."), + ); + }); + + it("sends the chosen protocol version", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "A2A 0.3" })); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created).protocol_version).toBe("0.3"); + }); + + it("offers JSON-RPC only, since HTTP+JSON is not supported yet", async () => { + const user = setup(); + + await openManual(user); + + expect(screen.getByRole("radio", { name: "JSON-RPC" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "HTTP+JSON" })).toBeDisabled(); + }); + + it("sends the chosen visibility", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("combobox", { name: /Visibility/ })); + await user.click(await screen.findByRole("option", { name: "Private" })); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(created.body?.visibility).toBe("private"); + }); + + describe("authentication", () => { + it("starts on None and sends no credentials", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + expect(screen.getByRole("radio", { name: "None" })).toBeChecked(); + await fillRequired(user); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).not.toHaveProperty("auth_type"); + }); + + it("sends a bearer token, and needs it", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "Bearer token" })); + await user.click(screen.getByRole("button", { name: "Connect" })); + expect(await screen.findByText("Enter the credentials the agent requires.")).toBeVisible(); + expect(created.count).toBe(0); + + await user.type(screen.getByPlaceholderText("Paste bearer token..."), "s3cret"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ auth_type: "bearer", auth_token: "s3cret" }); + }); + + it("sends basic credentials", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "Basic" })); + await user.type(screen.getByLabelText(/Username/i), "alice"); + await user.type(screen.getByLabelText(/Password/i), "pw"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ + auth_type: "basic", + auth_username: "alice", + auth_password: "pw", // pragma: allowlist secret + }); + }); + + it("starts custom headers with one empty row and sends the header", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "Custom headers" })); + await user.type(screen.getByPlaceholderText("e.g. X-API-Key..."), "X-API-Key"); + await user.type(screen.getByPlaceholderText("Add header value..."), "k-123"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ + auth_type: "authheaders", + auth_headers: [{ key: "X-API-Key", value: "k-123" }], + }); + }); + + it("sends a query parameter key", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "Query parameter" })); + await user.type(screen.getByPlaceholderText("e.g. api_key..."), "api_key"); + await user.type(screen.getByPlaceholderText("e.g. a1b2c3d4e5f6789..."), "k-123"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ + auth_type: "query_param", + auth_query_param_key: "api_key", + auth_query_param_value: "k-123", + }); + }); + + it("sends an OAuth 2.0 client-credentials configuration", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "OAuth 2.0" })); + await user.type(screen.getByLabelText(/Token URL/), "https://auth.example.com/token"); + await user.type(screen.getByLabelText(/Client ID/), "client-1"); + await user.type(screen.getByLabelText(/Client secret/), "shh"); + await user.type(screen.getByLabelText("Scopes"), "read, write"); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).toMatchObject({ + auth_type: "oauth", + oauth_config: { + grant_type: "client_credentials", + client_id: "client-1", + client_secret: "shh", // pragma: allowlist secret + token_url: "https://auth.example.com/token", + scopes: ["read", "write"], + }, + }); + }); + + it("sends only the credentials of the tab that is selected", async () => { + const created = mockCreate(); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("radio", { name: "Bearer token" })); + await user.type(screen.getByPlaceholderText("Paste bearer token..."), "s3cret"); + await user.click(screen.getByRole("radio", { name: "None" })); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(agentOf(created)).not.toHaveProperty("auth_token"); + }); + }); + + it("keeps the form and shows the server's reason when registration fails", async () => { + mockCreate(() => HttpResponse.json({ detail: "Agent name already exists" }, { status: 409 })); + const user = setup(); + + await openManual(user); + await fillRequired(user); + await user.click(screen.getByRole("button", { name: "Connect" })); + + expect(await screen.findByText("Agent name already exists")).toBeInTheDocument(); + expect(onCreated).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + }); + + it("can be used after the card could not be read", async () => { + mockFetchCard({ found: false, outcome: "no_card" }); + const created = mockCreate(); + const user = setup(); + + await submitUrl(user); + await screen.findByText(CARD_UNAVAILABLE); + await fillRequired(user); + await user.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)); + expect(agentOf(created)).toMatchObject({ name: "Echo agent" }); + }); + }); +}); diff --git a/src/components/agents/ConnectAgentForm.tsx b/src/components/agents/ConnectAgentForm.tsx new file mode 100644 index 00000000..cc8820e0 --- /dev/null +++ b/src/components/agents/ConnectAgentForm.tsx @@ -0,0 +1,424 @@ +import type { ReactNode } from "react"; +import { useIntl } from "react-intl"; +import { Loader2 } from "lucide-react"; +import { AgentIcon } from "@/components/icons/AgentIcon"; +import { BasicAuth } from "@/components/mcp-servers/BasicAuth"; +import { BearerTokenAuth } from "@/components/mcp-servers/BearerTokenAuth"; +import { CustomHeadersAuth } from "@/components/mcp-servers/CustomHeadersAuth"; +import { QueryParameterAuth } from "@/components/mcp-servers/QueryParameterAuth"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { BackButton } from "@/components/ui/back-button"; +import { Button } from "@/components/ui/button"; +import { Callout } from "@/components/ui/callout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Field } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { TagInput } from "@/components/ui/tag-input"; +import { useConnectAgentForm } from "@/hooks/useConnectAgentForm"; +import { useTagSuggestions } from "@/hooks/useTagSuggestions"; +import { MAX_TAGS } from "@/utils/tags"; +import type { A2AAgentRead } from "@/generated/types"; +import { ManualAgentFields } from "./ManualAgentFields"; +import { VisibilityFields } from "./VisibilityFields"; +import { summarizeEndpoint, summarizeInterfaces, summarizeRequirements } from "./agentCardSummary"; + +interface ConnectAgentFormProps { + /** Leave the form without registering anything. */ + onCancel: () => void; + /** The agent was registered. */ + onCreated: (agent: A2AAgentRead) => void; +} + +const INPUT_CLASS = + "rounded-md border-neutral-300 px-4 text-sm text-neutral-900 shadow-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 placeholder:text-neutral-400 dark:border-neutral-700 dark:text-neutral-100 dark:placeholder:text-neutral-500"; + +function StepHeader({ title, badge }: { title: string; badge?: ReactNode }) { + return ( +
+
+
+

{title}

+ {badge} +
+ ); +} + +function ExpandableSection({ + label, + children, + open, + onOpenChange, +}: { + label: string; + children: ReactNode; + /** Controlled when given; otherwise the section manages itself. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const controlled = open !== undefined; + return ( + onOpenChange?.(value === "section"), + } + : {})} + > + + + {label} + + {children} + + + ); +} + +function ViewMore({ children }: { children: ReactNode }) { + const intl = useIntl(); + return ( + + {children} + + ); +} + +function FormActions({ + onCancel, + onContinue, + continueLabel, + isBusy = false, + continueDisabled = false, +}: { + onCancel: () => void; + onContinue: () => void; + continueLabel: string; + isBusy?: boolean; + continueDisabled?: boolean; +}) { + const intl = useIntl(); + return ( +
+ + +
+ ); +} + +export function ConnectAgentForm({ onCancel, onCreated }: ConnectAgentFormProps) { + const intl = useIntl(); + const form = useConnectAgentForm({ onCreated }); + const tagSuggestions = useTagSuggestions(); + const card = form.result?.card; + + const handleBack = () => { + if (!form.goBack()) onCancel(); + }; + + return ( +
+ + + {form.step === "url" && ( + + +
{ + event.preventDefault(); + // With "Enter manually" open the form registers the details typed + // there; otherwise it reads the card at the URL. + void (form.manualOpen ? form.submitManual() : form.submitUrl()); + }} + > + +

+ {intl.formatMessage({ id: "agents.connect.url.intro" })} +

+ + + {(controlProps) => ( + form.setUrl(event.target.value)} + placeholder={intl.formatMessage({ id: "agents.connect.url.placeholder" })} + className={INPUT_CLASS} + /> + )} + + + {form.cardUnavailable && ( + + {intl.formatMessage({ id: "agents.connect.outcome.noCard" })} + + )} + + + + + + {form.manualOpen && form.errors.submit && ( +
+

{form.errors.submit}

+
+ )} + + undefined} + continueLabel={intl.formatMessage({ + id: form.manualOpen + ? form.isSubmitting + ? "agents.connect.auth.submitting" + : "agents.connect.auth.submit" + : form.isFetching + ? "agents.connect.checking" + : "agents.connect.continue", + })} + isBusy={form.isFetching || form.isSubmitting} + /> + +
+
+ )} + + {form.step === "review" && card && ( + + + + {intl.formatMessage( + { id: "agents.connect.review.badge" }, + { version: card.version }, + )} + + ) : undefined + } + /> + {card.description && ( +

{card.description}

+ )} + + {(card.skills?.length ?? 0) > 0 && ( +
+

+ {intl.formatMessage( + { id: "agents.connect.review.skills" }, + { count: card.skills?.length ?? 0 }, + )} +

+
    + {card.skills?.map((skill, index) => ( +
  • {skill.name}
  • + ))} +
+
+ )} + +
+ {( + [ + [ + "agents.connect.review.version", + form.result?.detectedVersion + ? intl.formatMessage( + { id: "agents.connect.review.a2aVersion" }, + { version: form.result.detectedVersion }, + ) + : "", + ], + ["agents.connect.review.endpoint", summarizeEndpoint(card)], + ["agents.connect.review.interfaces", summarizeInterfaces(card)], + ["agents.connect.review.requires", summarizeRequirements(card)], + ] as const + ) + .filter(([, value]) => value) + .map(([labelId, value]) => ( +
+
+ {intl.formatMessage({ id: labelId })} +
+
{value}
+
+ ))} +
+ + {(form.result?.warnings?.length ?? 0) > 0 && ( + +
    + {form.result?.warnings?.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} + + +
+
+ )} + + {form.step === "auth" && ( + + +
{ + event.preventDefault(); + void form.submit(); + }} + > + +

+ {intl.formatMessage({ + id: form.authType ? "agents.connect.auth.intro" : "agents.connect.auth.introNone", + })} +

+ + {!form.isAuthSupported && ( + + {intl.formatMessage({ id: "agents.connect.auth.unsupported" })} + + )} + + {form.authType === "bearer" && ( + + )} + {form.authType === "basic" && ( + + )} + {form.authType === "authheaders" && ( + + )} + {form.authType === "query_param" && ( + + )} + {form.errors.credentials && ( +

+ {form.errors.credentials} +

+ )} + + + + + + {(controlProps) => ( + + )} + + + + {form.errors.submit && ( +
+

{form.errors.submit}

+
+ )} + + undefined} + continueLabel={intl.formatMessage({ + id: form.isSubmitting + ? "agents.connect.auth.submitting" + : "agents.connect.auth.submit", + })} + isBusy={form.isSubmitting} + continueDisabled={!form.isAuthSupported} + /> + +
+
+ )} +
+ ); +} diff --git a/src/components/agents/ManualAgentFields.tsx b/src/components/agents/ManualAgentFields.tsx new file mode 100644 index 00000000..54ab76b6 --- /dev/null +++ b/src/components/agents/ManualAgentFields.tsx @@ -0,0 +1,351 @@ +import { useIntl } from "react-intl"; +import { BasicAuth } from "@/components/mcp-servers/BasicAuth"; +import { BearerTokenAuth } from "@/components/mcp-servers/BearerTokenAuth"; +import { CustomHeadersAuth } from "@/components/mcp-servers/CustomHeadersAuth"; +import { QueryParameterAuth } from "@/components/mcp-servers/QueryParameterAuth"; +import { Field } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; +import type { ConnectAgentFormState } from "@/hooks/useConnectAgentForm"; +import { VisibilityFields } from "./VisibilityFields"; +import { + AGENT_AUTH_TYPES, + PROTOCOL_VERSIONS, + type AgentAuthType, + type ProtocolVersion, +} from "./manualAgent"; + +const INPUT_CLASS = + "rounded-md border-neutral-300 px-4 text-sm text-neutral-900 shadow-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 placeholder:text-neutral-400 dark:border-neutral-700 dark:text-neutral-100 dark:placeholder:text-neutral-500"; + +/** Tab label ids reuse the MCP server form's, so the two forms word the auth types alike. */ +const AUTH_TAB_LABEL_IDS: Record = { + none: "mcpServer.advanced.authType.none", + basic: "mcpServer.advanced.authType.basic", + bearer: "mcpServer.advanced.authType.bearer", + authheaders: "mcpServer.advanced.authType.custom", + oauth: "mcpServer.advanced.authType.oauth", // pragma: allowlist secret + query_param: "mcpServer.advanced.authType.query", +}; + +function AuthTypeTabs({ + value, + onChange, +}: { + value: AgentAuthType; + onChange: (value: AgentAuthType) => void; +}) { + const intl = useIntl(); + return ( +
+ +
+ {AGENT_AUTH_TYPES.map((type) => ( +
+ onChange(type)} + className="peer sr-only" + /> + +
+ ))} +
+
+ ); +} + +function TextField({ + id, + label, + value, + onChange, + placeholder, + type = "text", + required, + error, + info, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + type?: string; + required?: boolean; + error?: string; + info?: string; +}) { + return ( + + {(controlProps) => ( + onChange(event.target.value)} + placeholder={placeholder} + className={INPUT_CLASS} + /> + )} + + ); +} + +function OAuthClientCredentialsFields({ form }: { form: ConnectAgentFormState }) { + const intl = useIntl(); + return ( +
+

+ {intl.formatMessage({ id: "agents.connect.manual.oauth.intro" })} +

+ +
+ + +
+ +
+ ); +} + +/** The fields for registering an agent that publishes no Agent Card. */ +export function ManualAgentFields({ form }: { form: ConnectAgentFormState }) { + const intl = useIntl(); + + const renderAuth = () => { + switch (form.manualAuthType) { + case "basic": + return ( + + ); + case "bearer": + return ; + case "authheaders": + return ; + case "oauth": + return ; + case "query_param": + return ( + + ); + default: + return null; + } + }; + + return ( +
+ { + form.setManualName(value); + form.clearError("name"); + }} + error={form.errors.name} + /> + + + + { + form.setManualEndpoint(value); + form.clearError("endpoint"); + }} + error={form.errors.endpoint} + /> + +
+
+ + {intl.formatMessage({ id: "agents.connect.manual.binding.label" })} + + + +
+ + +
+
+ {/* TODO(IBM/mcp-context-forge#7383): disabled because the API stores no protocol + binding yet, so a selection would be dropped on save. Enable this option, send + `protocol_binding` ("jsonrpc" | "http_json") and drop the note below once that + column exists in the generated client. */} + + +
+
+
+ +
+ + {intl.formatMessage({ id: "agents.connect.manual.version.label" })} + + + form.setProtocolVersion(value as ProtocolVersion)} + aria-label={intl.formatMessage({ id: "agents.connect.manual.version.label" })} + > + {PROTOCOL_VERSIONS.map((version) => ( +
+ + +
+ ))} +
+
+ + {/* TODO(IBM/mcp-context-forge#7383): temporary note. Remove it, and the + `agents.connect.manual.binding.httpUnsupported` strings, once HTTP+JSON can be saved. */} +

+ {intl.formatMessage({ id: "agents.connect.manual.binding.httpUnsupported" })} +

+
+ + + {renderAuth()} + {form.errors.credentials && ( +

+ {form.errors.credentials} +

+ )} + +
+ + + {/* The design shows the description without a visible label; keep one for screen readers. */} + + {(controlProps) => ( +