From fa7d9c2ff31f17672f9efc6eb51bba3057c4d42c Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Mon, 9 Nov 2026 14:45:22 +0000 Subject: [PATCH] feat(agents): add the Connect agent form with manual entry Part of IBM/mcp-context-forge#7023. Implements #7350 (add an agent that publishes no Agent Card) and the UI side of #7025. - Open a Connect agent form from the Connect agent card - Card flow: read the card from a URL, review it, then collect credentials and visibility and register the agent - "Enter manually" for agents without a card, with name, visibility, endpoint URL, protocol version, authentication (none, basic, bearer, custom headers, OAuth 2.0 client credentials, query parameter), tags and description. Any failed card read shows "Agent card not available" and opens it - Add agentsApi.fetchCard and agentsApi.create, and a shared VisibilityFields component - Add unit tests, e2e tests and en-US, es-ES and pt-BR strings Signed-off-by: Marek Dano --- e2e/agents.spec.ts | 364 +++++++++ src/api/agents.test.ts | 91 +++ src/api/agents.ts | 22 +- .../agents/ConnectAgentForm.test.tsx | 704 ++++++++++++++++++ src/components/agents/ConnectAgentForm.tsx | 424 +++++++++++ src/components/agents/ManualAgentFields.tsx | 351 +++++++++ src/components/agents/VisibilityFields.tsx | 84 +++ .../agents/agentCardSummary.test.ts | 102 +++ src/components/agents/agentCardSummary.ts | 65 ++ src/components/agents/agentSkills.test.ts | 47 ++ src/components/agents/agentSkills.ts | 31 + src/components/agents/manualAgent.test.ts | 112 +++ src/components/agents/manualAgent.ts | 95 +++ src/hooks/useConnectAgentForm.ts | 392 ++++++++++ src/i18n/locales/en-US/agents.json | 59 +- src/i18n/locales/es-ES/agents.json | 59 +- src/i18n/locales/pt-BR/agents.json | 59 +- src/pages/Agents.test.tsx | 134 +++- src/pages/Agents.tsx | 74 +- src/types/agentCard.ts | 82 ++ 20 files changed, 3313 insertions(+), 38 deletions(-) create mode 100644 src/api/agents.test.ts create mode 100644 src/components/agents/ConnectAgentForm.test.tsx create mode 100644 src/components/agents/ConnectAgentForm.tsx create mode 100644 src/components/agents/ManualAgentFields.tsx create mode 100644 src/components/agents/VisibilityFields.tsx create mode 100644 src/components/agents/agentCardSummary.test.ts create mode 100644 src/components/agents/agentCardSummary.ts create mode 100644 src/components/agents/agentSkills.test.ts create mode 100644 src/components/agents/agentSkills.ts create mode 100644 src/components/agents/manualAgent.test.ts create mode 100644 src/components/agents/manualAgent.ts create mode 100644 src/hooks/useConnectAgentForm.ts create mode 100644 src/types/agentCard.ts 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) => ( +