-
-
Notifications
You must be signed in to change notification settings - Fork 204
Add MCP server ping health checks #1605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
@@ -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<ServerHealthResponse | null>( | ||
| 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]); | ||
|
Comment on lines
+214
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent overlapping poll requests. The interval fires every 30s regardless of whether the previous Also applies to: 273-286 🤖 Prompt for AI Agents |
||
|
|
||
| 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 ( | ||
| <> | ||
| <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"> | ||
|
|
@@ -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 && ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🤖 Prompt for AI Agents