From e772acf2ed242037f5863cc3057ae0a71eb09370 Mon Sep 17 00:00:00 2001 From: Ahmad Al Tamimi Date: Wed, 12 Aug 2026 20:58:27 +0400 Subject: [PATCH] feat: add MCP handshake test mode to Test Connection UI half of the MCP handshake test, split out of IBM/mcp-context-forge#5934 now that the client lives in this repo. Test Connection gains a mode toggle. HTTP request keeps the existing raw-request behavior; MCP handshake calls the new POST /v1/mcp-servers/test-handshake and reports whether the target actually speaks MCP: - Detail rows for server name/version, protocol version, negotiation path (server/discover or initialize) and credential source - Count badges for first-page tools/resources/prompts, rendered as "3+ tools" when countsPartial marks the listing truncated - A failure-class badge (transport / protocol negotiation / authentication / invalid response) with the backend's actionable copy - A collapsible raw-response preview - Method, content type and body inputs are hidden in handshake mode; the in-flight request is aborted on unmount, cancel, and mode switch New user-facing copy goes through react-intl, with keys added to the en-US, es-ES and pt-BR mcpServer namespaces. The en-US messages are byte-identical to the strings they replace. Component counts use ICU plural forms. openapi.json gains only the new /v1/mcp-servers/test-handshake path plus the GatewayHandshakeRequest/GatewayHandshakeResponse schemas, extracted from the gateway's app.openapi(). The snapshot stays pinned at API v1.0.7 otherwise, so the generated types pick up the handshake endpoint without dragging in unrelated spec drift. Relates to IBM/mcp-context-forge#5649 Signed-off-by: Ahmad Al Tamimi --- openapi.json | 280 ++++++++ src/api/servers.ts | 20 +- .../servers/TestConnectionPanel.test.tsx | 358 +++++++++- .../servers/TestConnectionPanel.tsx | 663 ++++++++++++------ src/i18n/locales/en-US/mcpServer.json | 27 +- src/i18n/locales/es-ES/mcpServer.json | 27 +- src/i18n/locales/pt-BR/mcpServer.json | 27 +- 7 files changed, 1196 insertions(+), 206 deletions(-) diff --git a/openapi.json b/openapi.json index 874d953..30026ea 100644 --- a/openapi.json +++ b/openapi.json @@ -36803,6 +36803,89 @@ } ] } + }, + "/v1/mcp-servers/test-handshake": { + "post": { + "tags": [ + "MCP Servers" + ], + "summary": "Check Mcp Server Handshake", + "description": "Test whether an MCP server URL speaks MCP via a protocol handshake.\n\nDelegates to ``test_gateway_handshake`` in\n``mcpgateway.services.gateway_service``, which tries the stateless\n``server/discover`` method first and falls back to a stateful SDK\n``initialize`` round-trip, classifying failures for actionable UI copy.\n\nArgs:\n request (GatewayHandshakeRequest): The request object containing the server URL and optional headers.\n team_id (Optional[str]): Optional team ID for team-specific gateways.\n user: Authenticated user context.\n db (Session): Database session dependency.\n\nReturns:\n GatewayHandshakeResponse: The handshake outcome, including negotiation path,\n server identity, capabilities, component counts, and failure classification.\n\nExamples:\n >>> callable(check_mcp_server_handshake)\n True\n >>> check_mcp_server_handshake.__name__\n 'check_mcp_server_handshake'", + "operationId": "check_mcp_server_handshake_v1_mcp_servers_test_handshake_post", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "team_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by team ID", + "title": "Team Id" + }, + "description": "Filter by team ID" + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayHandshakeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayHandshakeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -52836,6 +52919,203 @@ "type" ], "title": "ValidationError" + }, + "GatewayHandshakeRequest": { + "properties": { + "baseUrl": { + "type": "string", + "minLength": 1, + "format": "uri", + "title": "Baseurl", + "description": "Base URL of the MCP server to test" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path", + "description": "Optional path appended to the base URL" + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers", + "description": "Optional headers (e.g. Authorization) sent with the handshake" + } + }, + "type": "object", + "required": [ + "baseUrl" + ], + "title": "GatewayHandshakeRequest", + "description": "Request to run an MCP handshake test against a server URL.", + "nullable": true + }, + "GatewayHandshakeResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "latencyMs": { + "type": "integer", + "title": "Latencyms" + }, + "negotiationPath": { + "anyOf": [ + { + "type": "string", + "enum": [ + "server_discover", + "initialize" + ] + }, + { + "type": "null" + } + ], + "title": "Negotiationpath", + "description": "Which handshake path produced the result" + }, + "protocolVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Protocolversion" + }, + "serverName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Servername" + }, + "serverVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serverversion" + }, + "capabilities": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Capabilities" + }, + "componentCounts": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Componentcounts", + "description": "Counts for tools/resources/prompts; a key is absent when the capability is not advertised" + }, + "countsPartial": { + "type": "boolean", + "title": "Countspartial", + "description": "True when any list result had a nextCursor (counts are first-page lower bounds)", + "default": false + }, + "credentialSource": { + "type": "string", + "enum": [ + "stored", + "form", + "none" + ], + "title": "Credentialsource", + "default": "none" + }, + "failureClass": { + "anyOf": [ + { + "type": "string", + "enum": [ + "transport", + "protocol", + "auth", + "invalid_response" + ] + }, + { + "type": "null" + } + ], + "title": "Failureclass" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "rawPreview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rawpreview", + "description": "Size-capped JSON preview of the final handshake payload" + } + }, + "type": "object", + "required": [ + "success", + "latencyMs" + ], + "title": "GatewayHandshakeResponse", + "description": "Result of an MCP handshake test.", + "nullable": true } }, "securitySchemes": { diff --git a/src/api/servers.ts b/src/api/servers.ts index 539b9f8..b131491 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -7,7 +7,12 @@ import { api } from "./client"; import type { ServersResponse, MCPServer } from "../types/server"; -import type { GatewayTestRequest, GatewayTestResponse } from "@/generated/types"; +import type { + GatewayHandshakeRequest, + GatewayHandshakeResponse, + GatewayTestRequest, + GatewayTestResponse, +} from "@/generated/types"; const serverByIdRequestCache = new Map>(); @@ -130,6 +135,19 @@ export const serversApi = { return api.post("/v1/mcp-servers/test", request, { signal }); }, + /** + * Test whether an MCP server URL speaks MCP via a protocol handshake. + * + * Tries the stateless server/discover method (MCP 2026-07-28+) first and + * falls back to a stateful initialize round-trip for earlier specs. + */ + testHandshake: ( + request: GatewayHandshakeRequest, + signal?: AbortSignal, + ): Promise => { + return api.post("/v1/mcp-servers/test-handshake", request, { signal }); + }, + /** * Toggle the enabled state of an MCP server (activate/deactivate) */ diff --git a/src/components/servers/TestConnectionPanel.test.tsx b/src/components/servers/TestConnectionPanel.test.tsx index 582ae3c..b141c31 100644 --- a/src/components/servers/TestConnectionPanel.test.tsx +++ b/src/components/servers/TestConnectionPanel.test.tsx @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders as render } from "@/test/test-utils"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { server } from "@/test/mocks/server"; import { TestConnectionPanel } from "./TestConnectionPanel"; +import { copyToClipboard } from "@/lib/clipboard"; + +vi.mock("@/lib/clipboard", () => ({ copyToClipboard: vi.fn() })); const TEST_ENDPOINT = "*/v1/mcp-servers/test"; +const HANDSHAKE_ENDPOINT = "*/v1/mcp-servers/test-handshake"; describe("TestConnectionPanel", () => { const defaultProps = { @@ -392,4 +397,355 @@ describe("TestConnectionPanel", () => { expect(screen.queryByText(/path shouldn't include a scheme or host/i)).not.toBeInTheDocument(); }); + + describe("MCP handshake mode", () => { + it("hides Method, Content type, and Body while keeping URL, Path, and Headers", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + + expect(screen.getByLabelText(/^url/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^path/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/headers/i)).toBeInTheDocument(); + expect(screen.queryByRole("radiogroup", { name: /method/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/content type/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/body/i)).not.toBeInTheDocument(); + }); + + it("shows the stored-credentials hint under Headers", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + + expect(screen.getByText(/stored credentials for registered servers/i)).toBeInTheDocument(); + }); + + it("renders server identity rows and component count badges on success", async () => { + const user = userEvent.setup(); + let requestBody: Record | undefined; + server.use( + http.post(HANDSHAKE_ENDPOINT, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + latencyMs: 12, + negotiationPath: "server_discover", + protocolVersion: "2026-07-28", + serverName: "git-server", + serverVersion: "1.2.3", + capabilities: { tools: {}, resources: {} }, + componentCounts: { tools: 3, resources: 1 }, + countsPartial: false, + credentialSource: "none", + }); + }), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect(screen.getByText("git-server")).toBeInTheDocument(); + expect(screen.getByText("1.2.3")).toBeInTheDocument(); + expect(screen.getByText("2026-07-28")).toBeInTheDocument(); + expect(screen.getByText("server/discover")).toBeInTheDocument(); + expect(screen.getByText("3 tools")).toBeInTheDocument(); + expect(screen.getByText("1 resource")).toBeInTheDocument(); + expect(requestBody).toEqual(expect.objectContaining({ baseUrl: "https://example.com" })); + }); + + it("keeps the plural label when counts are partial", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 12, + negotiationPath: "server_discover", + protocolVersion: "2026-07-28", + serverName: "git-server", + serverVersion: "1.2.3", + capabilities: { tools: {} }, + componentCounts: { tools: 1 }, + countsPartial: true, + credentialSource: "none", + }), + ), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText("1+ tools")).toBeInTheDocument(); + }); + }); + + it("clears field errors when switching modes", async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getByLabelText(/^url/i)); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + await waitFor(() => expect(screen.getByText(/url is required/i)).toBeInTheDocument()); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + + expect(screen.queryByText(/url is required/i)).not.toBeInTheDocument(); + }); + + it.each([ + ["transport", "Transport"], + ["protocol", "Protocol negotiation"], + ["auth", "Authentication"], + ["invalid_response", "Invalid response"], + ])("renders the %s failure classification and error copy", async (failureClass, label) => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: false, + latencyMs: 5, + credentialSource: "none", + failureClass, + error: "Actionable copy for the failure.", + }), + ), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + expect(screen.getByText(/handshake failed/i)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText("Actionable copy for the failure.")).toBeInTheDocument(); + }); + + it("cancels the in-flight handshake when the panel unmounts", async () => { + const user = userEvent.setup(); + let aborted = false; + server.use( + http.post(HANDSHAKE_ENDPOINT, async ({ request }) => { + // Resolve only once the client aborts, so the test can observe cancellation. + await new Promise((resolve) => { + request.signal.addEventListener("abort", () => { + aborted = true; + resolve(); + }); + }); + return HttpResponse.json({ success: true, latencyMs: 1 }); + }), + ); + const { unmount } = render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + unmount(); + + await waitFor(() => expect(aborted).toBe(true)); + }); + + it("runs a handshake even when an invalid HTTP body was typed in HTTP mode", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 12, + negotiationPath: "server_discover", + protocolVersion: "2026-07-28", + serverName: "git-server", + serverVersion: "1.2.3", + capabilities: { tools: {} }, + componentCounts: { tools: 1 }, + countsPartial: false, + credentialSource: "none", + }), + ), + ); + render(); + + // Only HTTP mode validates the body, and the handshake payload never + // carries one — leftover invalid JSON must not be parsed on this path. + await user.click(screen.getByRole("radio", { name: "Post" })); + await user.type(screen.getByLabelText(/body/i), "not json"); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + }); + + it("associates each tab with its tabpanel", async () => { + const user = userEvent.setup(); + render(); + + const httpPanel = screen.getByRole("tabpanel"); + expect(httpPanel.id).toBeTruthy(); + expect(screen.getByRole("tab", { name: /http request/i })).toHaveAttribute( + "aria-controls", + httpPanel.id, + ); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + + const handshakePanel = screen.getByRole("tabpanel"); + expect(handshakePanel.id).toBeTruthy(); + expect(screen.getByRole("tab", { name: /mcp handshake/i })).toHaveAttribute( + "aria-controls", + handshakePanel.id, + ); + }); + + it("shows a Cancel button during a handshake and aborts on click", async () => { + const user = userEvent.setup(); + let aborted = false; + server.use( + http.post(HANDSHAKE_ENDPOINT, async ({ request }) => { + // Resolve only once the client aborts, so the test can observe cancellation. + await new Promise((resolve) => { + request.signal.addEventListener("abort", () => { + aborted = true; + resolve(); + }); + }); + return HttpResponse.json({ success: true, latencyMs: 1 }); + }), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await user.click(await screen.findByRole("button", { name: /^cancel$/i })); + + await waitFor(() => expect(aborted).toBe(true)); + expect(screen.getByText(/run a test to see the response/i)).toBeInTheDocument(); + }); + + it("renders the raw response preview and copies it", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 12, + negotiationPath: "initialize", + credentialSource: "none", + rawPreview: '{"result":{"ok":true}}', + }), + ), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/raw response \(truncated\)/i)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /copy response body/i })); + + expect(copyToClipboard).toHaveBeenCalledWith( + JSON.stringify({ result: { ok: true } }, null, 2), + ); + }); + + it("labels stored credentials as the credential source", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 8, + negotiationPath: "initialize", + credentialSource: "stored", + }), + ), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText("Stored server credentials")).toBeInTheDocument(); + }); + }); + + it("labels form headers as the credential source", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 8, + negotiationPath: "initialize", + credentialSource: "form", + }), + ), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText("Form headers")).toBeInTheDocument(); + }); + }); + + it("forwards a non-empty path in the handshake payload", async () => { + const user = userEvent.setup(); + let requestBody: Record | undefined; + server.use( + http.post(HANDSHAKE_ENDPOINT, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json({ success: true, latencyMs: 1, credentialSource: "none" }); + }), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.type(screen.getByLabelText(/^path/i), "/mcp"); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => expect(requestBody).toBeDefined()); + expect(requestBody).toEqual(expect.objectContaining({ path: "/mcp" })); + }); + + it("forwards headers as a JSON object in the handshake payload", async () => { + const user = userEvent.setup(); + let requestBody: Record | undefined; + server.use( + http.post(HANDSHAKE_ENDPOINT, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json({ success: true, latencyMs: 1, credentialSource: "form" }); + }), + ); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.click(screen.getByLabelText(/headers/i)); + await user.paste('{"X-Api-Key": "k"}'); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => expect(requestBody).toBeDefined()); + expect(requestBody?.headers).toEqual({ "X-Api-Key": "k" }); + }); + }); }); diff --git a/src/components/servers/TestConnectionPanel.tsx b/src/components/servers/TestConnectionPanel.tsx index 41f1514..81348d6 100644 --- a/src/components/servers/TestConnectionPanel.tsx +++ b/src/components/servers/TestConnectionPanel.tsx @@ -7,11 +7,19 @@ import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { RadioGroup } from "../ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { Badge } from "../ui/badge"; import { Textarea } from "../ui/textarea"; import { JsonHighlighter } from "../ui/json-highlighter"; import { copyToClipboard } from "@/lib/clipboard"; import { serversApi } from "@/api/servers"; -import type { GatewayTestRequest, GatewayTestResponse } from "@/generated/types"; +import type { + GatewayHandshakeRequest, + GatewayHandshakeResponse, + GatewayTestRequest, + GatewayTestResponse, +} from "@/generated/types"; +import { useIntl, type IntlShape } from "react-intl"; import { parseApiError } from "@/lib/errorUtils"; import { cn } from "@/lib/utils"; @@ -20,6 +28,59 @@ interface TestConnectionPanelProps { } type TestStatus = "idle" | "testing" | "success" | "error"; +type TestMode = "http" | "handshake"; + +const SEGMENTED_TRIGGER_CLASS = + "rounded-md px-3 py-1 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; + +const FAILURE_CLASS_MESSAGE_IDS: Record = { + transport: "mcpServer.testConnection.failureClass.transport", + protocol: "mcpServer.testConnection.failureClass.protocol", + auth: "mcpServer.testConnection.failureClass.auth", + invalid_response: "mcpServer.testConnection.failureClass.invalidResponse", +}; + +const CREDENTIAL_SOURCE_MESSAGE_IDS: Record = { + stored: "mcpServer.testConnection.credentialSource.stored", + form: "mcpServer.testConnection.credentialSource.form", + none: "mcpServer.testConnection.credentialSource.none", +}; + +const COUNT_MESSAGE_IDS: Record = { + tools: "mcpServer.testConnection.counts.tools", + resources: "mcpServer.testConnection.counts.resources", + prompts: "mcpServer.testConnection.counts.prompts", +}; + +// Deliberately plain substitution, not ICU plural: the "+" means "at least", +// so "1+ tools" is correct even when the first page holds a single item. +const PARTIAL_COUNT_MESSAGE_IDS: Record = { + tools: "mcpServer.testConnection.countsPartial.tools", + resources: "mcpServer.testConnection.countsPartial.resources", + prompts: "mcpServer.testConnection.countsPartial.prompts", +}; + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function getHandshakeHeadline( + response: GatewayHandshakeResponse, + error: string, + intl: IntlShape, +): string { + if (!response) { + return error || intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" }); + } + return response.success + ? intl.formatMessage({ id: "mcpServer.testConnection.handshakeSucceeded" }) + : intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" }); +} const HTTP_METHODS = ["Get", "Post", "Put", "Delete", "Patch"] as const; @@ -123,7 +184,9 @@ function FieldLabel({ } export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { + const intl = useIntl(); const [status, setStatus] = useState("idle"); + const [mode, setMode] = useState("http"); const [method, setMethod] = useState("Get"); const [url, setUrl] = useState(serverUrl); const [path, setPath] = useState(""); @@ -131,6 +194,7 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { const [contentType, setContentType] = useState("application/json"); const [body, setBody] = useState(""); const [response, setResponse] = useState(null); + const [handshakeResponse, setHandshakeResponse] = useState(null); const [error, setError] = useState(""); const [errors, setErrors] = useState({}); // Aborted on unmount or via Cancel to avoid state updates on a stale request. @@ -146,6 +210,7 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { const handleTest = useCallback(async () => { setResponse(null); + setHandshakeResponse(null); setError(""); // Validate every field up front and surface problems inline; don't send a @@ -154,20 +219,56 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { url: validateUrl(url), path: validatePath(path), headers: validateHeaders(headers), - body: validateBody(body, method, contentType), + body: mode === "http" ? validateBody(body, method, contentType) : undefined, }; setErrors(nextErrors); if (nextErrors.url || nextErrors.path || nextErrors.headers || nextErrors.body) { return; } - // Fields are valid — parse the JSON payloads for sending. JSON bodies are - // parsed to an object so the backend forwards them as JSON; form-encoded - // bodies are sent as-is. + // Fields are valid — parse the headers JSON, which is validated and sent + // in both modes. const parsedHeaders: Record | undefined = headers.trim() ? (JSON.parse(headers) as Record) : undefined; + // Cancel any previous in-flight request before starting a new one. + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setStatus("testing"); + + if (mode === "handshake") { + const handshakePayload: GatewayHandshakeRequest = { + baseUrl: url.trim(), + ...(path.trim() ? { path: path.trim() } : {}), + ...(parsedHeaders ? { headers: parsedHeaders } : {}), + }; + try { + const result = await serversApi.testHandshake(handshakePayload, controller.signal); + if (controller.signal.aborted) { + return; + } + setHandshakeResponse(result); + setStatus(result?.success ? "success" : "error"); + } catch (e) { + if (controller.signal.aborted) { + return; + } + setHandshakeResponse(null); + setStatus("error"); + setError( + parseApiError(e, intl.formatMessage({ id: "mcpServer.testConnection.handshakeError" })), + ); + } + return; + } + + // HTTP mode only from here: the body is validated above only in this mode, + // so parsing it before the handshake branch returns would throw on input + // that handshake mode never sends. JSON bodies are parsed to an object so + // the backend forwards them as JSON; form-encoded bodies are sent as-is. let parsedBody: string | Record | undefined; if (sendsBodyFor(method) && body.trim()) { parsedBody = @@ -183,12 +284,6 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { ...(parsedBody !== undefined ? { body: parsedBody } : {}), }; - // Cancel any previous in-flight request before starting a new one. - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - setStatus("testing"); try { const result = await serversApi.testConnectivity(payload, controller.signal); if (controller.signal.aborted) { @@ -206,7 +301,7 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { setStatus("error"); setError(parseApiError(e, "Connection test failed. Please try again.")); } - }, [url, headers, body, method, path, contentType]); + }, [url, headers, body, method, path, contentType, mode, intl]); const handleCancel = useCallback(() => { abortRef.current?.abort(); @@ -220,45 +315,65 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { : JSON.stringify(response.body, null, 2); }, [response]); - const headline = response - ? `Status: ${response.statusCode} ${status === "success" ? "OK" : "error"}` - : error || "Connection failed"; + const handshakeRawPreview = useMemo(() => { + if (!handshakeResponse?.rawPreview) return ""; + try { + return JSON.stringify(JSON.parse(handshakeResponse.rawPreview), null, 2); + } catch { + return handshakeResponse.rawPreview; + } + }, [handshakeResponse]); + + const handshakeCountChips = useMemo(() => { + const counts = handshakeResponse?.componentCounts; + if (!counts) return []; + return ["tools", "resources", "prompts"].filter((key) => counts[key] != null); + }, [handshakeResponse]); + + const copyText = mode === "http" ? responseBodyText : handshakeRawPreview; + + const headline = + mode === "handshake" + ? getHandshakeHeadline(handshakeResponse, error, intl) + : response + ? `Status: ${response.statusCode} ${status === "success" ? "OK" : "error"}` + : error || "Connection failed"; const isTesting = status === "testing"; const hasResult = status === "success" || status === "error"; - return ( -
-
- {/* Left column — request form */} -
- {/* URL */} -
- - URL - - { - setUrl(e.target.value); - clearError("url"); - }} - onBlur={() => setErrors((prev) => ({ ...prev, url: validateUrl(url) }))} - placeholder="https://mcp.github.com/mcp" - disabled={isTesting} - aria-invalid={!!errors.url} - aria-describedby={errors.url ? "url-error" : undefined} - className="bg-transparent dark:bg-transparent" - /> - {errors.url && ( -

- {errors.url} -

- )} -
+ const formGrid = ( +
+ {/* Left column — request form */} +
+ {/* URL */} +
+ + URL + + { + setUrl(e.target.value); + clearError("url"); + }} + onBlur={() => setErrors((prev) => ({ ...prev, url: validateUrl(url) }))} + placeholder="https://mcp.github.com/mcp" + disabled={isTesting} + aria-invalid={!!errors.url} + aria-describedby={errors.url ? "url-error" : undefined} + className="bg-transparent dark:bg-transparent" + /> + {errors.url && ( +

+ {errors.url} +

+ )} +
- {/* Method */} + {/* Method */} + {mode === "http" && (
Method
+ )} + + {/* Path */} +
+ + Path + + { + setPath(e.target.value); + clearError("path"); + }} + onBlur={() => setErrors((prev) => ({ ...prev, path: validatePath(path) }))} + placeholder="/health" + disabled={isTesting} + aria-invalid={!!errors.path} + aria-describedby={errors.path ? "path-error" : undefined} + className="bg-transparent dark:bg-transparent" + /> + {errors.path && ( +

+ {errors.path} +

+ )} +
- {/* Path */} -
- - Path - - { - setPath(e.target.value); - clearError("path"); - }} - onBlur={() => setErrors((prev) => ({ ...prev, path: validatePath(path) }))} - placeholder="/health" - disabled={isTesting} - aria-invalid={!!errors.path} - aria-describedby={errors.path ? "path-error" : undefined} - className="bg-transparent dark:bg-transparent" - /> - {errors.path && ( -

- {errors.path} -

- )} -
- - {/* Content type */} + {/* Content type */} + {mode === "http" && (
Content type
+ )} + + {/* Headers */} +
+ + Headers + +