All files / src/tools getEventsFeed.ts

98.24% Statements 56/57
85.13% Branches 63/74
100% Functions 11/11
98.07% Lines 51/52

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 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291                                                                          1x   1x           1x         1x 1x                           6x                                 3x                           3x 2x   1x 1x       2x 2x 1x 1x       3x                                   3x 3x   3x 3x                                                         1x                                         8x 2x                         6x 1x             5x 3x     2x 1x     1x                       21x 21x   1x 1x 1x                     20x 20x 20x 20x 20x 20x     12x 1x 1x   11x 11x 11x   8x 8x 1x                   4x                                          
/**
 * MCP Tool: get_events_feed
 *
 * Get recently updated European Parliament events from the feed.
 *
 * **EP API Endpoint:**
 * - `GET /events/feed`
 *
 * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
 */
 
import { GetEventsFeedSchema } from '../schemas/europeanParliament.js';
import { epClient } from '../clients/europeanParliamentClient.js';
import { ToolError } from './shared/errors.js';
import {
  isUpstream404,
  buildEmptyFeedResponse,
  isErrorInBody,
  buildFeedSuccessResponse,
  extractUpstreamStatusCode,
  type FeedErrorMeta,
} from './shared/feedUtils.js';
import { APIError } from '../clients/ep/baseClient.js';
import { TimeoutError } from '../utils/timeout.js';
import { z } from 'zod';
import type { ToolResult } from './shared/types.js';
 
type EventsFeedParams = ReturnType<typeof GetEventsFeedSchema.parse>;
type EventsFeedTimeframe = EventsFeedParams['timeframe'];
 
/**
 * Build an in-band response for an error-in-body reply.
 *
 * @param rawError - Raw EP API error string from the response body
 * @internal
 */
function buildEnrichmentFailedResponse(rawError: string): ToolResult {
  const upstreamStatusCode = extractUpstreamStatusCode(rawError);
  const upstream =
    upstreamStatusCode !== undefined || rawError !== ''
      ? {
          ...(upstreamStatusCode !== undefined && { statusCode: upstreamStatusCode }),
          ...(rawError !== '' && { errorMessage: rawError }),
        }
      : undefined;
  const meta: FeedErrorMeta = {
    errorCode: 'ENRICHMENT_FAILED',
    retryable: true,
    ...(upstream !== undefined ? { upstream } : {}),
  };
  const errorSuffix = rawError ? ` (upstream: ${rawError})` : '';
  return buildEmptyFeedResponse(
    `EP API returned an error-in-body response for get_events_feed — the upstream enrichment step may have failed${errorSuffix}.`,
    meta,
  );
}
 
/**
 * Detect timeout failures surfaced either directly or via the EP API wrapper.
 *
 * @param error - Error thrown by the EP API client or timeout utility
 * @returns true when the failure should be classified as UPSTREAM_TIMEOUT
 * @internal
 */
function isTimeoutLikeError(error: unknown): boolean {
  return (
    error instanceof TimeoutError ||
    (error instanceof APIError && error.statusCode === 408)
  );
}
 
/**
 * Detect whether an APIError(429) originated from the local token-bucket
 * rate limiter rather than an upstream EP API response.
 *
 * BaseEPClient throws `APIError('Rate limit exceeded. Retry after …', 429)`
 * when the local limiter rejects a request *before* any HTTP call is made.
 * Upstream 429s use the standard `EP API request failed: 429` format.
 *
 * @internal
 */
function isLocalRateLimit(error: APIError): boolean {
  return error.message.startsWith('Rate limit exceeded');
}
 
/**
 * Extract the suggested retry delay (in ms) from a rate-limit APIError.
 *
 * BaseEPClient attaches `{ retryAfterMs, remainingTokens }` to `error.details`
 * for local token-bucket rejections; this helper reads from `details` first
 * and falls back to parsing the `Retry after <n>ms` substring from the
 * message when `details` is unavailable (e.g. mocked test fixtures).
 *
 * @internal
 */
function extractRetryAfterMsFromDetails(details: unknown): number | undefined {
  if (details === null || typeof details !== 'object' || !('retryAfterMs' in details)) {
    return undefined;
  }
  const ms = (details as { retryAfterMs?: unknown }).retryAfterMs;
  return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined;
}
 
function extractRetryAfterMsFromMessage(message: string): number | undefined {
  const match = /Retry after (\d+)\s*ms/i.exec(message);
  if (match?.[1] === undefined) return undefined;
  const parsed = parseInt(match[1], 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
 
function extractRetryAfterMs(error: APIError): number | undefined {
  return extractRetryAfterMsFromDetails(error.details) ?? extractRetryAfterMsFromMessage(error.message);
}
 
/**
 * Build the uniform feed envelope for HTTP 429 rate limits.
 *
 * Distinguishes local token-bucket rejections from upstream EP API 429s so
 * downstream consumers get accurate diagnostics. When the underlying error
 * exposes a `retryAfterMs` hint (always present for local limiter rejections),
 * surface it in both the human-readable `reason` and the machine-readable
 * `retryAfterMs` metadata field so automated clients can schedule retries
 * precisely instead of guessing.
 *
 * @param error - APIError carrying the rate-limit failure details
 * @returns MCP ToolResult with RATE_LIMIT metadata for downstream retry logic
 * @internal
 */
function buildRateLimitResponse(error: APIError): ToolResult {
  const local = isLocalRateLimit(error);
  const retryAfterMs = extractRetryAfterMs(error);
  const retryHint =
    retryAfterMs !== undefined ? `retry after ${String(retryAfterMs)}ms` : 'retry after a short delay';
  return buildEmptyFeedResponse(
    local
      ? `Local rate limit reached for get_events_feed — ${retryHint}.`
      : `EP API rate limit reached for get_events_feed — ${retryHint}.`,
    {
      errorCode: 'RATE_LIMIT',
      retryable: true,
      ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
      ...(!local
        ? {
            upstream: {
              statusCode: 429,
              ...(error.message ? { errorMessage: error.message } : {}),
            },
          }
        : {}),
    },
  );
}
 
/**
 * Build the uniform feed envelope for retryable upstream 5xx failures.
 *
 * @param error - APIError carrying the upstream failure details
 * @param statusCode - HTTP 5xx status code to expose in the envelope
 * @returns MCP ToolResult with UPSTREAM_ERROR metadata
 * @internal
 */
function buildUpstreamErrorResponse(error: APIError, statusCode: number): ToolResult {
  return buildEmptyFeedResponse(
    `EP API upstream error for get_events_feed — retry later or use get_events with limit/offset as a fallback.`,
    {
      errorCode: 'UPSTREAM_ERROR',
      retryable: true,
      upstream: {
        statusCode,
        ...(error.message ? { errorMessage: error.message } : {}),
      },
    },
  );
}
 
/**
 * Classify transient upstream errors into the uniform feed envelope.
 *
 * @param error - Caught error from the EP API client
 * @returns In-band ToolResult for known transient failures, or null
 * @internal
 */
function handleUpstreamCatchError(error: unknown): ToolResult | null {
  if (isUpstream404(error)) {
    return buildEmptyFeedResponse(
      `EP API returned 404 for get_events_feed — no data available for the requested feed window.`,
      {
        errorCode: 'NOT_FOUND',
        retryable: false,
        upstream: {
          statusCode: 404,
          ...(error instanceof APIError && error.message ? { errorMessage: error.message } : {}),
        },
      },
    );
  }
 
  if (isTimeoutLikeError(error)) {
    return buildEmptyFeedResponse(
      `EP API request timed out for get_events_feed — the endpoint is known to be slow. ` +
        `Consider retrying with timeframe="one-week" or using get_events with limit/offset as a fallback.`,
      { errorCode: 'UPSTREAM_TIMEOUT', retryable: true },
    );
  }
 
  if (error instanceof APIError && error.statusCode === 429) {
    return buildRateLimitResponse(error);
  }
 
  if (error instanceof APIError && error.statusCode !== undefined && error.statusCode >= 500) {
    return buildUpstreamErrorResponse(error, error.statusCode);
  }
 
  return null;
}
 
/**
 * Handles the get_events_feed MCP tool request.
 *
 * @param args - Raw tool arguments, validated against {@link GetEventsFeedSchema}
 * @returns MCP tool result containing recently updated event data
 * @security Input is validated with Zod before any API call.
 */
export async function handleGetEventsFeed(args: unknown): Promise<ToolResult> {
  let params: EventsFeedParams;
  try {
    params = GetEventsFeedSchema.parse(args);
  } catch (error: unknown) {
    Eif (error instanceof z.ZodError) {
      const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
      throw new ToolError({
        toolName: 'get_events_feed',
        operation: 'validateInput',
        message: `Invalid parameters: ${fieldErrors}`,
        isRetryable: false,
        cause: error,
      });
    }
    throw error;
  }
 
  try {
    const apiParams: Record<string, unknown> = {};
    apiParams['timeframe'] = params.timeframe;
    if (params.startDate !== undefined) apiParams['startDate'] = params.startDate;
    if (params.activityType !== undefined) apiParams['activityType'] = params.activityType;
    const result = await epClient.getEventsFeed(
      apiParams
    );
    if (isErrorInBody(result)) {
      const rawError = typeof result['error'] === 'string' ? result['error'] : '';
      return buildEnrichmentFailedResponse(rawError);
    }
    const timeframeLabel: EventsFeedTimeframe = params.timeframe;
    const emptyReason = `EP API events/feed returned no data for timeframe '${timeframeLabel}' — no events were updated in the requested period. Use get_events (with limit/offset) to browse a paginated list of events as a fallback.`;
    return buildFeedSuccessResponse(result, [], emptyReason);
  } catch (error: unknown) {
    const inBand = handleUpstreamCatchError(error);
    if (inBand !== null) return inBand;
    throw new ToolError({
      toolName: 'get_events_feed',
      operation: 'fetchData',
      message: 'Failed to retrieve events feed',
      isRetryable: true,
      cause: error,
    });
  }
}
/** Tool metadata for get_events_feed */
export const getEventsFeedToolMetadata = {
  name: 'get_events_feed',
  description:
    'Get recently updated European Parliament events from the feed. Returns events published or updated during the specified timeframe. Data source: European Parliament Open Data Portal. NOTE: The EP API events/feed endpoint is significantly slower than other feeds, so this tool uses the global EP request timeout (default 60 seconds) and normalizes timeout/rate-limit/upstream failures into the feed envelope. For faster fallback browsing, use get_events with limit/offset.',
  inputSchema: {
    type: 'object' as const,
    properties: {
      timeframe: {
        type: 'string',
        description: 'Timeframe for the feed (today, one-day, one-week, one-month, custom)',
        enum: ['today', 'one-day', 'one-week', 'one-month', 'custom'],
        default: 'one-week',
      },
      startDate: {
        type: 'string',
        description: 'Start date (YYYY-MM-DD) — required when timeframe is "custom"',
      },
      activityType: { type: 'string', description: 'Activity type filter' },
    },
  },
};