From 99e718ca7dfec0bcd0e01c5625f698c55cce8e26 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 | 163 +++++++- .../servers/TestConnectionPanel.tsx | 372 ++++++++++++++---- 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, 845 insertions(+), 71 deletions(-) diff --git a/openapi.json b/openapi.json index 7af09ad..99e3566 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": { @@ -52526,6 +52609,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..9905e5f 100644 --- a/src/components/servers/TestConnectionPanel.test.tsx +++ b/src/components/servers/TestConnectionPanel.test.tsx @@ -1,11 +1,13 @@ 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"; const TEST_ENDPOINT = "*/v1/mcp-servers/test"; +const HANDSHAKE_ENDPOINT = "*/v1/mcp-servers/test-handshake"; describe("TestConnectionPanel", () => { const defaultProps = { @@ -392,4 +394,163 @@ 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)); + }); + }); }); diff --git a/src/components/servers/TestConnectionPanel.tsx b/src/components/servers/TestConnectionPanel.tsx index 41f1514..5abcc09 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, 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 } from "react-intl"; import { parseApiError } from "@/lib/errorUtils"; import { cn } from "@/lib/utils"; @@ -20,6 +28,44 @@ 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", +}; + +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}
+
+ ); +} const HTTP_METHODS = ["Get", "Post", "Put", "Delete", "Patch"] as const; @@ -123,7 +169,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 +179,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 +195,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,7 +204,7 @@ 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) { @@ -189,6 +239,33 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { 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; + } + try { const result = await serversApi.testConnectivity(payload, controller.signal); if (controller.signal.aborted) { @@ -206,7 +283,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,15 +297,58 @@ 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 headline = + mode === "handshake" + ? handshakeResponse + ? handshakeResponse.success + ? intl.formatMessage({ id: "mcpServer.testConnection.handshakeSucceeded" }) + : intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" }) + : error || intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" }) + : response + ? `Status: ${response.statusCode} ${status === "success" ? "OK" : "error"}` + : error || "Connection failed"; const isTesting = status === "testing"; const hasResult = status === "success" || status === "error"; return (
+ { + setMode(value as TestMode); + setStatus("idle"); + setResponse(null); + setHandshakeResponse(null); + setError(""); + setErrors({}); + }} + > + + + {intl.formatMessage({ id: "mcpServer.testConnection.mode.http" })} + + + {intl.formatMessage({ id: "mcpServer.testConnection.mode.handshake" })} + + + +
{/* Left column — request form */}
@@ -259,32 +379,34 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) {
{/* Method */} -
- Method - - {HTTP_METHODS.map((m) => ( - - {m} - - ))} - -
+ {mode === "http" && ( +
+ Method + + {HTTP_METHODS.map((m) => ( + + {m} + + ))} + +
+ )} {/* Path */}
@@ -313,23 +435,25 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) {
{/* Content type */} -
- Content type - -
+ {mode === "http" && ( +
+ Content type + +
+ )} {/* Headers */}
@@ -355,10 +479,15 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { {errors.headers}

)} + {mode === "handshake" && ( +

+ {intl.formatMessage({ id: "mcpServer.testConnection.storedCredentialsHint" })} +

+ )}
{/* Body — not applicable to GET requests */} - {method !== "Get" && ( + {mode === "http" && method !== "Get" && (
Body @@ -450,7 +579,6 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { )} -
{status === "success" ? ( @@ -462,21 +590,133 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) {
- {response && ( -

- Latency: {response.latencyMs} ms -

- )} - - {responseBodyText && ( -
-

Response body:

-
-                      
-                        
-                      
-                    
-
+ {mode === "http" ? ( + <> + {response && ( +

+ Latency: {response.latencyMs} ms +

+ )} + + {responseBodyText && ( +
+

Response body:

+
+                          
+                            
+                          
+                        
+
+ )} + + ) : ( + <> + {handshakeResponse && ( +

+ Latency: {handshakeResponse.latencyMs} ms +

+ )} + + {handshakeResponse?.success && ( +
+ {handshakeResponse.serverName && ( + + {handshakeResponse.serverName} + + )} + {handshakeResponse.serverVersion && ( + + {handshakeResponse.serverVersion} + + )} + {handshakeResponse.protocolVersion && ( + + {handshakeResponse.protocolVersion} + + )} + {handshakeResponse.negotiationPath && ( + + {handshakeResponse.negotiationPath === "server_discover" + ? "server/discover" + : "initialize"} + + )} + + {intl.formatMessage({ + id: CREDENTIAL_SOURCE_MESSAGE_IDS[ + handshakeResponse.credentialSource ?? "none" + ], + })} + +
+ )} + + {handshakeResponse?.success && handshakeCountChips.length > 0 && ( +
+ {handshakeCountChips.map((key) => { + const count = handshakeResponse.componentCounts?.[key] ?? 0; + const messageIds = handshakeResponse.countsPartial + ? PARTIAL_COUNT_MESSAGE_IDS + : COUNT_MESSAGE_IDS; + return ( + + {intl.formatMessage({ id: messageIds[key] }, { count })} + + ); + })} +
+ )} + + {status === "error" && ( +
+ {handshakeResponse?.failureClass && ( + + {intl.formatMessage({ + id: FAILURE_CLASS_MESSAGE_IDS[handshakeResponse.failureClass], + })} + + )} + {(handshakeResponse?.error || error) && ( +

+ {handshakeResponse?.error ?? error} +

+ )} +
+ )} + + {handshakeRawPreview && ( +
+ + {intl.formatMessage({ id: "mcpServer.testConnection.rawResponse" })} + +
+                          
+                            
+                          
+                        
+
+ )} + )}
)} diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 89ba9ac..a088053 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -31,5 +31,30 @@ "mcpServer.catalog.noneConnected": "No MCP server catalog options are connected.", "mcpServer.catalog.disabled": "Server catalog is disabled for this gateway.", "mcpServer.catalog.error": "Unable to load server catalog. Try again.", - "mcpServer.catalog.retry": "Retry" + "mcpServer.catalog.retry": "Retry", + "mcpServer.testConnection.mode.http": "HTTP request", + "mcpServer.testConnection.mode.handshake": "MCP handshake", + "mcpServer.testConnection.handshakeSucceeded": "Handshake succeeded", + "mcpServer.testConnection.handshakeFailed": "Handshake failed", + "mcpServer.testConnection.handshakeError": "Handshake test failed. Please try again.", + "mcpServer.testConnection.failureClass.transport": "Transport", + "mcpServer.testConnection.failureClass.protocol": "Protocol negotiation", + "mcpServer.testConnection.failureClass.auth": "Authentication", + "mcpServer.testConnection.failureClass.invalidResponse": "Invalid response", + "mcpServer.testConnection.serverName": "Server name", + "mcpServer.testConnection.serverVersion": "Server version", + "mcpServer.testConnection.protocolVersion": "Protocol version", + "mcpServer.testConnection.negotiationPath": "Negotiation path", + "mcpServer.testConnection.credentialSource": "Credential source", + "mcpServer.testConnection.credentialSource.stored": "Stored server credentials", + "mcpServer.testConnection.credentialSource.form": "Form headers", + "mcpServer.testConnection.credentialSource.none": "None", + "mcpServer.testConnection.storedCredentialsHint": "Stored credentials for registered servers are used automatically; headers you enter here override them.", + "mcpServer.testConnection.rawResponse": "Raw response (truncated)", + "mcpServer.testConnection.counts.tools": "{count, plural, one {# tool} other {# tools}}", + "mcpServer.testConnection.counts.resources": "{count, plural, one {# resource} other {# resources}}", + "mcpServer.testConnection.counts.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "mcpServer.testConnection.countsPartial.tools": "{count}+ tools", + "mcpServer.testConnection.countsPartial.resources": "{count}+ resources", + "mcpServer.testConnection.countsPartial.prompts": "{count}+ prompts" } diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index ae04dba..3110206 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -31,5 +31,30 @@ "mcpServer.catalog.noneConnected": "Ninguna opción del catálogo de servidores MCP está conectada.", "mcpServer.catalog.disabled": "El catálogo de servidores está deshabilitado para esta puerta de enlace.", "mcpServer.catalog.error": "No se pudo cargar el catálogo de servidores. Inténtalo de nuevo.", - "mcpServer.catalog.retry": "Reintentar" + "mcpServer.catalog.retry": "Reintentar", + "mcpServer.testConnection.mode.http": "Solicitud HTTP", + "mcpServer.testConnection.mode.handshake": "Handshake MCP", + "mcpServer.testConnection.handshakeSucceeded": "Handshake completado", + "mcpServer.testConnection.handshakeFailed": "Handshake fallido", + "mcpServer.testConnection.handshakeError": "La prueba de handshake falló. Inténtalo de nuevo.", + "mcpServer.testConnection.failureClass.transport": "Transporte", + "mcpServer.testConnection.failureClass.protocol": "Negociación de protocolo", + "mcpServer.testConnection.failureClass.auth": "Autenticación", + "mcpServer.testConnection.failureClass.invalidResponse": "Respuesta no válida", + "mcpServer.testConnection.serverName": "Nombre del servidor", + "mcpServer.testConnection.serverVersion": "Versión del servidor", + "mcpServer.testConnection.protocolVersion": "Versión del protocolo", + "mcpServer.testConnection.negotiationPath": "Ruta de negociación", + "mcpServer.testConnection.credentialSource": "Origen de credenciales", + "mcpServer.testConnection.credentialSource.stored": "Credenciales almacenadas del servidor", + "mcpServer.testConnection.credentialSource.form": "Encabezados del formulario", + "mcpServer.testConnection.credentialSource.none": "Ninguna", + "mcpServer.testConnection.storedCredentialsHint": "Las credenciales almacenadas de los servidores registrados se usan automáticamente; los encabezados que introduzcas aquí las anulan.", + "mcpServer.testConnection.rawResponse": "Respuesta sin procesar (truncada)", + "mcpServer.testConnection.counts.tools": "{count, plural, one {# herramienta} other {# herramientas}}", + "mcpServer.testConnection.counts.resources": "{count, plural, one {# recurso} other {# recursos}}", + "mcpServer.testConnection.counts.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "mcpServer.testConnection.countsPartial.tools": "{count}+ herramientas", + "mcpServer.testConnection.countsPartial.resources": "{count}+ recursos", + "mcpServer.testConnection.countsPartial.prompts": "{count}+ prompts" } diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 6d5a4bc..59aed79 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -31,5 +31,30 @@ "mcpServer.catalog.noneConnected": "Nenhuma opção do catálogo de servidores MCP está conectada.", "mcpServer.catalog.disabled": "O catálogo de servidores está desabilitado para este gateway.", "mcpServer.catalog.error": "Não foi possível carregar o catálogo de servidores. Tente novamente.", - "mcpServer.catalog.retry": "Tentar novamente" + "mcpServer.catalog.retry": "Tentar novamente", + "mcpServer.testConnection.mode.http": "Requisição HTTP", + "mcpServer.testConnection.mode.handshake": "Handshake MCP", + "mcpServer.testConnection.handshakeSucceeded": "Handshake concluído", + "mcpServer.testConnection.handshakeFailed": "Falha no handshake", + "mcpServer.testConnection.handshakeError": "O teste de handshake falhou. Tente novamente.", + "mcpServer.testConnection.failureClass.transport": "Transporte", + "mcpServer.testConnection.failureClass.protocol": "Negociação de protocolo", + "mcpServer.testConnection.failureClass.auth": "Autenticação", + "mcpServer.testConnection.failureClass.invalidResponse": "Resposta inválida", + "mcpServer.testConnection.serverName": "Nome do servidor", + "mcpServer.testConnection.serverVersion": "Versão do servidor", + "mcpServer.testConnection.protocolVersion": "Versão do protocolo", + "mcpServer.testConnection.negotiationPath": "Caminho de negociação", + "mcpServer.testConnection.credentialSource": "Origem das credenciais", + "mcpServer.testConnection.credentialSource.stored": "Credenciais armazenadas do servidor", + "mcpServer.testConnection.credentialSource.form": "Cabeçalhos do formulário", + "mcpServer.testConnection.credentialSource.none": "Nenhuma", + "mcpServer.testConnection.storedCredentialsHint": "As credenciais armazenadas de servidores registrados são usadas automaticamente; os cabeçalhos inseridos aqui as substituem.", + "mcpServer.testConnection.rawResponse": "Resposta bruta (truncada)", + "mcpServer.testConnection.counts.tools": "{count, plural, one {# ferramenta} other {# ferramentas}}", + "mcpServer.testConnection.counts.resources": "{count, plural, one {# recurso} other {# recursos}}", + "mcpServer.testConnection.counts.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "mcpServer.testConnection.countsPartial.tools": "{count}+ ferramentas", + "mcpServer.testConnection.countsPartial.resources": "{count}+ recursos", + "mcpServer.testConnection.countsPartial.prompts": "{count}+ prompts" }