Skip to content
Merged
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
60 changes: 60 additions & 0 deletions e2e/virtual-servers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,10 @@ test.describe("Virtual Servers page", () => {
await expect(detailsPanel.getByText("Visibility")).toBeVisible();
await expect(detailsPanel.getByText("Internal")).toBeVisible();
await expect(detailsPanel.getByText("development")).toBeVisible();

// "Try it" is the default tab; switch to Components before asserting on the
// component list.
await detailsPanel.getByRole("tab", { name: "Components" }).click();
await expect(detailsPanel.getByText("Get Repo Issues")).toBeVisible();
await expect(detailsPanel.getByText("GITHUB_GET_REPO_ISSUES")).toBeVisible();
await expect(detailsPanel.getByText("github://repo/{owner}/{repo}").first()).toBeVisible();
Expand All @@ -1083,6 +1087,62 @@ test.describe("Virtual Servers page", () => {
await expect(detailsPanel.getByText("github://repo/{owner}/{repo}")).toHaveCount(0);
});

test("shows a tooltip with the full endpoint when it's truncated in the Try it tab", async ({
page,
}) => {
await page.route("**/servers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ servers: [MOCK_VIRTUAL_SERVER] }),
});
});
await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_VIRTUAL_SERVER_DETAILS),
});
});

// Narrow enough that the full endpoint URL can't fit on one line, forcing
// TruncatedText's CSS ellipsis to actually clip it.
await page.setViewportSize({ width: 480, height: 800 });

await page.goto(APP.GATEWAYS);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const detailsPanel = page.getByRole("region", { name: "testVS details" });
await expect(detailsPanel).toBeVisible();

// "Try it" is the default tab, so the endpoint is visible without switching tabs.
// Scope to the tabpanel — the sidebar's own "URL" field renders the same
// endpoint value (via a different component), which would otherwise be an
// ambiguous second match.
const endpoint = detailsPanel
.getByRole("tabpanel")
.getByText(new RegExp(`/servers/${MOCK_VIRTUAL_SERVER.id}/mcp$`));
await expect(endpoint).toBeVisible();
const fullEndpointText = (await endpoint.textContent())?.trim();
expect(fullEndpointText).toBeTruthy();

// The full value stays in the DOM regardless of visual clipping — confirm
// it's actually clipped at its current rendered width before relying on
// the tooltip to reveal it.
const isTruncated = await endpoint.evaluate((el) => el.scrollWidth > el.clientWidth);
expect(isTruncated).toBe(true);

await expect(page.getByRole("tooltip")).toHaveCount(0);
await endpoint.hover();

const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText(fullEndpointText!);
});

test("details panel add source button navigates to edit the virtual server", async ({ page }) => {
await page.route("**/servers?*", async (route) => {
await route.fulfill({
Expand Down
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,
});
}
Loading
Loading