diff --git a/openapi.json b/openapi.json index 30026ea..0e9b23e 100644 --- a/openapi.json +++ b/openapi.json @@ -36886,6 +36886,79 @@ } } } + }, + "/v1/virtual-servers/{server_id}/test-handshake": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Test Server Mcp Handshake", + "description": "Test whether a virtual server's own MCP endpoint speaks MCP via a protocol handshake.\n\nUnlike ``POST /gateways/test-handshake``, the target isn't an arbitrary\ncaller-supplied URL \u2014 it's this server's own ``/servers/{server_id}/mcp``\ntransport, resolved from a server ID the caller already has read access to.\nThe handshake runs in-process (no outbound network call, no SSRF allowlist),\nreusing the caller's own forwarded credentials by default so the result\nreflects what that caller would actually see.\n\nArgs:\n server_id (str): The ID of the virtual server to test.\n request (Request): The incoming request, used for scoped access validation and to forward the caller's own credentials.\n body (ServerHandshakeRequest): Optional header overrides for the handshake.\n db (Session): The database session used to interact with the data store.\n user: Authenticated user context.\n\nReturns:\n GatewayHandshakeResponse: The handshake outcome, including negotiation path,\n server identity, capabilities, component counts, and failure classification.\n\nRaises:\n HTTPException: If the server is not found or the caller lacks visibility.", + "operationId": "test_server_mcp_handshake_v1_virtual_servers__server_id__test_handshake_post", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "server_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Server Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerHandshakeRequest" + } + } + } + }, + "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": { @@ -53062,7 +53135,8 @@ "enum": [ "stored", "form", - "none" + "none", + "session" ], "title": "Credentialsource", "default": "none" @@ -53116,6 +53190,29 @@ "title": "GatewayHandshakeResponse", "description": "Result of an MCP handshake test.", "nullable": true + }, + "ServerHandshakeRequest": { + "properties": { + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers", + "description": "Optional headers (e.g. Authorization) overriding the caller's own forwarded credentials" + } + }, + "type": "object", + "title": "ServerHandshakeRequest", + "description": "Request to run an MCP handshake test against a virtual server's own endpoint.\n\nUnlike :class:`GatewayHandshakeRequest`, the target is derived from the\ntrusted, already-registered virtual server ID (path parameter) rather than\nan arbitrary caller-supplied URL, so no ``base_url``/``path`` fields exist here.", + "nullable": true } }, "securitySchemes": { @@ -53129,4 +53226,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/api/servers.ts b/src/api/servers.ts index b131491..4f7b6b9 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -140,6 +140,9 @@ export const serversApi = { * * Tries the stateless server/discover method (MCP 2026-07-28+) first and * falls back to a stateful initialize round-trip for earlier specs. + * + * Calls POST /v1/mcp-servers/test-handshake. Returns a structured + * GatewayHandshakeResponse describing the negotiation outcome. */ testHandshake: ( request: GatewayHandshakeRequest, diff --git a/src/api/virtualServers.test.ts b/src/api/virtualServers.test.ts index 154eccb..5b22ab6 100644 --- a/src/api/virtualServers.test.ts +++ b/src/api/virtualServers.test.ts @@ -5,6 +5,7 @@ import { buildUpdateVirtualServerPayload, deleteVirtualServer, setVirtualServerState, + testVirtualServerHandshake, updateVirtualServerTags, } from "./virtualServers"; @@ -314,4 +315,37 @@ describe("virtualServers API", () => { expect(api.put).toHaveBeenCalledWith("/servers/team%2F1", { tags: ["x"] }); }); + + describe("testVirtualServerHandshake", () => { + it("POSTs to /v1/virtual-servers/{id}/test-handshake with the request body and signal", async () => { + const response = { success: true, latencyMs: 12, credentialSource: "session" }; + vi.mocked(api.post).mockResolvedValue(response); + const controller = new AbortController(); + + const result = await testVirtualServerHandshake( + "server-1", + { headers: { Authorization: "Bearer tok" } }, + controller.signal, + ); + + expect(api.post).toHaveBeenCalledWith( + "/v1/virtual-servers/server-1/test-handshake", + { headers: { Authorization: "Bearer tok" } }, + { signal: controller.signal }, + ); + expect(result).toBe(response); + }); + + it("URL-encodes the server ID", async () => { + vi.mocked(api.post).mockResolvedValue({ success: true, latencyMs: 1 }); + + await testVirtualServerHandshake("team/1", {}); + + expect(api.post).toHaveBeenCalledWith( + "/v1/virtual-servers/team%2F1/test-handshake", + {}, + { signal: undefined }, + ); + }); + }); }); diff --git a/src/api/virtualServers.ts b/src/api/virtualServers.ts index 9c59765..bab8263 100644 --- a/src/api/virtualServers.ts +++ b/src/api/virtualServers.ts @@ -1,6 +1,7 @@ import { api } from "@/api/client"; import type { CreateServerDetails } from "@/components/gateways/types"; import type { VirtualServer } from "@/types/server"; +import type { GatewayHandshakeResponse, ServerHandshakeRequest } from "@/generated/types"; export interface CreateVirtualServerPayload { server: { @@ -119,3 +120,24 @@ export function updateVirtualServer( export function updateVirtualServerTags(serverId: string, tags: string[]): Promise { return api.put(`/servers/${encodeURIComponent(serverId)}`, { tags }); } + +/** + * Test whether a virtual server's own MCP endpoint speaks MCP via a protocol handshake. + * + * Unlike the gateway-scoped {@link serversApi.testHandshake}, the target isn't a + * caller-supplied URL — the backend derives it from the server's own ID and + * dispatches in-process, reusing the caller's own forwarded credentials + * (session/bearer token) by default. `request.headers` overrides those + * credentials when provided. + * + * Calls POST /v1/virtual-servers/{id}/test-handshake. + */ +export function testVirtualServerHandshake( + serverId: string, + request: ServerHandshakeRequest, + signal?: AbortSignal, +): Promise { + return api.post(`/v1/virtual-servers/${encodeURIComponent(serverId)}/test-handshake`, request, { + signal, + }); +} diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index afbe873..be7fe78 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -401,3 +401,191 @@ describe("VirtualServerDetailsPanel render variants", () => { expect(allTab).toHaveAttribute("aria-selected", "true"); }); }); + +describe("VirtualServerDetailsPanel test connection tab", () => { + const HANDSHAKE_ENDPOINT = "*/v1/virtual-servers/:serverId/test-handshake"; + + beforeEach(() => { + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [] })), + http.get("*/servers/:id/resources", () => HttpResponse.json({ resources: [] })), + http.get("*/servers/:id/prompts", () => HttpResponse.json({ prompts: [] })), + ); + }); + + it("renders the Components and Test connection top-level tabs", async () => { + render( + , + ); + + expect(await screen.findByRole("tab", { name: "Components" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Test connection" })).toBeInTheDocument(); + }); + + it("switches to the test panel and shows the handshake form", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + + expect(screen.getByRole("button", { name: /^test connection$/i })).toBeInTheDocument(); + expect(screen.getByText(/run a test to see the result here/i)).toBeInTheDocument(); + }); + + it("runs a handshake and displays a successful result", async () => { + const user = userEvent.setup(); + mswServer.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 42, + serverName: "Test MCP", + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/latency: 42 ms/i)).toBeInTheDocument(); + }); + + it("flags a component-count mismatch using the panel's own aggregated counts", async () => { + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 0 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect( + await screen.findByText(/counts don.t match the virtual server.s aggregate/i), + ).toBeInTheDocument(); + }); + + it("excludes disabled components from the aggregate used for the handshake comparison", async () => { + // The drawer's own queries pass include_inactive=true, but the handshake's + // component_counts only ever reflect enabled components — a disabled tool + // must not count toward the aggregate or it would permanently mismatch. + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ + tools: [ + { id: "t1", name: "tool-1", originalName: "tool-1", enabled: true }, + { id: "t2", name: "tool-2", originalName: "tool-2", enabled: false }, + ], + }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 1 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/handshake succeeded/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); + }); + + it("resets to the components tab when a new server is selected", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Test connection" })); + expect(screen.getByRole("button", { name: /^test connection$/i })).toBeInTheDocument(); + + // Simulate opening a different server — the panel resets to Components. + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Components" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + }); +}); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 76984ec..a4afc6c 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -12,6 +12,8 @@ import { Search, Wrench, } from "lucide-react"; +import { HandshakeTestPanel } from "@/components/servers/HandshakeTestPanel"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VisibilityInfoPopover, getVisibilityIcon, @@ -44,6 +46,12 @@ const COMPONENT_FILTER_OPTIONS: Array<{ value: ComponentFilter; labelId: string { value: "prompts", labelId: "gateways.details.filter.prompts" }, ]; +type TopTab = "components" | "test"; + +// Segmented-control styling shared with MCPServerDetailsPanel +const SEGMENTED_TRIGGER_CLASS = + "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; + interface Tool { id: string; name: string; @@ -52,6 +60,7 @@ interface Tool { description?: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } interface Resource { @@ -61,6 +70,7 @@ interface Resource { uri: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } interface Prompt { @@ -71,6 +81,7 @@ interface Prompt { description?: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } type ComponentWithType = @@ -144,6 +155,7 @@ export function VirtualServerDetailsPanel({ const tagFallback = intl.formatMessage({ id: "gateways.details.tagFallback" }); const notSyncedYet = intl.formatMessage({ id: "gateways.card.notSyncedYet" }); const tags = (server?.tags ?? []).map((tag, index) => getTagDisplay(tag, index, tagFallback)); + const [topTab, setTopTab] = useState("components"); const [sourceFilter, setSourceFilter] = useState("all"); const [componentFilter, setComponentFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -274,6 +286,21 @@ export function VirtualServerDetailsPanel({ const allComponents = fetchedComponents.length > 0 ? fetchedComponents : fallbackComponents; + // The virtual server's own aggregated component counts, used to flag a + // mismatch against what the handshake test itself reports. The handshake + // counts come from tools/resources/prompts `list` calls against the live + // MCP endpoint, which only ever see enabled components — so a disabled + // component here must be excluded too, or a server with one disabled tool + // would show a permanent, spurious mismatch. + const aggregatedComponentCounts = useMemo(() => { + const counts: Record = { tools: 0, resources: 0, prompts: 0 }; + for (const component of allComponents) { + if (component.enabled === false) continue; + counts[component.type] = (counts[component.type] ?? 0) + 1; + } + return counts; + }, [allComponents]); + const sourceIds = useMemo( () => Array.from( @@ -316,9 +343,10 @@ export function VirtualServerDetailsPanel({ const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; - // Reset filter and search when the panel opens or the selected server changes. + // Reset tab, filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; + setTopTab("components"); setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); @@ -447,205 +475,238 @@ export function VirtualServerDetailsPanel({
- {(sourcesLoading || sourceTabs.length > 0) && ( -
- {[ - { - id: "all", - label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), - isTruncated: false, - fullValue: undefined as string | undefined, - }, - ...sourceTabs, - ].map((source, index, sources) => { - const isSelected = sourceFilter === source.id; - const tabButton = ( + setTopTab(v as TopTab)} + aria-label="Virtual server details view" + > + + + {intl.formatMessage({ id: "gateways.details.components" })} + + + {intl.formatMessage({ id: "gateways.card.testConnection" })} + + + + + + + + + {(sourcesLoading || sourceTabs.length > 0) && ( +
+ {[ + { + id: "all", + label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), + isTruncated: false, + fullValue: undefined as string | undefined, + }, + ...sourceTabs, + ].map((source, index, sources) => { + const isSelected = sourceFilter === source.id; + const tabButton = ( + + ); + + return ( + + {tabButton} + {source.isTruncated && ( + {source.fullValue} + )} + + ); + })} +
+ )} + +
+
+ {COMPONENT_FILTER_OPTIONS.map((option) => ( + + ))} +
+
- ); - - return ( - - {tabButton} - {source.isTruncated && {source.fullValue}} - - ); - })} -
- )} - -
-
- {COMPONENT_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- - 0 ? 0 : -1} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onFocus={() => setIsSearchExpanded(true)} - onBlur={() => setIsSearchExpanded(searchQuery.length > 0)} - placeholder={isSearchExpanded || searchQuery.length > 0 ? "Search..." : ""} - className={cn( - "h-8 rounded-md border-border bg-muted/50 text-sm shadow-none transition-[width,padding,color,background-color,border-color] duration-200 ease-out placeholder:text-muted-foreground focus-visible:bg-background", - isSearchExpanded || searchQuery.length > 0 - ? "w-48 px-3 text-foreground" - : "w-0 px-0 text-transparent caret-foreground border-transparent", - )} - /> -
-
- - {error && ( -
- {error.message} -
- )} - -
- {componentsLoading && ( -
-
- )} - {!componentsLoading && - visibleComponents.map((component) => { - const title = component.title; - const identifier = getComponentIdentifier(component); + {error && ( +
+ {error.message} +
+ )} - return ( +
+ {componentsLoading && (
- - - {getComponentIcon(component.type)} - - {getComponentLabel(component.type)} - - {title ? ( - <> - - {title} - - - {identifier} - - - - ) : ( - <> - - {identifier} - - -
- ); - })} + )} - {!componentsLoading && visibleComponents.length === 0 && ( -
- No {componentFilter === "all" ? "components" : componentFilter} found + {!componentsLoading && + visibleComponents.map((component) => { + const title = component.title; + const identifier = getComponentIdentifier(component); + + return ( +
+ + + {getComponentIcon(component.type)} + + {getComponentLabel(component.type)} + + {title ? ( + <> + + {title} + + + {identifier} + + + + ) : ( + <> + + {identifier} + + +
+ ); + })} + + {!componentsLoading && visibleComponents.length === 0 && ( +
+ No {componentFilter === "all" ? "components" : componentFilter} found +
+ )}
- )} -
+ +