All files / src/tools getServerHealth.ts

92.1% Statements 35/38
83.33% Branches 15/18
100% Functions 5/5
91.66% Lines 33/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202                                                              4x                                       24x 24x 312x 312x 22x 22x   312x 312x 312x   24x                                           24x 24x 24x                                   24x   1x   3x   16x     4x                                       26x 26x   2x 2x   2x 2x     2x                     24x 24x 24x 24x   24x                                 24x       4x                                          
/**
 * MCP Tool: get_server_health
 *
 * Returns server health status and per-feed availability diagnostics.
 * Does NOT make upstream API calls — reports cached status from recent
 * feed tool invocations.
 *
 * **Intelligence Perspective:** Operational awareness of data source
 * availability is critical for reliable intelligence products.
 *
 * **Business Perspective:** Provides dashboard-ready health metrics
 * and enables clients to adapt data collection strategies based on
 * current feed availability.
 *
 * ISMS Policy: MO-001 (Monitoring and Alerting), PE-001 (Performance Standards)
 *
 * @module tools/getServerHealth
 */
 
import { z } from 'zod';
import { SERVER_VERSION } from '../config.js';
import { feedHealthTracker } from '../services/FeedHealthTracker.js';
import type { AvailabilityLevel, FeedStatus } from '../services/FeedHealthTracker.js';
import { lifecycleWarmupScheduler } from '../services/LifecycleWarmupScheduler.js';
import { getLifecycleCacheStatus } from '../utils/lifecycleStatistics.js';
import type { LifecycleCacheState } from '../utils/lifecycleStatistics.js';
import { buildToolResponse } from './shared/responseBuilder.js';
import { ToolError } from './shared/errors.js';
import type { ToolResult } from './shared/types.js';
 
/** Zod schema for the (empty) input of this tool. */
export const GetServerHealthSchema = z.object({});
 
/**
 * Per-feed projection used in the `get_server_health` response.
 *
 * Preserves the existing `lastSuccess` / `lastError` / `lastAttempt`
 * fields (backward compatible) and adds a `lastProbedAt` alias for
 * `lastAttempt` so consumers can judge cache staleness
 * (see issue #1, recommendation 3).
 */
export interface FeedProjection {
  status: FeedStatus['status'];
  lastAttempt?: string;
  lastProbedAt?: string;
  lastSuccess?: string;
  lastError?: string;
}
 
/** Build the per-feed projection map from the tracker's raw statuses. */
function projectFeeds(feeds: Record<string, FeedStatus>): Record<string, FeedProjection> {
  const projection: Record<string, FeedProjection> = {};
  for (const [name, feed] of Object.entries(feeds)) {
    const entry: FeedProjection = { status: feed.status };
    if (feed.lastAttempt !== undefined) {
      entry.lastAttempt = feed.lastAttempt;
      entry.lastProbedAt = feed.lastAttempt;
    }
    if (feed.lastSuccess !== undefined) entry.lastSuccess = feed.lastSuccess;
    if (feed.lastError !== undefined) entry.lastError = feed.lastError;
    projection[name] = entry;
  }
  return projection;
}
 
/**
 * Lifecycle-statistics cache projection exposed in the health response.
 *
 * | Field                 | Meaning |
 * |-----------------------|---------|
 * | `state`               | `WARM` (cached & fresh), `STALE` (cached but TTL elapsed), `COLD` (never warmed) |
 * | `ageMs`               | Age of the cached model in ms; `null` when COLD |
 * | `corpusSize`          | Number of procedures the cached model was built from; `null` when COLD |
 * | `lastRefreshErrorAt`  | ISO-8601 timestamp of the last warmup-scheduler failure; `null` when none |
 */
export interface LifecycleCacheProjection {
  state: LifecycleCacheState;
  ageMs: number | null;
  corpusSize: number | null;
  lastRefreshErrorAt: string | null;
}
 
/** Build the lifecycle-cache projection consumed by the health response. */
function projectLifecycleCache(): LifecycleCacheProjection {
  const cache = getLifecycleCacheStatus();
  const { lastRefreshErrorAt } = lifecycleWarmupScheduler.getStatus();
  return {
    state: cache.state,
    ageMs: cache.ageMs,
    corpusSize: cache.corpusSize,
    lastRefreshErrorAt,
  };
}
 
/**
 * Derive the overall server status from the feed availability level.
 *
 * The `Unknown` level maps to `'unknown'` — distinct from `'unhealthy'` —
 * so consumers do not treat an empty health cache as a feeds outage.
 * Cyclomatic complexity: 4
 */
function deriveServerStatus(
  level: AvailabilityLevel,
): 'healthy' | 'degraded' | 'unhealthy' | 'unknown' {
  switch (level) {
    case 'Full':
      return 'healthy';
    case 'Unavailable':
      return 'unhealthy';
    case 'Unknown':
      return 'unknown';
    case 'Degraded':
    case 'Sparse':
      return 'degraded';
    default: {
      const exhaustiveCheck: never = level;
      throw new Error(`Unhandled availability level: ${String(exhaustiveCheck)}`);
    }
  }
}
 
/**
 * Handles the get_server_health MCP tool request.
 *
 * Returns a structured health snapshot including:
 * - Server version, uptime, and overall status
 * - Per-feed health status (ok / error / unknown)
 * - Aggregate availability level (Full / Degraded / Sparse / Unavailable)
 *
 * @param args - Validated against empty-object schema (no parameters accepted)
 * @returns MCP tool result with JSON health payload
 */
export async function handleGetServerHealth(args: unknown): Promise<ToolResult> {
  try {
    GetServerHealthSchema.parse(args ?? {});
  } catch (error: unknown) {
    Eif (error instanceof z.ZodError) {
      const fieldErrors = error.issues
        .map((e) => {
          const path = e.path.join('.');
          return path ? `${path}: ${e.message}` : e.message;
        })
        .join('; ');
      throw new ToolError({
        toolName: 'get_server_health',
        operation: 'validateInput',
        message: `Invalid parameters: ${fieldErrors}`,
        isRetryable: false,
        cause: error,
      });
    }
    throw error;
  }
 
  const feeds = feedHealthTracker.getAllStatuses();
  const availability = feedHealthTracker.getAvailability();
  const feedsProjection = projectFeeds(feeds);
  const lifecycleCache = projectLifecycleCache();
 
  const result = {
    server: {
      version: SERVER_VERSION,
      uptime_seconds: feedHealthTracker.getUptimeSeconds(),
      status: deriveServerStatus(availability.level),
    },
    feeds: feedsProjection,
    availability: {
      operational_feeds: availability.operationalFeeds,
      error_feeds: availability.errorFeeds,
      unknown_feeds: availability.unknownFeeds,
      total_feeds: availability.totalFeeds,
      level: availability.level,
    },
    lifecycleCache,
  };
 
  return await Promise.resolve(buildToolResponse(result));
}
 
/** Tool metadata for MCP registration. */
export const getServerHealthToolMetadata = {
  name: 'get_server_health',
  description:
    'Check server health and feed availability status. Returns server version, uptime, ' +
    'per-feed health status (ok/error/unknown), overall availability level ' +
    '(Full/Degraded/Sparse/Unavailable/Unknown), and a `lifecycleCache` block ' +
    '({state: WARM|STALE|COLD, ageMs, corpusSize, lastRefreshErrorAt}) reporting ' +
    'the state of the corpus-wide lifecycle-statistics cache that feeds ' +
    '`monitor_legislative_pipeline`. Per-feed `lastProbedAt` and ' +
    '`lastAttempt` staleness timestamps are included only once a feed has been probed ' +
    '(absent for never-probed feeds). `Unknown` is reported when no feeds have been ' +
    'probed yet (cache empty) and must NOT be interpreted as an outage — consumers ' +
    'should attempt at least one feed probe before treating the server as down. Does ' +
    'not make upstream API calls — reports cached status from recent tool invocations. ' +
    'Use this to check which feeds are healthy before making data requests and to ' +
    'adapt data collection strategy.',
  inputSchema: {
    type: 'object' as const,
    properties: {},
  },
};