diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8dda7e0d..734c2a81 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -14,7 +14,7 @@ services: env_file: - ../.env.local # 读取仓库根目录的本地环境变量文件 healthcheck: - test: ["CMD", "sh", "-c", "curl -f http://localhost:${NGINX_PORT:-80}/ && curl -f http://localhost:${NGINX_PORT:-80}/mcp"] + test: ["CMD", "sh", "-c", "curl -fsS http://localhost:${NGINX_PORT:-80}/healthz >/dev/null"] interval: 30s timeout: 10s retries: 3 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 07506c57..dbd6bf5c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "28081:${NGINX_PORT:-80}" # Web应用端口(包含MCP服务器,通过/mcp路径访问) healthcheck: - test: ["CMD", "sh", "-c", "curl -f http://localhost:${NGINX_PORT:-80}/ && curl -f http://localhost:${NGINX_PORT:-80}/mcp"] + test: ["CMD", "sh", "-c", "curl -fsS http://localhost:${NGINX_PORT:-80}/healthz >/dev/null"] interval: 30s timeout: 10s retries: 3 diff --git a/docker/nginx.conf b/docker/nginx.conf index 03a766ab..fc3a4043 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -60,6 +60,17 @@ server { } } + location = /healthz { + auth_basic off; + + proxy_pass http://localhost:3000/healthz; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Docker环境状态检测(已移除,不再需要代理功能) # location = /api/docker-status { # add_header Content-Type 'application/json'; diff --git a/packages/mcp-server/src/health.ts b/packages/mcp-server/src/health.ts new file mode 100644 index 00000000..ba050d54 --- /dev/null +++ b/packages/mcp-server/src/health.ts @@ -0,0 +1,36 @@ +import type { Express } from 'express'; + +import type { CoreServicesManager } from './adapters/core-services.js'; + +export type MCPHealthStatus = Awaited>; +type HealthStatusProvider = Pick; + +function isHealthyStatus(healthStatus: MCPHealthStatus): boolean { + return healthStatus.initialized && Object.values(healthStatus.services).every(Boolean); +} + +export function buildHealthzResponse(healthStatus: MCPHealthStatus): { + statusCode: number; + body: MCPHealthStatus; +} { + return { + statusCode: isHealthyStatus(healthStatus) ? 200 : 503, + body: healthStatus + }; +} + +export function registerHealthzRoute(app: Express, healthProvider: HealthStatusProvider): void { + app.get('/healthz', async (_req, res) => { + try { + const healthStatus = await healthProvider.getHealthStatus(); + const { statusCode, body } = buildHealthzResponse(healthStatus); + res.status(statusCode).json(body); + } catch (error) { + res.status(503).json({ + initialized: false, + services: {}, + error: error instanceof Error ? error.message : 'Health check failed' + }); + } + }); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 7f8eb891..11876625 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -39,6 +39,7 @@ import { loadConfig, type MCPServerConfig } from './config/environment.js'; import * as logger from './utils/logging.js'; import { ParameterValidator } from './adapters/parameter-adapter.js'; import { getTemplateOptions, getDefaultTemplateId } from './config/templates.js'; +import { registerHealthzRoute } from './health.js'; import { randomUUID } from 'node:crypto'; import express from 'express'; @@ -377,6 +378,7 @@ async function main() { // 使用 Express 和会话管理支持多客户端连接 const app = express(); app.use(express.json()); + registerHealthzRoute(app, coreServices); logger.info('Express app configured'); // 存储每个会话的传输实例 diff --git a/packages/mcp-server/tests/health.test.ts b/packages/mcp-server/tests/health.test.ts new file mode 100644 index 00000000..742c823e --- /dev/null +++ b/packages/mcp-server/tests/health.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import express from 'express'; + +import { registerHealthzRoute } from '../src/health.js'; + +type TestHealthProvider = { + getHealthStatus: () => Promise<{ + initialized: boolean; + services: Record; + }>; +}; + +async function startHealthServer(provider: TestHealthProvider): Promise<{ + server: Server; + url: string; +}> { + const app = express(); + registerHealthzRoute(app, provider); + + const server = await new Promise((resolve) => { + const httpServer = app.listen(0, '127.0.0.1', () => resolve(httpServer)); + }); + + const { port } = server.address() as AddressInfo; + return { + server, + url: `http://127.0.0.1:${port}/healthz` + }; +} + +async function stopServer(server: Server | undefined): Promise { + if (!server) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +describe('MCP health route', () => { + let server: Server | undefined; + + afterEach(async () => { + await stopServer(server); + server = undefined; + }); + + it('returns 200 when core services are initialized and healthy', async () => { + const provider: TestHealthProvider = { + getHealthStatus: vi.fn().mockResolvedValue({ + initialized: true, + services: { + modelManager: true, + llmService: true, + languageService: true, + templateManager: true, + historyManager: true, + promptService: true + } + }) + }; + + const running = await startHealthServer(provider); + server = running.server; + + const response = await fetch(running.url); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + initialized: true, + services: { + modelManager: true, + llmService: true, + languageService: true, + templateManager: true, + historyManager: true, + promptService: true + } + }); + }); + + it('returns 503 when any required service is unhealthy', async () => { + const provider: TestHealthProvider = { + getHealthStatus: vi.fn().mockResolvedValue({ + initialized: true, + services: { + modelManager: true, + llmService: false + } + }) + }; + + const running = await startHealthServer(provider); + server = running.server; + + const response = await fetch(running.url); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + initialized: true, + services: { + modelManager: true, + llmService: false + } + }); + }); + + it('returns 503 when the health provider throws', async () => { + const provider: TestHealthProvider = { + getHealthStatus: vi.fn().mockRejectedValue(new Error('boom')) + }; + + const running = await startHealthServer(provider); + server = running.server; + + const response = await fetch(running.url); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + initialized: false, + services: {}, + error: 'boom' + }); + }); +});