From 3545bc92614fa6e3c2726a32807e52ea3c29299e 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.test.ts | 28 +- src/api/servers.ts | 20 +- .../servers/TestConnectionPanel.test.tsx | 493 +++++++++++++ .../servers/TestConnectionPanel.tsx | 670 +++++++++++++----- src/i18n/locales/en-US/mcpServer.json | 29 +- src/i18n/locales/es-ES/mcpServer.json | 29 +- src/i18n/locales/pt-BR/mcpServer.json | 29 +- 8 files changed, 1376 insertions(+), 202 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.test.ts b/src/api/servers.test.ts index 085cdf3..3d99b93 100644 --- a/src/api/servers.test.ts +++ b/src/api/servers.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { serversApi } from "./servers"; import { setCsrfToken } from "./client"; -import type { GatewayTestRequest } from "@/generated/types"; +import type { GatewayTestRequest, GatewayHandshakeRequest } from "@/generated/types"; describe("serversApi", () => { const mockFetch = vi.fn(); @@ -512,4 +512,30 @@ describe("serversApi", () => { ); }); }); + + describe("testHandshake", () => { + it("POSTs the request to /v1/mcp-servers/test-handshake, forwards the signal, and returns the response", async () => { + const upstream = { success: true, latencyMs: 12, credentialSource: "none" }; + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(upstream), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const request: GatewayHandshakeRequest = { baseUrl: "https://example.com" }; + const controller = new AbortController(); + const result = await serversApi.testHandshake(request, controller.signal); + + expect(result).toEqual(upstream); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/v1/mcp-servers/test-handshake"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify(request), + signal: controller.signal, + }), + ); + }); + }); }); 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 dbd28e2..d32e182 100644 --- a/src/components/servers/TestConnectionPanel.test.tsx +++ b/src/components/servers/TestConnectionPanel.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; + import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { server } from "@/test/mocks/server"; @@ -7,6 +8,7 @@ import { renderWithProviders as render } from "@/test/test-utils"; import { TestConnectionPanel } from "./TestConnectionPanel"; const TEST_ENDPOINT = "*/v1/mcp-servers/test"; +const HANDSHAKE_ENDPOINT = "*/v1/mcp-servers/test-handshake"; describe("TestConnectionPanel", () => { const defaultProps = { @@ -393,4 +395,495 @@ 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("keeps the diagnostic rows visible when the handshake fails", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: false, + latencyMs: 5, + failureClass: "auth", + credentialSource: "stored", + serverName: "srv", + protocolVersion: "2026-07-28", + negotiationPath: "initialize", + error: "Authentication rejected by the server.", + }), + ), + ); + 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("Authentication")).toBeInTheDocument(); + }); + expect(screen.getByText("Stored server credentials")).toBeInTheDocument(); + expect(screen.getByText("srv")).toBeInTheDocument(); + expect(screen.getByText("2026-07-28")).toBeInTheDocument(); + expect(screen.getByText("initialize")).toBeInTheDocument(); + }); + + it("renders an unrecognized negotiation path verbatim", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 12, + negotiationPath: "future_path", + 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("future_path")).toBeInTheDocument(); + }); + + it("shows the API error once and keeps the generic failure headline", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ detail: "Upstream exploded." }, { status: 500 }), + ), + ); + 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 failed/i)).toBeInTheDocument(); + }); + expect(screen.getAllByText("Upstream exploded.")).toHaveLength(1); + }); + + it("drops the previous result when a re-test fails validation", async () => { + const user = userEvent.setup(); + server.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ success: true, latencyMs: 12, 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(); + }); + + await user.clear(screen.getByLabelText(/^url/i)); + await user.click(screen.getByRole("button", { name: /re-test connection/i })); + + await waitFor(() => expect(screen.getByText(/url is required/i)).toBeInTheDocument()); + expect(screen.queryByText(/handshake succeeded/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/handshake failed/i)).not.toBeInTheDocument(); + }); + + it("points the Headers field at the stored-credentials hint", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + + const hint = screen.getByText(/stored credentials for registered servers/i); + expect(hint.id).toBe("headers-hint"); + expect(screen.getByLabelText(/headers/i)).toHaveAttribute("aria-describedby", "headers-hint"); + }); + + it("describes the Headers field by both the error and the hint", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /mcp handshake/i })); + await user.type(screen.getByLabelText(/headers/i), "not json"); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => expect(screen.getByText(/invalid headers json/i)).toBeInTheDocument()); + expect(screen.getByLabelText(/headers/i)).toHaveAttribute( + "aria-describedby", + "headers-error headers-hint", + ); + }); + + it("rejects non-string header values instead of posting them", async () => { + const user = userEvent.setup(); + let requested = false; + server.use( + http.post(HANDSHAKE_ENDPOINT, () => { + requested = true; + 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(/headers/i), '{{"X-Retry": 3}'); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => + expect(screen.getByText(/header values must be strings/i)).toBeInTheDocument(), + ); + expect(requested).toBe(false); + }); + + 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(); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + 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(writeText).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 56cfa8a..7ea13be 100644 --- a/src/components/servers/TestConnectionPanel.tsx +++ b/src/components/servers/TestConnectionPanel.tsx @@ -2,17 +2,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { CircleCheck, CircleAlert, Info, Loader2 } from "lucide-react"; -import { useIntl } from "react-intl"; +import { useIntl, type IntlShape } from "react-intl"; import { Button } from "../ui/button"; import { CopyButton } from "../ui/copy-button"; 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 { serversApi } from "@/api/servers"; -import type { GatewayTestRequest, GatewayTestResponse } from "@/generated/types"; +import type { + GatewayHandshakeRequest, + GatewayHandshakeResponse, + GatewayTestRequest, + GatewayTestResponse, +} from "@/generated/types"; import { parseApiError } from "@/lib/errorUtils"; import { cn } from "@/lib/utils"; @@ -21,6 +28,57 @@ 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 NEGOTIATION_PATH_MESSAGE_IDS: Record = { + server_discover: "mcpServer.testConnection.negotiationPath.serverDiscover", + initialize: "mcpServer.testConnection.negotiationPath.initialize", +}; + +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, intl: IntlShape): string { + 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; @@ -73,6 +131,15 @@ function validateHeaders(value: string): string | undefined { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return "Headers must be a JSON object."; } + // Both test endpoints type header values as strings; a number or nested + // object would only come back as a 422 from the backend. + if ( + Object.values(parsed as Record).some( + (headerValue) => typeof headerValue !== "string", + ) + ) { + return "Header values must be strings."; + } } catch (e) { return `Invalid headers JSON: ${e instanceof Error ? e.message : "Parse error"}`; } @@ -126,6 +193,7 @@ 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(""); @@ -133,6 +201,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. @@ -147,7 +216,9 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { useEffect(() => () => abortRef.current?.abort(), []); const handleTest = useCallback(async () => { + setStatus("idle"); setResponse(null); + setHandshakeResponse(null); setError(""); // Validate every field up front and surface problems inline; don't send a @@ -156,20 +227,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 = @@ -185,12 +292,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) { @@ -208,7 +309,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(); @@ -222,45 +323,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, 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 + +