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
2 changes: 1 addition & 1 deletion docs/sdk/concepts/connecting-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Verify a server is responsive:

```typescript
try {
manager.pingServer("myServer");
await manager.pingServer("myServer");
} catch {
console.warn("Server not responding, reconnecting...");
await manager.disconnectServer("myServer");
Expand Down
6 changes: 3 additions & 3 deletions docs/sdk/reference/mcp-client-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ await manager.disconnectServer("myServer");
Sends a ping to check if a server is responsive.

```typescript
pingServer(serverId: string): void
pingServer(serverId: string): Promise<{}>
```

#### Parameters
Expand All @@ -196,7 +196,7 @@ pingServer(serverId: string): void

#### Returns

`void` - This method is synchronous and does not return a value.
`Promise<{}>` - Resolves when the server responds to the ping request.

#### Throws

Expand All @@ -207,7 +207,7 @@ pingServer(serverId: string): void

```typescript
try {
manager.pingServer("myServer");
await manager.pingServer("myServer");
console.log("Server is responsive");
} catch (error) {
console.warn("Server not responding:", error.message);
Expand Down
6 changes: 4 additions & 2 deletions examples/evals/brightdata/brightdata-unit-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ describe("SDK Features with shared connection", () => {
expect(brightdataSummary?.config).toBeDefined();
});

test("SDK Feature: pingServer - verifies server is responsive", () => {
expect(() => clientManager.pingServer("brightdata-ecommerce")).not.toThrow();
test("SDK Feature: pingServer - verifies server is responsive", async () => {
await expect(
clientManager.pingServer("brightdata-ecommerce")
).resolves.toEqual({});
Comment on lines +75 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid over-constraining ping payload in this liveness test.

This test verifies responsiveness; asserting exact {} couples it to payload shape and can fail on harmless protocol changes.

Proposed adjustment
-    await expect(
-      clientManager.pingServer("brightdata-ecommerce")
-    ).resolves.toEqual({});
+    await expect(
+      clientManager.pingServer("brightdata-ecommerce", { timeout: 5000 })
+    ).resolves.toEqual(expect.any(Object));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/evals/brightdata/brightdata-unit-test.test.ts` around lines 75 - 78,
The test "SDK Feature: pingServer - verifies server is responsive" currently
asserts an exact empty object from
clientManager.pingServer("brightdata-ecommerce") which over-constrains the
liveness check; change the assertion to simply verify a successful/responsive
result (for example using resolves.toBeDefined() or resolves.not.toBeNull() or
resolves.toBeTruthy()) so the test only checks server responsiveness and not the
exact payload shape.

});

test("SDK Feature: listTools - returns available ecommerce tools", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Card } from "../ui/card";
import { Button } from "../ui/button";
Expand Down Expand Up @@ -29,6 +29,10 @@ import {
} from "lucide-react";
import { ServerWithName } from "@/hooks/use-app-state";
import { exportServerApi } from "@/lib/apis/mcp-export-api";
import {
getServerHealth,
type ServerHealthResponse,
} from "@/lib/apis/mcp-servers-api";
import {
getConnectionStatusMeta,
getServerCommandDisplay,
Expand Down Expand Up @@ -57,6 +61,8 @@ import { useConvexAuth } from "convex/react";
import { HOSTED_MODE } from "@/lib/config";
import { ShareServerDialog } from "./ShareServerDialog";

const SERVER_HEALTH_POLL_INTERVAL_MS = 30000;

function isHostedInsecureHttpServer(server: ServerWithName): boolean {
if (!HOSTED_MODE || !("url" in server.config) || !server.config.url) {
return false;
Expand Down Expand Up @@ -112,6 +118,11 @@ export function ServerConnectionCard({
const [isClosingTunnel, setIsClosingTunnel] = useState(false);
const [showTunnelExplanation, setShowTunnelExplanation] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [serverHealth, setServerHealth] = useState<ServerHealthResponse | null>(
null,
);
const [isCheckingHealth, setIsCheckingHealth] = useState(false);
const healthCheckRequestIdRef = useRef(0);

const { label: connectionStatusLabel, indicatorColor } =
getConnectionStatusMeta(server.connectionStatus);
Expand Down Expand Up @@ -147,6 +158,7 @@ export function ServerConnectionCard({
const isTunnelEnabled = !HOSTED_MODE;
const canManageTunnels = isAuthenticated;
const showTunnelActions = isConnected && isTunnelEnabled;
const showHealthStatus = isConnected && !HOSTED_MODE;
const hasTunnel = Boolean(tunnelUrl);
const hasError =
server.connectionStatus === "failed" && Boolean(server.lastError);
Expand Down Expand Up @@ -199,6 +211,39 @@ export function ServerConnectionCard({
}
}, [serverTunnelUrl]);

const handleHealthCheck = useCallback(async () => {
if (!showHealthStatus) {
return;
}

const requestId = ++healthCheckRequestIdRef.current;
setIsCheckingHealth(true);

try {
const result = await getServerHealth(server.name);
if (healthCheckRequestIdRef.current !== requestId) {
return;
}
setServerHealth(result);
} catch (error) {
if (healthCheckRequestIdRef.current !== requestId) {
return;
}
setServerHealth({
success: false,
serverId: server.name,
connectionStatus: server.connectionStatus,
healthStatus: "unhealthy",
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Health check failed",
});
} finally {
if (healthCheckRequestIdRef.current === requestId) {
setIsCheckingHealth(false);
}
}
}, [server.connectionStatus, server.name, showHealthStatus]);
Comment on lines +214 to +245
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent overlapping poll requests.

The interval fires every 30s regardless of whether the previous getServerHealth() call is still running. Once a check takes longer than the poll interval, each tick increments healthCheckRequestIdRef, so completed responses are discarded as stale and the badge can stay stuck in "checking" while more pings pile up. Please gate the poll on in-flight state, or switch this loop to recursive setTimeout scheduling after each completion.

Also applies to: 273-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx`
around lines 214 - 245, The polling logic allows overlapping calls because
handleHealthCheck increments healthCheckRequestIdRef on every tick even if a
previous getServerHealth is still in-flight; update handleHealthCheck (and the
interval setup that schedules it) to gate new requests when a check is already
running by checking the in-flight state (use isCheckingHealth or a dedicated ref
like isCheckingRef) and return early if true, or replace the fixed setInterval
with a recursive setTimeout that schedules the next call only after the current
getServerHealth completes; ensure healthCheckRequestIdRef, setIsCheckingHealth,
getServerHealth and the interval/timeout scheduler (the code around the existing
useEffect that sets the 30s poll) are updated accordingly so only one network
call is active at a time.


useEffect(() => {
let isCancelled = false;

Expand All @@ -225,6 +270,27 @@ export function ServerConnectionCard({
};
}, [getAccessToken, server.name, serverTunnelUrl, showTunnelActions]);

useEffect(() => {
healthCheckRequestIdRef.current += 1;

if (!showHealthStatus) {
setServerHealth(null);
setIsCheckingHealth(false);
return;
}

void handleHealthCheck();

const intervalId = window.setInterval(() => {
void handleHealthCheck();
}, SERVER_HEALTH_POLL_INTERVAL_MS);

return () => {
healthCheckRequestIdRef.current += 1;
window.clearInterval(intervalId);
};
}, [handleHealthCheck, showHealthStatus]);

const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
Expand Down Expand Up @@ -364,6 +430,29 @@ export function ServerConnectionCard({
}
};

const healthStatusClasses =
serverHealth?.success === false
? "border-red-300/40 bg-red-500/10 text-red-700 dark:text-red-300"
: serverHealth?.success === true
? "border-emerald-300/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-border/70 bg-muted/30 text-muted-foreground";

const healthLabel =
serverHealth?.success === false
? "Ping failed"
: serverHealth?.success === true
? `Ping ok ${serverHealth.latencyMs} ms`
: isCheckingHealth
? "Checking ping..."
: "Ping pending";

const healthTitle =
serverHealth?.success === false
? serverHealth.error
: serverHealth?.success === true
? `Last ping ${new Date(serverHealth.checkedAt).toLocaleTimeString()}`
: "Checks whether the server responds to MCP ping";

return (
<>
<Card className="group h-full rounded-xl border border-border/50 bg-card/60 p-0 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md">
Expand Down Expand Up @@ -585,9 +674,51 @@ export function ServerConnectionCard({
</button>
</div>

<div className="mt-3 flex items-center justify-end">
<div className="mt-3 flex flex-wrap items-center gap-2">
{showHealthStatus && (
<div
className="flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<div
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] ${healthStatusClasses}`}
title={healthTitle}
>
{isCheckingHealth ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : serverHealth?.success === false ? (
<AlertCircle className="h-3 w-3" />
) : serverHealth?.success === true ? (
<Check className="h-3 w-3" />
) : (
<RefreshCw className="h-3 w-3" />
)}
<span>{healthLabel}</span>
</div>
<button
type="button"
onClick={() => {
posthog.capture("server_health_refresh_clicked", {
location: "server_connection_card",
platform: detectPlatform(),
environment: detectEnvironment(),
server_id: server.name,
});
void handleHealthCheck();
}}
disabled={isCheckingHealth}
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-border/70 bg-muted/30 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
aria-label="Refresh server health"
title="Refresh server health"
>
<RefreshCw
className={`h-3 w-3 ${isCheckingHealth ? "animate-spin" : ""}`}
/>
</button>
</div>
)}
<div
className="flex items-center gap-2"
className="ml-auto flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{hasInitInfo && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ vi.mock("@/lib/apis/mcp-tools-api", () => ({
listTools: vi.fn().mockResolvedValue({ tools: [], toolsMetadata: {} }),
}));

vi.mock("@/lib/apis/mcp-servers-api", () => ({
getServerHealth: vi.fn().mockResolvedValue({
success: true,
serverId: "test-server",
connectionStatus: "connected",
healthStatus: "healthy",
latencyMs: 42,
checkedAt: "2026-03-13T12:00:00.000Z",
}),
}));

vi.mock("@/lib/apis/mcp-export-api", () => ({
exportServerApi: vi.fn().mockResolvedValue({}),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ vi.mock("@/lib/apis/mcp-tools-api", () => ({
listTools: vi.fn().mockResolvedValue({ tools: [], toolsMetadata: {} }),
}));

vi.mock("@/lib/apis/mcp-servers-api", () => ({
getServerHealth: vi.fn().mockResolvedValue({
success: false,
serverId: "test-server",
connectionStatus: "connected",
healthStatus: "unhealthy",
checkedAt: "2026-03-13T12:00:00.000Z",
error: "Server health checks are only available in local mode",
}),
}));

vi.mock("@/lib/apis/mcp-export-api", () => ({
exportServerApi: vi.fn().mockResolvedValue({}),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ vi.mock("@/lib/apis/mcp-tools-api", () => ({
listTools: vi.fn().mockResolvedValue({ tools: [], toolsMetadata: {} }),
}));

vi.mock("@/lib/apis/mcp-servers-api", () => ({
getServerHealth: vi.fn().mockResolvedValue({
success: true,
serverId: "test-server",
connectionStatus: "connected",
healthStatus: "healthy",
latencyMs: 42,
checkedAt: "2026-03-13T12:00:00.000Z",
}),
}));

vi.mock("@/lib/apis/mcp-export-api", () => ({
exportServerApi: vi.fn().mockResolvedValue({}),
}));
Expand Down Expand Up @@ -58,6 +69,7 @@ vi.mock("sonner", () => ({

// Must import after mocks are set up
import { ServerConnectionCard } from "../ServerConnectionCard";
import { getServerHealth } from "@/lib/apis/mcp-servers-api";

// Mock navigator.clipboard
const mockClipboard = {
Expand Down Expand Up @@ -91,6 +103,14 @@ describe("ServerConnectionCard", () => {

beforeEach(() => {
vi.clearAllMocks();
(getServerHealth as Mock).mockResolvedValue({
success: true,
serverId: "test-server",
connectionStatus: "connected",
healthStatus: "healthy",
latencyMs: 42,
checkedAt: "2026-03-13T12:00:00.000Z",
});
});

describe("rendering", () => {
Expand Down Expand Up @@ -166,6 +186,42 @@ describe("ServerConnectionCard", () => {

expect(screen.getByText("Failed (3)")).toBeInTheDocument();
});

it("shows ping health for connected servers", async () => {
const server = createServer({ connectionStatus: "connected" });
render(<ServerConnectionCard server={server} {...defaultProps} />);

await waitFor(() => {
expect(screen.getByText("Ping ok 42 ms")).toBeInTheDocument();
});
expect(getServerHealth).toHaveBeenCalledWith("test-server");
});

it("does not check ping health for disconnected servers", () => {
const server = createServer({ connectionStatus: "disconnected" });
render(<ServerConnectionCard server={server} {...defaultProps} />);

expect(getServerHealth).not.toHaveBeenCalled();
});

it("shows failed ping state when the health check fails", async () => {
(getServerHealth as Mock).mockResolvedValue({
success: false,
serverId: "test-server",
connectionStatus: "connected",
healthStatus: "unhealthy",
checkedAt: "2026-03-13T12:00:00.000Z",
error: "Ping timeout",
});

render(
<ServerConnectionCard server={createServer()} {...defaultProps} />,
);

await waitFor(() => {
expect(screen.getByText("Ping failed")).toBeInTheDocument();
});
});
});

describe("toggle switch", () => {
Expand Down Expand Up @@ -246,6 +302,26 @@ describe("ServerConnectionCard", () => {
});
});

describe("health refresh", () => {
it("refreshes server health on demand", async () => {
render(
<ServerConnectionCard server={createServer()} {...defaultProps} />,
);

await waitFor(() => {
expect(screen.getByText("Ping ok 42 ms")).toBeInTheDocument();
});

fireEvent.click(
screen.getByRole("button", { name: "Refresh server health" }),
);

await waitFor(() => {
expect((getServerHealth as Mock).mock.calls.length).toBeGreaterThan(1);
});
});
});

describe("error display", () => {
it("shows error message when connection failed", () => {
const server = createServer({
Expand Down
Loading
Loading