Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -53062,7 +53135,8 @@
"enum": [
"stored",
"form",
"none"
"none",
"session"
],
"title": "Credentialsource",
"default": "none"
Expand Down Expand Up @@ -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": {
Expand All @@ -53129,4 +53226,4 @@
}
}
}
}
}
3 changes: 3 additions & 0 deletions src/api/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/api/virtualServers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildUpdateVirtualServerPayload,
deleteVirtualServer,
setVirtualServerState,
testVirtualServerHandshake,
updateVirtualServerTags,
} from "./virtualServers";

Expand Down Expand Up @@ -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 },
);
});
});
});
22 changes: 22 additions & 0 deletions src/api/virtualServers.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -119,3 +120,24 @@ export function updateVirtualServer(
export function updateVirtualServerTags(serverId: string, tags: string[]): Promise<VirtualServer> {
return api.put<VirtualServer>(`/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<GatewayHandshakeResponse> {
return api.post(`/v1/virtual-servers/${encodeURIComponent(serverId)}/test-handshake`, request, {
signal,
});
}
146 changes: 146 additions & 0 deletions src/components/gateways/VirtualServerDetailsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,149 @@ 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(
<VirtualServerDetailsPanel
server={makeServer()}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

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(
<VirtualServerDetailsPanel
server={makeServer()}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

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(
<VirtualServerDetailsPanel
server={makeServer()}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

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(
<VirtualServerDetailsPanel
server={makeServer()}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

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("resets to the components tab when a new server is selected", async () => {
const user = userEvent.setup();
const { rerender } = render(
<VirtualServerDetailsPanel
server={makeServer({ id: "s1" })}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

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(
<VirtualServerDetailsPanel
server={makeServer({ id: "s2" })}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

await waitFor(() => {
expect(screen.getByRole("tab", { name: "Components" })).toHaveAttribute(
"aria-selected",
"true",
);
});
});
});
Loading
Loading