All files / src/mcp/ep error-classifier.ts

96.15% Statements 25/26
97.36% Branches 37/38
100% Functions 2/2
95.45% Lines 21/22

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                                                          130x 130x 1x   129x         1x   128x     128x             6x   122x           1x   121x 101x 90x                                                     128x 128x   126x   125x 125x 125x           2x     123x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module MCP/ep/error-classifier
 * @description Error classification and feed-unavailability detection for EP MCP tools.
 */
 
import type { MCPToolResult } from '../../types/index.js';
import { _parseResultPayload } from './parse.js';
 
/**
 * Classify an error message into a diagnostic error category.
 *
 * Maps EP MCP Server v1.3.10 structured error codes and generic HTTP/network
 * errors into one of six broad categories used for logging and retry decisions:
 *
 * Returned categories (priority order):
 * 1. `INTERNAL_ERROR` — EP MCP `INTERNAL_ERROR` (catch-all for DNS, TLS, unclassified upstream failures)
 * 2. `SERVER_ERROR`   — EP MCP `UPSTREAM_500`/`UPSTREAM_503`/`SERVER_ERROR`, or gateway 5xx patterns
 * 3. `TIMEOUT`        — EP MCP `UPSTREAM_TIMEOUT`, or generic "timeout" strings
 * 4. `RATE_LIMIT`     — EP MCP `RATE_LIMITED`, HTTP 429, or "rate limit"/"too many requests" strings
 * 5. `NOT_FOUND`      — EP MCP `UPSTREAM_404`, or generic "404" strings
 * 6. `UNKNOWN`        — everything else
 *
 * @param message - Raw error message
 * @returns Diagnostic error category string
 */
export function classifyToolError(message: string): string {
  const lowerMsg = message.toLowerCase();
  if (lowerMsg.includes('internal_error')) {
    return 'INTERNAL_ERROR';
  }
  if (
    lowerMsg.includes('upstream_500') ||
    lowerMsg.includes('upstream_503') ||
    lowerMsg.includes('server_error')
  ) {
    return 'SERVER_ERROR';
  }
  Iif (lowerMsg.includes('upstream_timeout')) {
    return 'TIMEOUT';
  }
  if (
    lowerMsg.includes('gateway timeout') ||
    lowerMsg.includes('gateway error 500') ||
    lowerMsg.includes('gateway error 502') ||
    lowerMsg.includes('gateway error 503') ||
    lowerMsg.includes('gateway error 504')
  ) {
    return 'SERVER_ERROR';
  }
  if (
    lowerMsg.includes('429') ||
    lowerMsg.includes('rate limit') ||
    lowerMsg.includes('too many requests') ||
    lowerMsg.includes('rate_limited')
  ) {
    return 'RATE_LIMIT';
  }
  if (lowerMsg.includes('404') || lowerMsg.includes('upstream_404')) return 'NOT_FOUND';
  if (lowerMsg.includes('timeout')) return 'TIMEOUT';
  return 'UNKNOWN';
}
 
/**
 * Detect whether an MCP feed result represents an "unavailable" response,
 * covering the two shapes historically emitted by the EP MCP server.
 *
 * 1. **Uniform envelope** (all feeds as of
 *    `european-parliament-mcp-server@1.3.10`) —
 *    `{status:"unavailable", items:[], generatedAt:"..."}` established by
 *    Hack23/European-Parliament-MCP-Server#301 and extended to
 *    `get_events_feed`/`get_procedures_feed` by
 *    Hack23/European-Parliament-MCP-Server#380 (which closed #378).
 * 2. **Pre-v1.2.13 raw upstream 404 shape** (historically emitted pre-v1.2.13 by
 *    `get_events_feed` / `get_procedures_feed`, fixed upstream in PR #380) —
 *    `{"@id":"https://data.europarl.europa.eu/eli/dl/...","error":"404 N..."}`.
 *    Retained purely as defense-in-depth for older pinned server versions or
 *    any future regression of #378, so such payloads do not silently poison
 *    downstream analysis.
 *
 * Returning `true` from this helper lets callers treat both shapes as
 * "known-empty" rather than "success with garbage payload".
 *
 * @param result - Raw MCP tool result
 * @returns `true` when the payload matches either unavailable envelope
 */
export function isFeedUnavailable(result: MCPToolResult | undefined): boolean {
  const envelope = _parseResultPayload(result);
  if (!envelope) return false;
 
  if (envelope['status'] === 'unavailable') return true;
 
  const error = envelope['error'];
  const idField = envelope['@id'];
  if (
    typeof error === 'string' &&
    typeof idField === 'string' &&
    idField.startsWith('https://data.europarl.europa.eu/') &&
    error.includes('404')
  ) {
    return true;
  }
 
  return false;
}