diff --git a/README.md b/README.md index d14afad49..0b7750293 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,44 @@ List of available preview features: - `mcpUI` - Enables an optional web-based UI for interacting with the MCP server. +#### Monitoring Server (Health Check & Metrics) + +When running with `--transport http`, you can expose a separate monitoring HTTP server for health checks and metrics. This server is only started when both `monitoringServerHost` and `monitoringServerPort` are set, and it listens on its own host/port (independent from the main `httpHost`/`httpPort`). + +The features exposed are controlled by `monitoringServerFeatures` (default: `health-check`). Available features and their endpoints: + +| Feature | Endpoint | Description | +| -------------- | ---------- | ------------------------------------------------------------------------------------------- | +| `health-check` | `/health` | Returns `200 OK` with a JSON body describing the server status. Useful for liveness probes. | +| `metrics` | `/metrics` | Returns server metrics in Prometheus text format. | + +The `/health` response is sent with `Cache-Control: no-store` and has the following shape (`status` is always `"ok"` while the process is alive): + +```json +{ + "status": "ok", + "version": "1.13.0", + "uptimeSeconds": 42, + "timestamp": "2026-06-20T12:00:00.000Z" +} +``` + +Example: start the server with the monitoring server enabled and call the health-check endpoint: + +```shell +npx -y mongodb-mcp-server@latest --transport http --httpHost 0.0.0.0 --httpPort 3000 --monitoringServerHost 0.0.0.0 --monitoringServerPort 8080 & +curl http://0.0.0.0:8080/health +# => {"status":"ok","version":"1.13.0","uptimeSeconds":42,"timestamp":"2026-06-20T12:00:00.000Z"} +``` + +To expose both endpoints, pass the features explicitly: + +```shell +npx -y mongodb-mcp-server@latest --transport http --monitoringServerHost 0.0.0.0 --monitoringServerPort 8080 --monitoringServerFeatures health-check,metrics +``` + +> **💡 Note:** `healthCheckHost` / `healthCheckPort` are deprecated aliases for `monitoringServerHost` / `monitoringServerPort` and continue to serve the same `/health` endpoint. + ### Atlas API Access To use the Atlas API tools, you'll need to create a service account in MongoDB Atlas: diff --git a/src/transports/monitoringServer.ts b/src/transports/monitoringServer.ts index a980012c4..a686854cb 100644 --- a/src/transports/monitoringServer.ts +++ b/src/transports/monitoringServer.ts @@ -1,5 +1,6 @@ import type express from "express"; import { LogId } from "../common/logging/loggingDefinitions.js"; +import { packageInfo } from "../common/packageInfo.js"; import type { DefaultMetrics, MonitoringServerFeature, @@ -53,7 +54,14 @@ export class MonitoringServer protected override setupRoutes(): Promise { if (this.features.includes("health-check")) { this.app.get("/health", (_req: express.Request, res: express.Response) => { - res.json({ status: "ok" }); + // Health responses should never be cached by proxies or load balancers. + res.set("Cache-Control", "no-store"); + res.json({ + status: "ok", + version: packageInfo.version, + uptimeSeconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }); }); } diff --git a/tests/integration/transports/streamableHttp.test.ts b/tests/integration/transports/streamableHttp.test.ts index 826e65ecb..1f9680358 100644 --- a/tests/integration/transports/streamableHttp.test.ts +++ b/tests/integration/transports/streamableHttp.test.ts @@ -24,6 +24,13 @@ import type { IncomingMessage } from "node:http"; import { AsyncLocalStorage } from "node:async_hooks"; import { sleep } from "../../../src/common/managedTimeout.js"; +const expectedHealthData: Record = { + status: "ok", + version: expect.any(String) as unknown, + uptimeSeconds: expect.any(Number) as unknown, + timestamp: expect.any(String) as unknown, +}; + describe("StreamableHttpRunner", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let runner: StreamableHttpRunner; @@ -886,7 +893,7 @@ describe("StreamableHttpRunner", () => { const healthResponse = await fetch("http://localhost:3001/health"); expect(healthResponse.status).toBe(200); const healthData = (await healthResponse.json()) as unknown; - expect(healthData).toEqual({ status: "ok" }); + expect(healthData).toEqual(expectedHealthData); }); it("does not start the monitoring server when not configured", async () => { @@ -929,7 +936,7 @@ describe("StreamableHttpRunner", () => { const healthResponse = await fetch(`${runner["monitoringServer"]!.serverAddress}/health`); expect(healthResponse.status).toBe(200); const healthData = (await healthResponse.json()) as unknown; - expect(healthData).toEqual({ status: "ok" }); + expect(healthData).toEqual(expectedHealthData); }); }); @@ -952,7 +959,7 @@ describe("StreamableHttpRunner", () => { const healthResponse = await fetch("http://localhost:3001/health"); expect(healthResponse.status).toBe(200); const healthData = (await healthResponse.json()) as unknown; - expect(healthData).toEqual({ status: "ok" }); + expect(healthData).toEqual(expectedHealthData); }); it("does not start the monitoring server when not configured", async () => { diff --git a/tests/unit/transports/monitoringServer.test.ts b/tests/unit/transports/monitoringServer.test.ts index 36b27a643..a453136a3 100644 --- a/tests/unit/transports/monitoringServer.test.ts +++ b/tests/unit/transports/monitoringServer.test.ts @@ -47,9 +47,20 @@ describe("MonitoringServer", () => { const response = await fetch(`${server.serverAddress}/health`); expect(response.status).toBe(200); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const body = await response.json(); - expect(body).toEqual({ status: "ok" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = (await response.json()) as { + status: string; + version: string; + uptimeSeconds: number; + timestamp: string; + }; + // status remains "ok" for backward compatibility with existing probes + expect(body.status).toBe("ok"); + expect(typeof body.version).toBe("string"); + expect(typeof body.uptimeSeconds).toBe("number"); + expect(body.uptimeSeconds).toBeGreaterThanOrEqual(0); + // timestamp is a valid ISO date string + expect(Number.isNaN(Date.parse(body.timestamp))).toBe(false); }); it("does not expose health endpoint when health-check feature is disabled", async () => { diff --git a/tests/unit/transports/streamableHttp.test.ts b/tests/unit/transports/streamableHttp.test.ts index 598084353..ea8bdbab6 100644 --- a/tests/unit/transports/streamableHttp.test.ts +++ b/tests/unit/transports/streamableHttp.test.ts @@ -13,6 +13,13 @@ import { MockMetrics } from "../mocks/metrics.js"; import type { CreateSessionStoreFn, ISessionStore } from "../../../src/common/sessionStore.js"; import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +const expectedHealthData: Record = { + status: "ok", + version: expect.any(String) as unknown, + uptimeSeconds: expect.any(Number) as unknown, + timestamp: expect.any(String) as unknown, +}; + describe("StreamableHttpRunner", () => { describe("monitoring server initialization", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -52,7 +59,7 @@ describe("StreamableHttpRunner", () => { // Verify the custom server is actually serving requests const address = customServer.serverAddress; - expect(await fetch(`${address}/health`).then((res) => res.json())).toEqual({ status: "ok" }); + expect(await fetch(`${address}/health`).then((res) => res.json())).toEqual(expectedHealthData); }); it("supports extending MonitoringServer with custom routes via hook", async () => { @@ -86,7 +93,7 @@ describe("StreamableHttpRunner", () => { }); // Verify default routes from parent class still work - expect(await fetch(`${address}/health`).then((res) => res.json())).toEqual({ status: "ok" }); + expect(await fetch(`${address}/health`).then((res) => res.json())).toEqual(expectedHealthData); const metricsResponse = await fetch(`${address}/metrics`); expect(metricsResponse.status).toBe(200); });