diff --git a/docs/sdk/concepts/connecting-servers.mdx b/docs/sdk/concepts/connecting-servers.mdx index eef38d276..24dcc4f04 100644 --- a/docs/sdk/concepts/connecting-servers.mdx +++ b/docs/sdk/concepts/connecting-servers.mdx @@ -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"); diff --git a/docs/sdk/reference/mcp-client-manager.mdx b/docs/sdk/reference/mcp-client-manager.mdx index b266a0b60..396ec8cee 100644 --- a/docs/sdk/reference/mcp-client-manager.mdx +++ b/docs/sdk/reference/mcp-client-manager.mdx @@ -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 @@ -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 @@ -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); diff --git a/examples/evals/brightdata/brightdata-unit-test.test.ts b/examples/evals/brightdata/brightdata-unit-test.test.ts index 8b8f70ec9..c8dfbfb82 100644 --- a/examples/evals/brightdata/brightdata-unit-test.test.ts +++ b/examples/evals/brightdata/brightdata-unit-test.test.ts @@ -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({}); }); test("SDK Feature: listTools - returns available ecommerce tools", async () => { diff --git a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx index 6b61d1e99..8f4713259 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx @@ -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"; @@ -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, @@ -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; @@ -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( + null, + ); + const [isCheckingHealth, setIsCheckingHealth] = useState(false); + const healthCheckRequestIdRef = useRef(0); const { label: connectionStatusLabel, indicatorColor } = getConnectionStatusMeta(server.connectionStatus); @@ -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); @@ -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]); + useEffect(() => { let isCancelled = false; @@ -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); @@ -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 ( <> @@ -585,9 +674,51 @@ export function ServerConnectionCard({ -
+
+ {showHealthStatus && ( +
e.stopPropagation()} + > +
+ {isCheckingHealth ? ( + + ) : serverHealth?.success === false ? ( + + ) : serverHealth?.success === true ? ( + + ) : ( + + )} + {healthLabel} +
+ +
+ )}
e.stopPropagation()} > {hasInitInfo && ( diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.featureFlags.test.tsx b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.featureFlags.test.tsx index 76b57030b..2c22511c0 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.featureFlags.test.tsx +++ b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.featureFlags.test.tsx @@ -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({}), })); diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.hosted.test.tsx b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.hosted.test.tsx index 0e6c5fb21..42fcc6a03 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.hosted.test.tsx +++ b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.hosted.test.tsx @@ -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({}), })); diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx index a3387ca0c..6fddacc7c 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx +++ b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx @@ -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({}), })); @@ -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 = { @@ -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", () => { @@ -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(); + + 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(); + + 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( + , + ); + + await waitFor(() => { + expect(screen.getByText("Ping failed")).toBeInTheDocument(); + }); + }); }); describe("toggle switch", () => { @@ -246,6 +302,26 @@ describe("ServerConnectionCard", () => { }); }); + describe("health refresh", () => { + it("refreshes server health on demand", async () => { + render( + , + ); + + 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({ diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx index 39dd2732e..2f8419959 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx @@ -309,7 +309,9 @@ describe("useChatSession minimal mode parity", () => { const latestTransport = mockTransportInstances.at(-1)!; expect(latestTransport.options.api).toBe("/api/mcp/chat-v2"); expect(latestTransport.options.fetch).toBeUndefined(); - expect(await resolveConfig(latestTransport.options.headers)).toBeUndefined(); + expect( + await resolveConfig(latestTransport.options.headers), + ).toBeUndefined(); act(() => { result.current.sendMessage({ text: "hello" }); @@ -353,9 +355,9 @@ describe("useChatSession minimal mode parity", () => { expect(await resolveConfig(latestTransport.options.headers)).toEqual({ Authorization: "Bearer convex-token", }); - expect(await resolveConfig(latestTransport.options.headers)).not.toHaveProperty( - "X-MCP-Session-Auth", - ); + expect( + await resolveConfig(latestTransport.options.headers), + ).not.toHaveProperty("X-MCP-Session-Auth"); expect(mockAuthFetch).not.toHaveBeenCalled(); }); }); diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-servers-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-servers-api.ts new file mode 100644 index 000000000..b18f9ca41 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/apis/mcp-servers-api.ts @@ -0,0 +1,62 @@ +import type { ConnectionStatus } from "@/state/app-types"; +import { authFetch } from "@/lib/session-token"; +import { runByMode } from "@/lib/apis/mode-client"; + +export type ServerHealthResponse = + | { + success: true; + serverId: string; + connectionStatus: ConnectionStatus | string; + healthStatus: "healthy"; + latencyMs: number; + checkedAt: string; + } + | { + success: false; + serverId: string; + connectionStatus: ConnectionStatus | string; + healthStatus: "unhealthy"; + checkedAt: string; + latencyMs?: number; + error: string; + }; + +export async function getServerHealth( + serverId: string, +): Promise { + return runByMode({ + hosted: async () => ({ + success: false, + serverId, + connectionStatus: "connected", + healthStatus: "unhealthy", + checkedAt: new Date().toISOString(), + error: "Server health checks are only available in local mode", + }), + local: async () => { + const res = await authFetch( + `/api/mcp/servers/status/${encodeURIComponent(serverId)}`, + ); + + let body: ServerHealthResponse | null = null; + try { + body = (await res.json()) as ServerHealthResponse; + } catch {} + + if (body) { + return body; + } + + return { + success: false, + serverId, + connectionStatus: "disconnected", + healthStatus: "unhealthy", + checkedAt: new Date().toISOString(), + error: res.ok + ? "Server health check returned an empty response" + : `Server health check failed (${res.status})`, + }; + }, + }); +} diff --git a/mcpjam-inspector/server/routes/mcp/__tests__/servers.test.ts b/mcpjam-inspector/server/routes/mcp/__tests__/servers.test.ts index 10851e637..7ac12c03a 100644 --- a/mcpjam-inspector/server/routes/mcp/__tests__/servers.test.ts +++ b/mcpjam-inspector/server/routes/mcp/__tests__/servers.test.ts @@ -25,7 +25,7 @@ const createMockMcpClientManager = (overrides: Record = {}) => ({ }, ]), getConnectionStatus: vi.fn().mockReturnValue("connected"), - pingServer: vi.fn().mockReturnValue("connected"), + pingServer: vi.fn().mockResolvedValue({}), getInitializationInfo: vi.fn().mockReturnValue({ protocolVersion: "2024-11-05", capabilities: { tools: {}, resources: {} }, @@ -130,35 +130,44 @@ describe("GET /api/mcp/servers/status/:serverId", () => { const data = await res.json(); expect(data.success).toBe(true); expect(data.serverId).toBe("server-1"); - expect(data.status).toBe("connected"); + expect(data.connectionStatus).toBe("connected"); + expect(data.healthStatus).toBe("healthy"); + expect(typeof data.latencyMs).toBe("number"); + expect(typeof data.checkedAt).toBe("string"); - expect(mcpClientManager.pingServer).toHaveBeenCalledWith("server-1"); + expect(mcpClientManager.pingServer).toHaveBeenCalledWith("server-1", { + timeout: 5000, + }); }); - it("returns disconnected status for unhealthy server", async () => { - mcpClientManager.pingServer.mockReturnValue("disconnected"); + it("returns unhealthy status when ping fails", async () => { + mcpClientManager.getConnectionStatus.mockReturnValue("connected"); + mcpClientManager.pingServer.mockRejectedValue(new Error("Ping timeout")); const res = await app.request("/api/mcp/servers/status/server-2", { method: "GET", }); - expect(res.status).toBe(200); + expect(res.status).toBe(503); const data = await res.json(); - expect(data.status).toBe("disconnected"); + expect(data.success).toBe(false); + expect(data.serverId).toBe("server-2"); + expect(data.connectionStatus).toBe("connected"); + expect(data.healthStatus).toBe("unhealthy"); + expect(data.error).toBe("Ping timeout"); }); - it("returns 500 when status check fails", async () => { - mcpClientManager.pingServer.mockImplementation(() => { - throw new Error("Ping timeout"); - }); + it("returns 503 when status check fails", async () => { + mcpClientManager.pingServer.mockRejectedValue(new Error("Ping timeout")); const res = await app.request("/api/mcp/servers/status/server-1", { method: "GET", }); - expect(res.status).toBe(500); + expect(res.status).toBe(503); const data = await res.json(); expect(data.success).toBe(false); + expect(data.healthStatus).toBe("unhealthy"); expect(data.error).toBe("Ping timeout"); }); }); diff --git a/mcpjam-inspector/server/routes/mcp/servers.ts b/mcpjam-inspector/server/routes/mcp/servers.ts index e4fdf252a..c460721c2 100644 --- a/mcpjam-inspector/server/routes/mcp/servers.ts +++ b/mcpjam-inspector/server/routes/mcp/servers.ts @@ -6,6 +6,20 @@ import { logger } from "../../utils/logger"; import { HOSTED_MODE } from "../../config"; const servers = new Hono(); +const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000; + +function getSafeConnectionStatus( + mcpClientManager: { + getConnectionStatus: (serverId: string) => string; + }, + serverId: string, +): string { + try { + return mcpClientManager.getConnectionStatus(serverId); + } catch { + return "disconnected"; + } +} // List all connected servers with their status servers.get("/", async (c) => { @@ -38,24 +52,38 @@ servers.get("/", async (c) => { servers.get("/status/:serverId", async (c) => { let serverId: string | undefined; + const startedAt = Date.now(); try { serverId = c.req.param("serverId"); const mcpClientManager = c.mcpClientManager; - const status = mcpClientManager.pingServer(serverId); + + await mcpClientManager.pingServer(serverId, { + timeout: SERVER_HEALTH_CHECK_TIMEOUT_MS, + }); return c.json({ success: true, serverId, - status, + connectionStatus: getSafeConnectionStatus(mcpClientManager, serverId), + healthStatus: "healthy", + latencyMs: Date.now() - startedAt, + checkedAt: new Date().toISOString(), }); } catch (error) { logger.error("Error getting server status", error, { serverId }); return c.json( { success: false, + serverId, + connectionStatus: serverId + ? getSafeConnectionStatus(c.mcpClientManager, serverId) + : "disconnected", + healthStatus: "unhealthy", + latencyMs: Date.now() - startedAt, + checkedAt: new Date().toISOString(), error: error instanceof Error ? error.message : "Unknown error", }, - 500, + 503, ); } }); diff --git a/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts b/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts index 2a0faefbb..c3d74aea9 100644 --- a/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts +++ b/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts @@ -105,7 +105,9 @@ describe("web routes — chat-v2 guest mode", () => { beforeEach(() => { vi.clearAllMocks(); - testGuestKeyDir = mkdtempSync(path.join(os.tmpdir(), "chat-v2-guest-test-")); + testGuestKeyDir = mkdtempSync( + path.join(os.tmpdir(), "chat-v2-guest-test-"), + ); process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; initGuestTokenSecret(); process.env.CONVEX_HTTP_URL = "https://example.convex.site"; diff --git a/sdk/src/mcp-client-manager/MCPClientManager.ts b/sdk/src/mcp-client-manager/MCPClientManager.ts index 9b057cce2..a6ce3d0a7 100644 --- a/sdk/src/mcp-client-manager/MCPClientManager.ts +++ b/sdk/src/mcp-client-manager/MCPClientManager.ts @@ -698,10 +698,13 @@ export class MCPClientManager { /** * Pings a server to check connectivity. */ - pingServer(serverId: string, options?: RequestOptions): void { + async pingServer( + serverId: string, + options?: RequestOptions + ): Promise>> { const client = this.getClientOrThrow(serverId); try { - client.ping(options); + return await client.ping(this.withTimeout(serverId, options)); } catch (error) { throw new Error( `Failed to ping MCP server "${serverId}": ${error instanceof Error ? error.message : "Unknown error"}` diff --git a/sdk/tests/MCPClientManager.test.ts b/sdk/tests/MCPClientManager.test.ts index 45f6a0647..24f89e878 100644 --- a/sdk/tests/MCPClientManager.test.ts +++ b/sdk/tests/MCPClientManager.test.ts @@ -130,6 +130,11 @@ describe("MCPClientManager", () => { expect((result as any).content[0].text).toBe("Result: 30"); }, 10000); + it("should ping the HTTP server", async () => { + const result = await manager.pingServer("http-server"); + expect(result).toEqual({}); + }, 10000); + it("should list resources from HTTP server", async () => { const result = await manager.listResources("http-server"); expect(result.resources.length).toBe(MOCK_RESOURCES.length);