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 + +